diff --git a/migration/1586641139527-remote-reaction.ts b/migration/1586641139527-remote-reaction.ts new file mode 100644 index 0000000000..5a7fb36e35 --- /dev/null +++ b/migration/1586641139527-remote-reaction.ts @@ -0,0 +1,12 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class remoteReaction1586641139527 implements MigrationInterface { + name = 'remoteReaction1586641139527' + public async up(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(260)`, undefined); + } + + public async down(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(130)`, undefined); + } +} diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 18d5cc34ba..a39520fb4c 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -301,6 +301,14 @@ export default Vue.extend({ case 'reacted': { const reaction = body.reaction; + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + emojis.push(body.emoji); + Vue.set(this.appearNote, 'emojis', emojis); + } + } + if (this.appearNote.reactions == null) { Vue.set(this.appearNote, 'reactions', {}); } diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue index f415887e76..97a2fe1873 100644 --- a/src/client/components/notification.vue +++ b/src/client/components/notification.vue @@ -12,7 +12,7 @@ <fa :icon="faReply" v-else-if="notification.type === 'reply'"/> <fa :icon="faAt" v-else-if="notification.type === 'mention'"/> <fa :icon="faQuoteLeft" v-else-if="notification.type === 'quote'"/> - <x-reaction-icon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/> + <x-reaction-icon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction" :customEmojis="notification.note.emojis" :no-style="true"/> </div> </div> <div class="tail"> diff --git a/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue index 9155c59440..3c6d56b80a 100644 --- a/src/client/components/reaction-icon.vue +++ b/src/client/components/reaction-icon.vue @@ -1,5 +1,5 @@ <template> -<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :is-reaction="true" :normal="true" :no-style="noStyle"/> +<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :customEmojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> </template> <script lang="ts"> @@ -12,6 +12,10 @@ export default Vue.extend({ type: String, required: true }, + customEmojis: { + required: false, + default: () => [] + }, noStyle: { type: Boolean, required: false, diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue index 7494fde25f..67774cbb39 100644 --- a/src/client/components/reactions-viewer.reaction.vue +++ b/src/client/components/reactions-viewer.reaction.vue @@ -9,7 +9,7 @@ ref="reaction" v-particle > - <x-reaction-icon :reaction="reaction" ref="icon"/> + <x-reaction-icon :reaction="reaction" :customEmojis="note.emojis" ref="icon"/> <span>{{ count }}</span> </button> </template> diff --git a/src/misc/reaction-lib.ts b/src/misc/reaction-lib.ts index 43dbe1cc2c..e5da5ca4aa 100644 --- a/src/misc/reaction-lib.ts +++ b/src/misc/reaction-lib.ts @@ -1,6 +1,7 @@ import { emojiRegex } from './emoji-regex'; import { fetchMeta } from './fetch-meta'; import { Emojis } from '../models'; +import { toPunyNullable } from './convert-host'; const legacies: Record<string, string> = { 'like': '👍', @@ -40,12 +41,20 @@ export function convertLegacyReactions(reactions: Record<string, number>) { } } - return _reactions; + const _reactions2 = {} as Record<string, number>; + + for (const reaction of Object.keys(_reactions)) { + _reactions2[decodeReaction(reaction).reaction] = _reactions[reaction]; + } + + return _reactions2; } -export async function toDbReaction(reaction?: string | null): Promise<string> { +export async function toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { if (reaction == null) return await getFallbackReaction(); + reacterHost = toPunyNullable(reacterHost); + // 文字列タイプのリアクションを絵文字に変換 if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; @@ -61,18 +70,58 @@ export async function toDbReaction(reaction?: string | null): Promise<string> { const custom = reaction.match(/^:([\w+-]+):$/); if (custom) { + const name = custom[1]; const emoji = await Emojis.findOne({ - host: null, - name: custom[1], + host: reacterHost || null, + name, }); - if (emoji) return reaction; + if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:` } return await getFallbackReaction(); } +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +export function decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + + if (custom) { + const name = custom[1]; + const host = custom[2] || null; + + return { + reaction: `:${name}@${host || '.'}:`, // ローカル分は@以降を省略するのではなく.にする + name, + host + }; + } + + return { + reaction: str, + name: undefined, + host: undefined + }; +} + export function convertLegacyReaction(reaction: string): string { + reaction = decodeReaction(reaction).reaction; if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; return reaction; } diff --git a/src/models/entities/note-reaction.ts b/src/models/entities/note-reaction.ts index 995748760c..ed38450bb2 100644 --- a/src/models/entities/note-reaction.ts +++ b/src/models/entities/note-reaction.ts @@ -36,7 +36,7 @@ export class NoteReaction { public note: Note | null; @Column('varchar', { - length: 130 + length: 260 }) public reaction: string; } diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts index 2aad5c0fa3..d29a48b7ec 100644 --- a/src/models/repositories/note.ts +++ b/src/models/repositories/note.ts @@ -5,9 +5,11 @@ import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { awaitAll } from '../../prelude/await-all'; -import { convertLegacyReaction, convertLegacyReactions } from '../../misc/reaction-lib'; +import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '../../misc/reaction-lib'; import { toString } from '../../mfm/toString'; import { parse } from '../../mfm/parse'; +import { Emoji } from '../entities/emoji'; +import { concat } from '../../prelude/array'; export type PackedNote = SchemaType<typeof packedNoteSchema>; @@ -129,31 +131,61 @@ export class NoteRepository extends Repository<Note> { }; } + /** + * 添付用emojisを解決する + * @param emojiNames Note等に添付されたカスタム絵文字名 (:は含めない) + * @param noteUserHost Noteのホスト + * @param reactionNames Note等にリアクションされたカスタム絵文字名 (:は含めない) + */ async function populateEmojis(emojiNames: string[], noteUserHost: string | null, reactionNames: string[]) { - const where = [] as {}[]; + let all = [] as { + name: string, + url: string + }[]; + // カスタム絵文字 if (emojiNames?.length > 0) { - where.push({ - name: In(emojiNames), - host: noteUserHost - }); + const tmp = await Emojis.find({ + where: { + name: In(emojiNames), + host: noteUserHost + }, + select: ['name', 'host', 'url'] + }).then(emojis => emojis.map((emoji: Emoji) => { + return { + name: emoji.name, + url: emoji.url, + }; + })); + + all = concat([all, tmp]); } - reactionNames = reactionNames?.filter(x => x.match(/^:[^:]+:$/)).map(x => x.replace(/:/g, '')); + const customReactions = reactionNames?.map(x => decodeReaction(x)).filter(x => x.name); - if (reactionNames?.length > 0) { - where.push({ - name: In(reactionNames), - host: null - }); + if (customReactions?.length > 0) { + const where = [] as {}[]; + + for (const customReaction of customReactions) { + where.push({ + name: customReaction.name, + host: customReaction.host + }); + } + + const tmp = await Emojis.find({ + where, + select: ['name', 'host', 'url'] + }).then(emojis => emojis.map((emoji: Emoji) => { + return { + name: `${emoji.name}@${emoji.host || '.'}`, // @host付きでローカルは. + url: emoji.url, + }; + })); + all = concat([all, tmp]); } - if (where.length === 0) return []; - - return Emojis.find({ - where, - select: ['name', 'host', 'url', 'aliases'] - }); + return all; } async function populateMyReaction() { diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts index b25f80aedc..a877110303 100644 --- a/src/remote/activitypub/kernel/like.ts +++ b/src/remote/activitypub/kernel/like.ts @@ -1,7 +1,7 @@ import { IRemoteUser } from '../../../models/entities/user'; import { ILike, getApId } from '../type'; import create from '../../../services/note/reaction/create'; -import { fetchNote } from '../models/note'; +import { fetchNote, extractEmojis } from '../models/note'; export default async (actor: IRemoteUser, activity: ILike) => { const targetUri = getApId(activity.object); @@ -11,6 +11,8 @@ export default async (actor: IRemoteUser, activity: ILike) => { if (actor.id === note.userId) return `skip: cannot react to my note`; + await extractEmojis(activity.tag || [], actor.host).catch(() => null); + await create(actor, note, activity._misskey_reaction || activity.content || activity.name); return `ok`; }; diff --git a/src/remote/activitypub/renderer/like.ts b/src/remote/activitypub/renderer/like.ts index e36a3ab0d6..d4dd3663d4 100644 --- a/src/remote/activitypub/renderer/like.ts +++ b/src/remote/activitypub/renderer/like.ts @@ -1,12 +1,30 @@ import config from '../../../config'; import { NoteReaction } from '../../../models/entities/note-reaction'; import { Note } from '../../../models/entities/note'; +import { Emojis } from '../../../models'; +import renderEmoji from './emoji'; -export const renderLike = (noteReaction: NoteReaction, note: Note) => ({ - type: 'Like', - id: `${config.url}/likes/${noteReaction.id}`, - actor: `${config.url}/users/${noteReaction.userId}`, - object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, - content: noteReaction.reaction, - _misskey_reaction: noteReaction.reaction -}); +export const renderLike = async (noteReaction: NoteReaction, note: Note) => { + const reaction = noteReaction.reaction; + + const object = { + type: 'Like', + id: `${config.url}/likes/${noteReaction.id}`, + actor: `${config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, + content: reaction, + _misskey_reaction: reaction + } as any; + + if (reaction.startsWith(':')) { + const name = reaction.replace(/:/g, ''); + const emoji = await Emojis.findOne({ + name, + host: null + }); + + if (emoji) object.tag = [ renderEmoji(emoji) ]; + } + + return object; +}; diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index 72750affe0..70cb1adf4b 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -4,10 +4,10 @@ import { renderLike } from '../../../remote/activitypub/renderer/like'; import DeliverManager from '../../../remote/activitypub/deliver-manager'; import { renderActivity } from '../../../remote/activitypub/renderer'; import { IdentifiableError } from '../../../misc/identifiable-error'; -import { toDbReaction } from '../../../misc/reaction-lib'; +import { toDbReaction, decodeReaction } from '../../../misc/reaction-lib'; import { User, IRemoteUser } from '../../../models/entities/user'; import { Note } from '../../../models/entities/note'; -import { NoteReactions, Users, NoteWatchings, Notes, UserProfiles } from '../../../models'; +import { NoteReactions, Users, NoteWatchings, Notes, UserProfiles, Emojis } from '../../../models'; import { Not } from 'typeorm'; import { perUserReactionsChart } from '../../chart'; import { genId } from '../../../misc/gen-id'; @@ -20,7 +20,7 @@ export default async (user: User, note: Note, reaction?: string) => { throw new IdentifiableError('2d8e7297-1873-4c00-8404-792c68d7bef0', 'cannot react to my note'); } - reaction = await toDbReaction(reaction); + reaction = await toDbReaction(reaction, user.host); const exist = await NoteReactions.findOne({ noteId: note.id, @@ -59,8 +59,27 @@ export default async (user: User, note: Note, reaction?: string) => { perUserReactionsChart.update(user, note); + // カスタム絵文字リアクションだったら絵文字情報も送る + const decodedReaction = decodeReaction(reaction); + + let emoji = await Emojis.findOne({ + where: { + name: decodedReaction.name, + host: decodedReaction.host + }, + select: ['name', 'host', 'url'] + }); + + if (emoji) { + emoji = { + name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}`, + url: emoji.url + } as any; + } + publishNoteStream(note.id, 'reacted', { reaction: reaction, + emoji: emoji, userId: user.id }); @@ -96,7 +115,7 @@ export default async (user: User, note: Note, reaction?: string) => { //#region 配信 if (Users.isLocalUser(user) && !note.localOnly) { - const content = renderActivity(renderLike(inserted, note)); + const content = renderActivity(await renderLike(inserted, note)); const dm = new DeliverManager(user, content); if (note.userHost !== null) { const reactee = await Users.findOne(note.userId) diff --git a/src/services/note/reaction/delete.ts b/src/services/note/reaction/delete.ts index 09566e07ba..fd6628c71f 100644 --- a/src/services/note/reaction/delete.ts +++ b/src/services/note/reaction/delete.ts @@ -44,7 +44,7 @@ export default async (user: User, note: Note) => { //#region 配信 if (Users.isLocalUser(user) && !note.localOnly) { - const content = renderActivity(renderUndo(renderLike(exist, note), user)); + const content = renderActivity(renderUndo(await renderLike(exist, note), user)); const dm = new DeliverManager(user, content); if (note.userHost !== null) { const reactee = await Users.findOne(note.userId)