feat: 特定のドライブファイルを添付しているチャットメッセージを一覧できるように

This commit is contained in:
syuilo 2025-07-06 09:54:49 +09:00
parent 8430256f22
commit e6ec15e397
13 changed files with 257 additions and 6 deletions

View File

@ -4,6 +4,7 @@
- Feat: ノートの下書き機能
- Feat: クリップ内でノートを検索できるように
- Feat: Playを検索できるように
- Feat: モデレーションにおいて、特定のドライブファイルを添付しているチャットメッセージを一覧できるように
### Client
- Feat: モデログを検索できるように

4
locales/index.d.ts vendored
View File

@ -10890,6 +10890,10 @@ export interface Locale extends ILocale {
*
*/
"attachedNotes": string;
/**
*
*/
"usage": string;
/**
*
*/

View File

@ -2885,6 +2885,7 @@ _fileViewer:
url: "URL"
uploadedAt: "追加日"
attachedNotes: "添付されているノート"
usage: "利用"
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"
_externalResourceInstaller:

View File

@ -168,6 +168,7 @@ export * as 'clips/update' from './endpoints/clips/update.js';
export * as 'drive' from './endpoints/drive.js';
export * as 'drive/files' from './endpoints/drive/files.js';
export * as 'drive/files/attached-notes' from './endpoints/drive/files/attached-notes.js';
export * as 'drive/files/attached-chat-messages' from './endpoints/drive/files/attached-chat-messages.js';
export * as 'drive/files/check-existence' from './endpoints/drive/files/check-existence.js';
export * as 'drive/files/create' from './endpoints/drive/files/create.js';
export * as 'drive/files/delete' from './endpoints/drive/files/delete.js';

View File

@ -0,0 +1,85 @@
/*
* 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 type { DriveFilesRepository, ChatMessagesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['drive', 'chat'],
requireCredential: true,
kind: 'read:drive',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessage',
},
},
errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: '485ce26d-f5d2-4313-9783-e689d131eafb',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
private chatEntityService: ChatEntityService,
private queryService: QueryService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({
id: ps.fileId,
userId: await this.roleService.isModerator(me) ? undefined : me.id,
});
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate);
query.andWhere('message.fileId = :fileId', { fileId: file.id });
const messages = await query.limit(ps.limit).getMany();
return await this.chatEntityService.packMessagesDetailed(messages, me);
});
}
}

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items }">
<div :class="$style.root">
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
<MkUserInfo v-for="item in items" :key="item.id" :user="extractor(item)"/>
</div>
</template>
</MkPagination>

View File

@ -0,0 +1,38 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
<MkPagination :paginator="paginator">
<template #default="{ items }">
<XMessage v-for="item in items" :key="item.id" :message="item" :isSearchResult="true"/>
</template>
</MkPagination>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, markRaw } from 'vue';
import XMessage from './chat/XMessage.vue';
import { i18n } from '@/i18n.js';
import MkInfo from '@/components/MkInfo.vue';
import { Paginator } from '@/utility/paginator.js';
import MkPagination from '@/components/MkPagination.vue';
const props = defineProps<{
fileId: string;
}>();
const realFileId = computed(() => props.fileId);
const paginator = markRaw(new Paginator('drive/files/attached-chat-messages', {
limit: 10,
params: {
fileId: realFileId.value,
},
}));
</script>

View File

@ -44,8 +44,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
<div v-else-if="tab === 'notes' && info" class="_gaps_m">
<XNotes :fileId="fileId"/>
<div v-else-if="tab === 'usage' && info" class="_gaps_m">
<MkTabs
v-model:tab="usageTab"
:tabs="[{
key: 'note',
title: 'Note',
}, {
key: 'chat',
title: 'Chat',
}]"
/>
<XNotes v-if="usageTab === 'note'" :fileId="fileId"/>
<XChat v-else-if="usageTab === 'chat'" :fileId="fileId"/>
</div>
<div v-else-if="tab === 'ip' && info" class="_gaps_m">
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
@ -86,12 +97,15 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { iAmAdmin, iAmModerator } from '@/i.js';
import MkTabs from '@/components/MkTabs.vue';
const tab = ref('overview');
const file = ref<Misskey.entities.DriveFile | null>(null);
const info = ref<Misskey.entities.AdminDriveShowFileResponse | null>(null);
const isSensitive = ref<boolean>(false);
const usageTab = ref<'note' | 'chat'>('note');
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
const XChat = defineAsyncComponent(() => import('./admin-file.chat.vue'));
const props = defineProps<{
fileId: string,
@ -147,9 +161,9 @@ const headerTabs = computed(() => [{
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, iAmModerator ? {
key: 'notes',
title: i18n.ts._fileViewer.attachedNotes,
icon: 'ti ti-pencil',
key: 'usage',
title: i18n.ts._fileViewer.usage,
icon: 'ti ti-plus',
} : null, iAmModerator ? {
key: 'ip',
title: 'IP',

View File

@ -1226,6 +1226,12 @@ type DateString = string;
// @public (undocumented)
type DriveFile = components['schemas']['DriveFile'];
// @public (undocumented)
type DriveFilesAttachedChatMessagesRequest = operations['drive___files___attached-chat-messages']['requestBody']['content']['application/json'];
// @public (undocumented)
type DriveFilesAttachedChatMessagesResponse = operations['drive___files___attached-chat-messages']['responses']['200']['content']['application/json'];
// @public (undocumented)
type DriveFilesAttachedNotesRequest = operations['drive___files___attached-notes']['requestBody']['content']['application/json'];
@ -1740,6 +1746,8 @@ declare namespace entities {
DriveResponse,
DriveFilesRequest,
DriveFilesResponse,
DriveFilesAttachedChatMessagesRequest,
DriveFilesAttachedChatMessagesResponse,
DriveFilesAttachedNotesRequest,
DriveFilesAttachedNotesResponse,
DriveFilesCheckExistenceRequest,

View File

@ -2018,6 +2018,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:drive*
*/
request<E extends 'drive/files/attached-chat-messages', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* Find the notes to which the given file is attached.
*

View File

@ -275,6 +275,8 @@ import type {
DriveResponse,
DriveFilesRequest,
DriveFilesResponse,
DriveFilesAttachedChatMessagesRequest,
DriveFilesAttachedChatMessagesResponse,
DriveFilesAttachedNotesRequest,
DriveFilesAttachedNotesResponse,
DriveFilesCheckExistenceRequest,
@ -833,6 +835,7 @@ export type Endpoints = {
'clips/update': { req: ClipsUpdateRequest; res: ClipsUpdateResponse };
'drive': { req: EmptyRequest; res: DriveResponse };
'drive/files': { req: DriveFilesRequest; res: DriveFilesResponse };
'drive/files/attached-chat-messages': { req: DriveFilesAttachedChatMessagesRequest; res: DriveFilesAttachedChatMessagesResponse };
'drive/files/attached-notes': { req: DriveFilesAttachedNotesRequest; res: DriveFilesAttachedNotesResponse };
'drive/files/check-existence': { req: DriveFilesCheckExistenceRequest; res: DriveFilesCheckExistenceResponse };
'drive/files/create': { req: DriveFilesCreateRequest; res: DriveFilesCreateResponse };

View File

@ -278,6 +278,8 @@ export type ClipsUpdateResponse = operations['clips___update']['responses']['200
export type DriveResponse = operations['drive']['responses']['200']['content']['application/json'];
export type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json'];
export type DriveFilesResponse = operations['drive___files']['responses']['200']['content']['application/json'];
export type DriveFilesAttachedChatMessagesRequest = operations['drive___files___attached-chat-messages']['requestBody']['content']['application/json'];
export type DriveFilesAttachedChatMessagesResponse = operations['drive___files___attached-chat-messages']['responses']['200']['content']['application/json'];
export type DriveFilesAttachedNotesRequest = operations['drive___files___attached-notes']['requestBody']['content']['application/json'];
export type DriveFilesAttachedNotesResponse = operations['drive___files___attached-notes']['responses']['200']['content']['application/json'];
export type DriveFilesCheckExistenceRequest = operations['drive___files___check-existence']['requestBody']['content']['application/json'];

View File

@ -1653,6 +1653,15 @@ export type paths = {
*/
post: operations['drive___files'];
};
'/drive/files/attached-chat-messages': {
/**
* drive/files/attached-chat-messages
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:drive*
*/
post: operations['drive___files___attached-chat-messages'];
};
'/drive/files/attached-notes': {
/**
* drive/files/attached-notes
@ -18748,6 +18757,80 @@ export interface operations {
};
};
};
'drive___files___attached-chat-messages': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
sinceDate?: number;
untilDate?: number;
/** @default 10 */
limit?: number;
/** Format: misskey:id */
fileId: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['ChatMessage'][];
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
'drive___files___attached-notes': {
requestBody: {
content: {