This commit is contained in:
syuilo
2025-03-17 18:00:31 +09:00
parent 9e5fb89408
commit 30be29a785
13 changed files with 544 additions and 8 deletions

8
locales/index.d.ts vendored
View File

@@ -1223,9 +1223,9 @@ export interface Locale extends ILocale {
*/
"noMoreHistory": string;
/**
* チャットを
* チャットを始める
*/
"startMessaging": string;
"startChat": string;
/**
* {n}人が読みました
*/
@@ -5350,6 +5350,10 @@ export interface Locale extends ILocale {
* 文字数
*/
"textCount": string;
/**
* チャット
*/
"chat": string;
"_emojiPalette": {
/**
* パレット

View File

@@ -301,7 +301,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか
explore: "みつける"
messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません"
startMessaging: "チャットを始"
startChat: "チャットを始める"
nUsersRead: "{n}人が読みました"
agreeTo: "{0}に同意"
agree: "同意する"
@@ -1333,6 +1333,7 @@ paste: "ペースト"
emojiPalette: "絵文字パレット"
postForm: "投稿フォーム"
textCount: "文字数"
chat: "チャット"
_emojiPalette:
palettes: "パレット"

View File

@@ -16,7 +16,7 @@ import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityServi
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.js';
import type { ChatMessagesRepository, MiChatMessage, MiDriveFile, MiUser, UsersRepository } from '@/models/_.js';
import type { ChatMessagesRepository, MiChatMessage, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { QueryService } from '@/core/QueryService.js';
@@ -35,6 +35,9 @@ export class ChatService {
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private userEntityService: UserEntityService,
private chatMessageEntityService: ChatMessageEntityService,
private idService: IdService,
@@ -278,4 +281,82 @@ export class ChatService {
return messages;
}
@bindThis
public async userHistory(meId: MiUser['id'], limit: number) {
const history: MiChatMessage[] = [];
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: meId });
for (let i = 0; i < limit; i++) {
const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!);
const query = this.chatMessagesRepository.createQueryBuilder('message')
.orderBy('message.id', 'DESC')
.where(new Brackets(qb => {
qb
.where('message.fromUserId = :meId', { meId: meId })
.orWhere('message.toUserId = :meId', { meId: meId });
}))
.andWhere('message.groupId IS NULL')
.andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
if (found.length > 0) {
query.andWhere('message.fromUserId NOT IN (:...found)', { found: found });
query.andWhere('message.toUserId NOT IN (:...found)', { found: found });
}
query.setParameters(mutingQuery.getParameters());
const message = await query.getOne();
if (message) {
history.push(message);
} else {
break;
}
}
return history;
}
@bindThis
public async groupHistory(meId: MiUser['id'], limit: number) {
/*
const groups = await this.userGroupJoiningsRepository.findBy({
userId: meId,
}).then(xs => xs.map(x => x.userGroupId));
if (groups.length === 0) {
return [];
}
const history: MiChatMessage[] = [];
for (let i = 0; i < limit; i++) {
const found = history.map(m => m.groupId!);
const query = this.chatMessagesRepository.createQueryBuilder('message')
.orderBy('message.id', 'DESC')
.where('message.groupId IN (:...groups)', { groups: groups });
if (found.length > 0) {
query.andWhere('message.groupId NOT IN (:...found)', { found: found });
}
const message = await query.getOne();
if (message) {
history.push(message);
} else {
break;
}
}
return history;
*/
}
}

View File

@@ -399,4 +399,5 @@ export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
export * as 'chat/messages/create' from './endpoints/chat/messages/create.js';
export * as 'chat/messages/timeline' from './endpoints/chat/messages/timeline.js';
export * as 'chat/messages/history' from './endpoints/chat/messages/history.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';

View File

@@ -0,0 +1,54 @@
/*
* 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 { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
kind: 'read:chat',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessage',
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
group: { type: 'boolean', default: false },
},
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatMessageEntityService: ChatMessageEntityService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
const history = ps.group ? await this.chatService.groupHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
return await this.chatMessageEntityService.packMany(history, me);
});
}
}

View File

@@ -28,7 +28,7 @@ class ChatChannel extends Channel {
if (typeof params.otherId !== 'string') return;
this.otherId = params.otherId;
this.subscriber.on(`chatStream:${this.user.id}-${this.otherId}`, this.onEvent);
this.subscriber.on(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
@bindThis
@@ -39,7 +39,7 @@ class ChatChannel extends Channel {
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`chatStream:${this.user.id}-${this.otherId}`, this.onEvent);
this.subscriber.off(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
}

View File

@@ -0,0 +1,85 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div class="_gaps">
<MkButton primary :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
<div v-if="messages.length > 0" :class="$style.history">
<MkA
v-for="(message, i) in messages"
:key="message.id"
:class="[$style.message, { [$style.isMe]: message.isMe, [$style.isRead]: message.isRead }]"
class="_panel"
:to="message.groupId ? `/chat/group/${message.groupId}` : `/chat/user/${message.otherId}`"
>
<div>
<MkAvatar :class="$style.avatar" :user="message.user" indicator link preview/>
<header v-if="message.groupId">
<span class="name">{{ message.group.name }}</span>
<MkTime :time="message.createdAt" class="time"/>
</header>
<header v-else>
<span class="name"><MkUserName :user="message.other"/></span>
<span class="username">@{{ acct(message.other) }}</span>
<MkTime :time="message.createdAt" class="time"/>
</header>
<div class="body">
<p class="text"><span v-if="message.isMe" :class="$style.iSaid">{{ i18n.ts.you }}:</span>{{ message.text }}</p>
</div>
</div>
</MkA>
</div>
<div v-if="!fetching && messages.length == 0" class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noHistory }}</div>
</div>
<MkLoading v-if="fetching"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js';
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts.chat,
icon: 'ti ti-message',
}));
</script>
<style lang="scss" module>
.add {
margin: 0 auto 16px auto;
}
.antenna {
display: block;
padding: 16px;
border: solid 1px var(--MI_THEME-divider);
border-radius: 6px;
&:hover {
border: solid 1px var(--MI_THEME-accent);
text-decoration: none;
}
}
.name {
font-weight: bold;
}
</style>

View File

@@ -951,6 +951,30 @@ type ChartsUsersRequest = operations['charts___users']['requestBody']['content']
// @public (undocumented)
type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessage = components['schemas']['ChatMessage'];
// @public (undocumented)
type ChatMessageLite = components['schemas']['ChatMessageLite'];
// @public (undocumented)
type ChatMessagesCreateRequest = operations['chat___messages___create']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesHistoryRequest = operations['chat___messages___history']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesHistoryResponse = operations['chat___messages___history']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json'];
// @public (undocumented)
type Clip = components['schemas']['Clip'];
@@ -1448,6 +1472,12 @@ declare namespace entities {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
ChatMessagesCreateRequest,
ChatMessagesCreateResponse,
ChatMessagesHistoryRequest,
ChatMessagesHistoryResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,
ClipsCreateRequest,
ClipsCreateResponse,
@@ -1880,7 +1910,9 @@ declare namespace entities {
MetaDetailedOnly,
MetaDetailed,
SystemWebhook,
AbuseReportNotificationRecipient
AbuseReportNotificationRecipient,
ChatMessage,
ChatMessageLite
}
}
export { entities }
@@ -2903,7 +2935,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View File

@@ -1534,6 +1534,39 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:chat*
*/
request<E extends 'chat/messages/create', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
request<E extends 'chat/messages/history', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
request<E extends 'chat/messages/timeline', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@@ -207,6 +207,12 @@ import type {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
ChatMessagesCreateRequest,
ChatMessagesCreateResponse,
ChatMessagesHistoryRequest,
ChatMessagesHistoryResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,
ClipsCreateRequest,
ClipsCreateResponse,
@@ -726,6 +732,9 @@ export type Endpoints = {
'charts/user/pv': { req: ChartsUserPvRequest; res: ChartsUserPvResponse };
'charts/user/reactions': { req: ChartsUserReactionsRequest; res: ChartsUserReactionsResponse };
'charts/users': { req: ChartsUsersRequest; res: ChartsUsersResponse };
'chat/messages/create': { req: ChatMessagesCreateRequest; res: ChatMessagesCreateResponse };
'chat/messages/history': { req: ChatMessagesHistoryRequest; res: ChatMessagesHistoryResponse };
'chat/messages/timeline': { req: ChatMessagesTimelineRequest; res: ChatMessagesTimelineResponse };
'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse };
'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse };
'clips/delete': { req: ClipsDeleteRequest; res: EmptyResponse };

View File

@@ -210,6 +210,12 @@ export type ChartsUserReactionsRequest = operations['charts___user___reactions']
export type ChartsUserReactionsResponse = operations['charts___user___reactions']['responses']['200']['content']['application/json'];
export type ChartsUsersRequest = operations['charts___users']['requestBody']['content']['application/json'];
export type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
export type ChatMessagesCreateRequest = operations['chat___messages___create']['requestBody']['content']['application/json'];
export type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json'];
export type ChatMessagesHistoryRequest = operations['chat___messages___history']['requestBody']['content']['application/json'];
export type ChatMessagesHistoryResponse = operations['chat___messages___history']['responses']['200']['content']['application/json'];
export type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
export type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json'];
export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json'];
export type ClipsCreateRequest = operations['clips___create']['requestBody']['content']['application/json'];
export type ClipsCreateResponse = operations['clips___create']['responses']['200']['content']['application/json'];

View File

@@ -54,3 +54,5 @@ export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly'];
export type MetaDetailed = components['schemas']['MetaDetailed'];
export type SystemWebhook = components['schemas']['SystemWebhook'];
export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient'];
export type ChatMessage = components['schemas']['ChatMessage'];
export type ChatMessageLite = components['schemas']['ChatMessageLite'];

View File

@@ -1358,6 +1358,33 @@ export type paths = {
*/
post: operations['charts___users'];
};
'/chat/messages/create': {
/**
* chat/messages/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:chat*
*/
post: operations['chat___messages___create'];
};
'/chat/messages/history': {
/**
* chat/messages/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
post: operations['chat___messages___history'];
};
'/chat/messages/timeline': {
/**
* chat/messages/timeline
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
post: operations['chat___messages___timeline'];
};
'/clips/add-note': {
/**
* clips/add-note
@@ -5146,6 +5173,28 @@ export type components = {
systemWebhookId?: string;
systemWebhook?: components['schemas']['SystemWebhook'];
};
ChatMessage: {
id: string;
/** Format: date-time */
createdAt: string;
fromUserId: string;
fromUser?: components['schemas']['UserLite'];
toUserId?: string | null;
toUser?: components['schemas']['UserLite'] | null;
text?: string | null;
fileId?: string | null;
file?: components['schemas']['DriveFile'] | null;
};
ChatMessageLite: {
id: string;
/** Format: date-time */
createdAt: string;
fromUserId: string;
toUserId?: string | null;
text?: string | null;
fileId?: string | null;
file?: components['schemas']['DriveFile'] | null;
};
};
responses: never;
parameters: never;
@@ -13669,6 +13718,185 @@ export type operations = {
};
};
};
/**
* chat/messages/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:chat*
*/
chat___messages___create: {
requestBody: {
content: {
'application/json': {
text?: string | null;
/** Format: misskey:id */
fileId?: string;
/** Format: misskey:id */
userId?: string | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessageLite'];
};
};
/** @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 Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* chat/messages/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
chat___messages___history: {
requestBody: {
content: {
'application/json': {
/** @default 10 */
limit?: number;
/** @default false */
group?: boolean;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessage'][];
};
};
/** @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/timeline
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
chat___messages___timeline: {
requestBody: {
content: {
'application/json': {
/** @default 10 */
limit?: number;
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
/** Format: misskey:id */
userId?: string | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessageLite'][];
};
};
/** @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'];
};
};
};
};
/**
* clips/add-note
* @description No description provided.