feat: 特定のドライブファイルを添付しているチャットメッセージを一覧できるように
This commit is contained in:
parent
8430256f22
commit
e6ec15e397
|
@ -4,6 +4,7 @@
|
||||||
- Feat: ノートの下書き機能
|
- Feat: ノートの下書き機能
|
||||||
- Feat: クリップ内でノートを検索できるように
|
- Feat: クリップ内でノートを検索できるように
|
||||||
- Feat: Playを検索できるように
|
- Feat: Playを検索できるように
|
||||||
|
- Feat: モデレーションにおいて、特定のドライブファイルを添付しているチャットメッセージを一覧できるように
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Feat: モデログを検索できるように
|
- Feat: モデログを検索できるように
|
||||||
|
|
|
@ -10890,6 +10890,10 @@ export interface Locale extends ILocale {
|
||||||
* 添付されているノート
|
* 添付されているノート
|
||||||
*/
|
*/
|
||||||
"attachedNotes": string;
|
"attachedNotes": string;
|
||||||
|
/**
|
||||||
|
* 利用
|
||||||
|
*/
|
||||||
|
"usage": string;
|
||||||
/**
|
/**
|
||||||
* このページは、このファイルをアップロードしたユーザーしか閲覧できません。
|
* このページは、このファイルをアップロードしたユーザーしか閲覧できません。
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2885,6 +2885,7 @@ _fileViewer:
|
||||||
url: "URL"
|
url: "URL"
|
||||||
uploadedAt: "追加日"
|
uploadedAt: "追加日"
|
||||||
attachedNotes: "添付されているノート"
|
attachedNotes: "添付されているノート"
|
||||||
|
usage: "利用"
|
||||||
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"
|
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"
|
||||||
|
|
||||||
_externalResourceInstaller:
|
_externalResourceInstaller:
|
||||||
|
|
|
@ -168,6 +168,7 @@ export * as 'clips/update' from './endpoints/clips/update.js';
|
||||||
export * as 'drive' from './endpoints/drive.js';
|
export * as 'drive' from './endpoints/drive.js';
|
||||||
export * as 'drive/files' from './endpoints/drive/files.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-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/check-existence' from './endpoints/drive/files/check-existence.js';
|
||||||
export * as 'drive/files/create' from './endpoints/drive/files/create.js';
|
export * as 'drive/files/create' from './endpoints/drive/files/create.js';
|
||||||
export * as 'drive/files/delete' from './endpoints/drive/files/delete.js';
|
export * as 'drive/files/delete' from './endpoints/drive/files/delete.js';
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template #default="{ items }">
|
<template #default="{ items }">
|
||||||
<div :class="$style.root">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab === 'notes' && info" class="_gaps_m">
|
<div v-else-if="tab === 'usage' && info" class="_gaps_m">
|
||||||
<XNotes :fileId="fileId"/>
|
<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>
|
||||||
<div v-else-if="tab === 'ip' && info" class="_gaps_m">
|
<div v-else-if="tab === 'ip' && info" class="_gaps_m">
|
||||||
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
|
<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 { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import { iAmAdmin, iAmModerator } from '@/i.js';
|
import { iAmAdmin, iAmModerator } from '@/i.js';
|
||||||
|
import MkTabs from '@/components/MkTabs.vue';
|
||||||
|
|
||||||
const tab = ref('overview');
|
const tab = ref('overview');
|
||||||
const file = ref<Misskey.entities.DriveFile | null>(null);
|
const file = ref<Misskey.entities.DriveFile | null>(null);
|
||||||
const info = ref<Misskey.entities.AdminDriveShowFileResponse | null>(null);
|
const info = ref<Misskey.entities.AdminDriveShowFileResponse | null>(null);
|
||||||
const isSensitive = ref<boolean>(false);
|
const isSensitive = ref<boolean>(false);
|
||||||
|
const usageTab = ref<'note' | 'chat'>('note');
|
||||||
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
|
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
|
||||||
|
const XChat = defineAsyncComponent(() => import('./admin-file.chat.vue'));
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fileId: string,
|
fileId: string,
|
||||||
|
@ -147,9 +161,9 @@ const headerTabs = computed(() => [{
|
||||||
title: i18n.ts.overview,
|
title: i18n.ts.overview,
|
||||||
icon: 'ti ti-info-circle',
|
icon: 'ti ti-info-circle',
|
||||||
}, iAmModerator ? {
|
}, iAmModerator ? {
|
||||||
key: 'notes',
|
key: 'usage',
|
||||||
title: i18n.ts._fileViewer.attachedNotes,
|
title: i18n.ts._fileViewer.usage,
|
||||||
icon: 'ti ti-pencil',
|
icon: 'ti ti-plus',
|
||||||
} : null, iAmModerator ? {
|
} : null, iAmModerator ? {
|
||||||
key: 'ip',
|
key: 'ip',
|
||||||
title: 'IP',
|
title: 'IP',
|
||||||
|
|
|
@ -1226,6 +1226,12 @@ type DateString = string;
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type DriveFile = components['schemas']['DriveFile'];
|
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)
|
// @public (undocumented)
|
||||||
type DriveFilesAttachedNotesRequest = operations['drive___files___attached-notes']['requestBody']['content']['application/json'];
|
type DriveFilesAttachedNotesRequest = operations['drive___files___attached-notes']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
@ -1740,6 +1746,8 @@ declare namespace entities {
|
||||||
DriveResponse,
|
DriveResponse,
|
||||||
DriveFilesRequest,
|
DriveFilesRequest,
|
||||||
DriveFilesResponse,
|
DriveFilesResponse,
|
||||||
|
DriveFilesAttachedChatMessagesRequest,
|
||||||
|
DriveFilesAttachedChatMessagesResponse,
|
||||||
DriveFilesAttachedNotesRequest,
|
DriveFilesAttachedNotesRequest,
|
||||||
DriveFilesAttachedNotesResponse,
|
DriveFilesAttachedNotesResponse,
|
||||||
DriveFilesCheckExistenceRequest,
|
DriveFilesCheckExistenceRequest,
|
||||||
|
|
|
@ -2018,6 +2018,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**: *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.
|
* Find the notes to which the given file is attached.
|
||||||
*
|
*
|
||||||
|
|
|
@ -275,6 +275,8 @@ import type {
|
||||||
DriveResponse,
|
DriveResponse,
|
||||||
DriveFilesRequest,
|
DriveFilesRequest,
|
||||||
DriveFilesResponse,
|
DriveFilesResponse,
|
||||||
|
DriveFilesAttachedChatMessagesRequest,
|
||||||
|
DriveFilesAttachedChatMessagesResponse,
|
||||||
DriveFilesAttachedNotesRequest,
|
DriveFilesAttachedNotesRequest,
|
||||||
DriveFilesAttachedNotesResponse,
|
DriveFilesAttachedNotesResponse,
|
||||||
DriveFilesCheckExistenceRequest,
|
DriveFilesCheckExistenceRequest,
|
||||||
|
@ -833,6 +835,7 @@ export type Endpoints = {
|
||||||
'clips/update': { req: ClipsUpdateRequest; res: ClipsUpdateResponse };
|
'clips/update': { req: ClipsUpdateRequest; res: ClipsUpdateResponse };
|
||||||
'drive': { req: EmptyRequest; res: DriveResponse };
|
'drive': { req: EmptyRequest; res: DriveResponse };
|
||||||
'drive/files': { req: DriveFilesRequest; res: DriveFilesResponse };
|
'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/attached-notes': { req: DriveFilesAttachedNotesRequest; res: DriveFilesAttachedNotesResponse };
|
||||||
'drive/files/check-existence': { req: DriveFilesCheckExistenceRequest; res: DriveFilesCheckExistenceResponse };
|
'drive/files/check-existence': { req: DriveFilesCheckExistenceRequest; res: DriveFilesCheckExistenceResponse };
|
||||||
'drive/files/create': { req: DriveFilesCreateRequest; res: DriveFilesCreateResponse };
|
'drive/files/create': { req: DriveFilesCreateRequest; res: DriveFilesCreateResponse };
|
||||||
|
|
|
@ -278,6 +278,8 @@ export type ClipsUpdateResponse = operations['clips___update']['responses']['200
|
||||||
export type DriveResponse = operations['drive']['responses']['200']['content']['application/json'];
|
export type DriveResponse = operations['drive']['responses']['200']['content']['application/json'];
|
||||||
export type DriveFilesRequest = operations['drive___files']['requestBody']['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 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 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 DriveFilesAttachedNotesResponse = operations['drive___files___attached-notes']['responses']['200']['content']['application/json'];
|
||||||
export type DriveFilesCheckExistenceRequest = operations['drive___files___check-existence']['requestBody']['content']['application/json'];
|
export type DriveFilesCheckExistenceRequest = operations['drive___files___check-existence']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -1653,6 +1653,15 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['drive___files'];
|
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': {
|
||||||
/**
|
/**
|
||||||
* 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': {
|
'drive___files___attached-notes': {
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
|
|
Loading…
Reference in New Issue