enhance: チャットのリアクションを削除できるように
This commit is contained in:
parent
8e72c68205
commit
98554579ea
|
@ -33,6 +33,20 @@ const MAX_ROOM_MEMBERS = 30;
|
||||||
const MAX_REACTIONS_PER_MESSAGE = 100;
|
const MAX_REACTIONS_PER_MESSAGE = 100;
|
||||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||||
|
|
||||||
|
// TODO: ReactionServiceのやつと共通化
|
||||||
|
function normalizeEmojiString(x: string) {
|
||||||
|
const match = emojiRegex.exec(x);
|
||||||
|
if (match) {
|
||||||
|
// 合字を含む1つの絵文字
|
||||||
|
const unicode = match[0];
|
||||||
|
|
||||||
|
// 異体字セレクタ除去
|
||||||
|
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid emoji');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -751,24 +765,10 @@ export class ChatService {
|
||||||
public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
|
public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
|
||||||
let reaction;
|
let reaction;
|
||||||
|
|
||||||
// TODO: ReactionServiceのやつと共通化
|
|
||||||
function normalize(x: string) {
|
|
||||||
const match = emojiRegex.exec(x);
|
|
||||||
if (match) {
|
|
||||||
// 合字を含む1つの絵文字
|
|
||||||
const unicode = match[0];
|
|
||||||
|
|
||||||
// 異体字セレクタ除去
|
|
||||||
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
|
||||||
} else {
|
|
||||||
throw new Error('invalid emoji');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const custom = reaction_.match(isCustomEmojiRegexp);
|
const custom = reaction_.match(isCustomEmojiRegexp);
|
||||||
|
|
||||||
if (custom == null) {
|
if (custom == null) {
|
||||||
reaction = normalize(reaction_);
|
reaction = normalizeEmojiString(reaction_);
|
||||||
} else {
|
} else {
|
||||||
const name = custom[1];
|
const name = custom[1];
|
||||||
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
|
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
|
||||||
|
@ -827,6 +827,52 @@ export class ChatService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
|
||||||
|
let reaction;
|
||||||
|
|
||||||
|
const custom = reaction_.match(isCustomEmojiRegexp);
|
||||||
|
|
||||||
|
if (custom == null) {
|
||||||
|
reaction = normalizeEmojiString(reaction_);
|
||||||
|
} else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし
|
||||||
|
const name = custom[1];
|
||||||
|
reaction = `:${name}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし
|
||||||
|
|
||||||
|
const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
|
||||||
|
|
||||||
|
const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
|
||||||
|
|
||||||
|
await this.chatMessagesRepository.createQueryBuilder().update()
|
||||||
|
.set({
|
||||||
|
reactions: () => `array_remove("reactions", '${userId}/${reaction}')`,
|
||||||
|
})
|
||||||
|
.where('id = :id', { id: message.id })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// TODO: 実際に削除が行われたときのみイベントを発行する
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
this.globalEventService.publishChatRoomStream(room.id, 'unreact', {
|
||||||
|
messageId: message.id,
|
||||||
|
user: await this.userEntityService.pack(userId),
|
||||||
|
reaction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', {
|
||||||
|
messageId: message.id,
|
||||||
|
reaction,
|
||||||
|
});
|
||||||
|
this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', {
|
||||||
|
messageId: message.id,
|
||||||
|
reaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
|
public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
|
||||||
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
|
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
|
||||||
|
|
|
@ -167,6 +167,11 @@ export interface ChatEventTypes {
|
||||||
user?: Packed<'UserLite'>;
|
user?: Packed<'UserLite'>;
|
||||||
messageId: MiChatMessage['id'];
|
messageId: MiChatMessage['id'];
|
||||||
};
|
};
|
||||||
|
unreact: {
|
||||||
|
reaction: string;
|
||||||
|
user?: Packed<'UserLite'>;
|
||||||
|
messageId: MiChatMessage['id'];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReversiEventTypes {
|
export interface ReversiEventTypes {
|
||||||
|
|
|
@ -401,6 +401,7 @@ export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/creat
|
||||||
export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
|
export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
|
||||||
export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
|
export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
|
||||||
export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
|
export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
|
||||||
|
export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js';
|
||||||
export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
|
export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
|
||||||
export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
|
export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
|
||||||
export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
|
export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ChatService } from '@/core/ChatService.js';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['chat'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
kind: 'write:chat',
|
||||||
|
|
||||||
|
res: {
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchMessage: {
|
||||||
|
message: 'No such message.',
|
||||||
|
code: 'NO_SUCH_MESSAGE',
|
||||||
|
id: 'c39ea42f-e3ca-428a-ad57-390e0a711595',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
messageId: { type: 'string', format: 'misskey:id' },
|
||||||
|
reaction: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['messageId', 'reaction'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private chatService: ChatService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
await this.chatService.unreact(ps.messageId, me.id, ps.reaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
|
:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
|
||||||
tag="div" :class="$style.reactions"
|
tag="div" :class="$style.reactions"
|
||||||
>
|
>
|
||||||
<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction">
|
<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction" @click="onReactionClick(record)">
|
||||||
<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
|
<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
|
||||||
<MkReactionIcon
|
<MkReactionIcon
|
||||||
:withTooltip="true"
|
:withTooltip="true"
|
||||||
|
@ -87,6 +87,15 @@ function react(ev: MouseEvent) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
|
||||||
|
if (record.user.id === $i.id) {
|
||||||
|
misskeyApi('chat/messages/unreact', {
|
||||||
|
messageId: props.message.id,
|
||||||
|
reaction: record.reaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showMenu(ev: MouseEvent) {
|
function showMenu(ev: MouseEvent) {
|
||||||
const menu: MenuItem[] = [];
|
const menu: MenuItem[] = [];
|
||||||
|
|
||||||
|
|
|
@ -170,6 +170,7 @@ async function initialize() {
|
||||||
connection.value.on('message', onMessage);
|
connection.value.on('message', onMessage);
|
||||||
connection.value.on('deleted', onDeleted);
|
connection.value.on('deleted', onDeleted);
|
||||||
connection.value.on('react', onReact);
|
connection.value.on('react', onReact);
|
||||||
|
connection.value.on('unreact', onUnreact);
|
||||||
} else {
|
} else {
|
||||||
const [r, m] = await Promise.all([
|
const [r, m] = await Promise.all([
|
||||||
misskeyApi('chat/rooms/show', { roomId: props.roomId }),
|
misskeyApi('chat/rooms/show', { roomId: props.roomId }),
|
||||||
|
@ -189,6 +190,7 @@ async function initialize() {
|
||||||
connection.value.on('message', onMessage);
|
connection.value.on('message', onMessage);
|
||||||
connection.value.on('deleted', onDeleted);
|
connection.value.on('deleted', onDeleted);
|
||||||
connection.value.on('react', onReact);
|
connection.value.on('react', onReact);
|
||||||
|
connection.value.on('unreact', onUnreact);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.document.addEventListener('visibilitychange', onVisibilitychange);
|
window.document.addEventListener('visibilitychange', onVisibilitychange);
|
||||||
|
@ -268,6 +270,16 @@ function onReact(ctx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onUnreact(ctx) {
|
||||||
|
const message = messages.value.find(m => m.id === ctx.messageId);
|
||||||
|
if (message) {
|
||||||
|
const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
message.reactions.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onIndicatorClick() {
|
function onIndicatorClick() {
|
||||||
showIndicator.value = false;
|
showIndicator.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1001,6 +1001,12 @@ type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
|
type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type ChatMessagesUnreactRequest = operations['chat___messages___unreact']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type ChatMessagesUnreactResponse = operations['chat___messages___unreact']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
|
type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
@ -1613,6 +1619,8 @@ declare namespace entities {
|
||||||
ChatMessagesSearchResponse,
|
ChatMessagesSearchResponse,
|
||||||
ChatMessagesShowRequest,
|
ChatMessagesShowRequest,
|
||||||
ChatMessagesShowResponse,
|
ChatMessagesShowResponse,
|
||||||
|
ChatMessagesUnreactRequest,
|
||||||
|
ChatMessagesUnreactResponse,
|
||||||
ChatMessagesUserTimelineRequest,
|
ChatMessagesUserTimelineRequest,
|
||||||
ChatMessagesUserTimelineResponse,
|
ChatMessagesUserTimelineResponse,
|
||||||
ChatRoomsCreateRequest,
|
ChatRoomsCreateRequest,
|
||||||
|
|
|
@ -1622,6 +1622,17 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:chat*
|
||||||
|
*/
|
||||||
|
request<E extends 'chat/messages/unreact', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No description provided.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
@ -223,6 +223,8 @@ import type {
|
||||||
ChatMessagesSearchResponse,
|
ChatMessagesSearchResponse,
|
||||||
ChatMessagesShowRequest,
|
ChatMessagesShowRequest,
|
||||||
ChatMessagesShowResponse,
|
ChatMessagesShowResponse,
|
||||||
|
ChatMessagesUnreactRequest,
|
||||||
|
ChatMessagesUnreactResponse,
|
||||||
ChatMessagesUserTimelineRequest,
|
ChatMessagesUserTimelineRequest,
|
||||||
ChatMessagesUserTimelineResponse,
|
ChatMessagesUserTimelineResponse,
|
||||||
ChatRoomsCreateRequest,
|
ChatRoomsCreateRequest,
|
||||||
|
@ -780,6 +782,7 @@ export type Endpoints = {
|
||||||
'chat/messages/room-timeline': { req: ChatMessagesRoomTimelineRequest; res: ChatMessagesRoomTimelineResponse };
|
'chat/messages/room-timeline': { req: ChatMessagesRoomTimelineRequest; res: ChatMessagesRoomTimelineResponse };
|
||||||
'chat/messages/search': { req: ChatMessagesSearchRequest; res: ChatMessagesSearchResponse };
|
'chat/messages/search': { req: ChatMessagesSearchRequest; res: ChatMessagesSearchResponse };
|
||||||
'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
|
'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
|
||||||
|
'chat/messages/unreact': { req: ChatMessagesUnreactRequest; res: ChatMessagesUnreactResponse };
|
||||||
'chat/messages/user-timeline': { req: ChatMessagesUserTimelineRequest; res: ChatMessagesUserTimelineResponse };
|
'chat/messages/user-timeline': { req: ChatMessagesUserTimelineRequest; res: ChatMessagesUserTimelineResponse };
|
||||||
'chat/rooms/create': { req: ChatRoomsCreateRequest; res: ChatRoomsCreateResponse };
|
'chat/rooms/create': { req: ChatRoomsCreateRequest; res: ChatRoomsCreateResponse };
|
||||||
'chat/rooms/delete': { req: ChatRoomsDeleteRequest; res: ChatRoomsDeleteResponse };
|
'chat/rooms/delete': { req: ChatRoomsDeleteRequest; res: ChatRoomsDeleteResponse };
|
||||||
|
|
|
@ -226,6 +226,8 @@ export type ChatMessagesSearchRequest = operations['chat___messages___search']['
|
||||||
export type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
|
export type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
|
||||||
export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
|
export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
|
||||||
export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
|
export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
|
||||||
|
export type ChatMessagesUnreactRequest = operations['chat___messages___unreact']['requestBody']['content']['application/json'];
|
||||||
|
export type ChatMessagesUnreactResponse = operations['chat___messages___unreact']['responses']['200']['content']['application/json'];
|
||||||
export type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
|
export type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
|
||||||
export type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
|
export type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
|
||||||
export type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
|
export type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -1430,6 +1430,15 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['chat___messages___show'];
|
post: operations['chat___messages___show'];
|
||||||
};
|
};
|
||||||
|
'/chat/messages/unreact': {
|
||||||
|
/**
|
||||||
|
* chat/messages/unreact
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:chat*
|
||||||
|
*/
|
||||||
|
post: operations['chat___messages___unreact'];
|
||||||
|
};
|
||||||
'/chat/messages/user-timeline': {
|
'/chat/messages/user-timeline': {
|
||||||
/**
|
/**
|
||||||
* chat/messages/user-timeline
|
* chat/messages/user-timeline
|
||||||
|
@ -14424,6 +14433,61 @@ export type operations = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* chat/messages/unreact
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:chat*
|
||||||
|
*/
|
||||||
|
chat___messages___unreact: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
/** Format: misskey:id */
|
||||||
|
messageId: string;
|
||||||
|
reaction: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (with results) */
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* chat/messages/user-timeline
|
* chat/messages/user-timeline
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
Loading…
Reference in New Issue