リモートのカスタム絵文字リアクションを表示できるように (#6239)

* リモートのカスタム絵文字リアクションを表示できるように

* AP

* DBマイグレーション

* ローカルのリアクションの.

* fix

* fix

* fix

* space
This commit is contained in:
MeiMei 2020-04-14 00:42:59 +09:00 committed by GitHub
parent d9ecb62a65
commit 2905e56a72
12 changed files with 185 additions and 41 deletions

View file

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

View file

@ -301,6 +301,14 @@ export default Vue.extend({
case 'reacted': { case 'reacted': {
const reaction = body.reaction; 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) { if (this.appearNote.reactions == null) {
Vue.set(this.appearNote, 'reactions', {}); Vue.set(this.appearNote, 'reactions', {});
} }

View file

@ -12,7 +12,7 @@
<fa :icon="faReply" v-else-if="notification.type === 'reply'"/> <fa :icon="faReply" v-else-if="notification.type === 'reply'"/>
<fa :icon="faAt" v-else-if="notification.type === 'mention'"/> <fa :icon="faAt" v-else-if="notification.type === 'mention'"/>
<fa :icon="faQuoteLeft" v-else-if="notification.type === 'quote'"/> <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> </div>
<div class="tail"> <div class="tail">

View file

@ -1,5 +1,5 @@
<template> <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> </template>
<script lang="ts"> <script lang="ts">
@ -12,6 +12,10 @@ export default Vue.extend({
type: String, type: String,
required: true required: true
}, },
customEmojis: {
required: false,
default: () => []
},
noStyle: { noStyle: {
type: Boolean, type: Boolean,
required: false, required: false,

View file

@ -9,7 +9,7 @@
ref="reaction" ref="reaction"
v-particle v-particle
> >
<x-reaction-icon :reaction="reaction" ref="icon"/> <x-reaction-icon :reaction="reaction" :customEmojis="note.emojis" ref="icon"/>
<span>{{ count }}</span> <span>{{ count }}</span>
</button> </button>
</template> </template>

View file

@ -1,6 +1,7 @@
import { emojiRegex } from './emoji-regex'; import { emojiRegex } from './emoji-regex';
import { fetchMeta } from './fetch-meta'; import { fetchMeta } from './fetch-meta';
import { Emojis } from '../models'; import { Emojis } from '../models';
import { toPunyNullable } from './convert-host';
const legacies: Record<string, string> = { const legacies: Record<string, string> = {
'like': '👍', '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(); if (reaction == null) return await getFallbackReaction();
reacterHost = toPunyNullable(reacterHost);
// 文字列タイプのリアクションを絵文字に変換 // 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; 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+-]+):$/); const custom = reaction.match(/^:([\w+-]+):$/);
if (custom) { if (custom) {
const name = custom[1];
const emoji = await Emojis.findOne({ const emoji = await Emojis.findOne({
host: null, host: reacterHost || null,
name: custom[1], name,
}); });
if (emoji) return reaction; if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`
} }
return await getFallbackReaction(); 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 { export function convertLegacyReaction(reaction: string): string {
reaction = decodeReaction(reaction).reaction;
if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
return reaction; return reaction;
} }

View file

@ -36,7 +36,7 @@ export class NoteReaction {
public note: Note | null; public note: Note | null;
@Column('varchar', { @Column('varchar', {
length: 130 length: 260
}) })
public reaction: string; public reaction: string;
} }

View file

@ -5,9 +5,11 @@ import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls
import { ensure } from '../../prelude/ensure'; import { ensure } from '../../prelude/ensure';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
import { awaitAll } from '../../prelude/await-all'; 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 { toString } from '../../mfm/toString';
import { parse } from '../../mfm/parse'; import { parse } from '../../mfm/parse';
import { Emoji } from '../entities/emoji';
import { concat } from '../../prelude/array';
export type PackedNote = SchemaType<typeof packedNoteSchema>; 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[]) { async function populateEmojis(emojiNames: string[], noteUserHost: string | null, reactionNames: string[]) {
const where = [] as {}[]; let all = [] as {
name: string,
url: string
}[];
// カスタム絵文字
if (emojiNames?.length > 0) { if (emojiNames?.length > 0) {
where.push({ const tmp = await Emojis.find({
name: In(emojiNames), where: {
host: noteUserHost 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) { if (customReactions?.length > 0) {
where.push({ const where = [] as {}[];
name: In(reactionNames),
host: null 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 all;
return Emojis.find({
where,
select: ['name', 'host', 'url', 'aliases']
});
} }
async function populateMyReaction() { async function populateMyReaction() {

View file

@ -1,7 +1,7 @@
import { IRemoteUser } from '../../../models/entities/user'; import { IRemoteUser } from '../../../models/entities/user';
import { ILike, getApId } from '../type'; import { ILike, getApId } from '../type';
import create from '../../../services/note/reaction/create'; 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) => { export default async (actor: IRemoteUser, activity: ILike) => {
const targetUri = getApId(activity.object); 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`; 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); await create(actor, note, activity._misskey_reaction || activity.content || activity.name);
return `ok`; return `ok`;
}; };

View file

@ -1,12 +1,30 @@
import config from '../../../config'; import config from '../../../config';
import { NoteReaction } from '../../../models/entities/note-reaction'; import { NoteReaction } from '../../../models/entities/note-reaction';
import { Note } from '../../../models/entities/note'; import { Note } from '../../../models/entities/note';
import { Emojis } from '../../../models';
import renderEmoji from './emoji';
export const renderLike = (noteReaction: NoteReaction, note: Note) => ({ export const renderLike = async (noteReaction: NoteReaction, note: Note) => {
type: 'Like', const reaction = noteReaction.reaction;
id: `${config.url}/likes/${noteReaction.id}`,
actor: `${config.url}/users/${noteReaction.userId}`, const object = {
object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, type: 'Like',
content: noteReaction.reaction, id: `${config.url}/likes/${noteReaction.id}`,
_misskey_reaction: noteReaction.reaction 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;
};

View file

@ -4,10 +4,10 @@ import { renderLike } from '../../../remote/activitypub/renderer/like';
import DeliverManager from '../../../remote/activitypub/deliver-manager'; import DeliverManager from '../../../remote/activitypub/deliver-manager';
import { renderActivity } from '../../../remote/activitypub/renderer'; import { renderActivity } from '../../../remote/activitypub/renderer';
import { IdentifiableError } from '../../../misc/identifiable-error'; 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 { User, IRemoteUser } from '../../../models/entities/user';
import { Note } from '../../../models/entities/note'; 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 { Not } from 'typeorm';
import { perUserReactionsChart } from '../../chart'; import { perUserReactionsChart } from '../../chart';
import { genId } from '../../../misc/gen-id'; 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'); 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({ const exist = await NoteReactions.findOne({
noteId: note.id, noteId: note.id,
@ -59,8 +59,27 @@ export default async (user: User, note: Note, reaction?: string) => {
perUserReactionsChart.update(user, note); 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', { publishNoteStream(note.id, 'reacted', {
reaction: reaction, reaction: reaction,
emoji: emoji,
userId: user.id userId: user.id
}); });
@ -96,7 +115,7 @@ export default async (user: User, note: Note, reaction?: string) => {
//#region 配信 //#region 配信
if (Users.isLocalUser(user) && !note.localOnly) { 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); const dm = new DeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await Users.findOne(note.userId) const reactee = await Users.findOne(note.userId)

View file

@ -44,7 +44,7 @@ export default async (user: User, note: Note) => {
//#region 配信 //#region 配信
if (Users.isLocalUser(user) && !note.localOnly) { 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); const dm = new DeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await Users.findOne(note.userId) const reactee = await Users.findOne(note.userId)