This commit is contained in:
かっこかり 2025-06-01 08:37:08 +09:00 committed by GitHub
commit f5c991bebc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 295 additions and 4 deletions

View File

@ -30,6 +30,7 @@
- したがって、それらの種別不明ファイルを許可したい場合は application/octet-stream を指定に追加してください。
- Feat: プレビュー先がリダイレクトを伴う場合、リダイレクト先のコンテンツを取得しに行くか否かを設定できるように(#16043)
- Enhance: UIのアイコンデータの読み込みを軽量化
- Enhance: お知らせの既読をリセットできるように
### Client
- Feat: ドライブのUIが強化されました

16
locales/index.d.ts vendored
View File

@ -5481,6 +5481,14 @@ export interface Locale extends ILocale {
*
*/
"hideAllTips": string;
/**
*
*/
"resetReads": string;
/**
* {x}
*/
"resetReadsAreYouSure": ParameterizedString<"x">;
"_chat": {
/**
*
@ -10702,6 +10710,14 @@ export interface Locale extends ILocale {
*
*/
"deleteUserAnnouncement": string;
/**
*
*/
"resetReadsForGlobalAnnouncement": string;
/**
*
*/
"resetReadsForUserAnnouncement": string;
/**
*
*/

View File

@ -1365,6 +1365,8 @@ abort: "中止"
tip: "ヒントとコツ"
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
hideAllTips: "全ての「ヒントとコツ」を非表示"
resetReads: "既読をリセット"
resetReadsAreYouSure: "「{x}」の既読をリセットしますか?"
_chat:
noMessagesYet: "まだメッセージはありません"
@ -2835,6 +2837,8 @@ _moderationLogTypes:
updateUserAnnouncement: "ユーザーのお知らせを更新"
deleteGlobalAnnouncement: "全体のお知らせを削除"
deleteUserAnnouncement: "ユーザーのお知らせを削除"
resetReadsForGlobalAnnouncement: "全体のお知らせの既読をリセット"
resetReadsForUserAnnouncement: "ユーザーのお知らせの既読をリセット"
resetPassword: "パスワードをリセット"
suspendRemoteInstance: "リモートサーバーを停止"
unsuspendRemoteInstance: "リモートサーバーを再開"

View File

@ -179,6 +179,32 @@ export class AnnouncementService {
}
}
@bindThis
public async resetReads(announcementId: MiAnnouncement['id'], moderator?: MiUser): Promise<void> {
await this.announcementReadsRepository.delete({
announcementId: announcementId,
});
if (moderator) {
const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId });
if (announcement.userId) {
const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId });
this.moderationLogService.log(moderator, 'resetReadsForUserAnnouncement', {
announcementId: announcement.id,
announcement: announcement,
userId: announcement.userId,
userUsername: user.username,
userHost: user.host,
});
} else {
this.moderationLogService.log(moderator, 'resetReadsForGlobalAnnouncement', {
announcementId: announcement.id,
announcement: announcement,
});
}
}
}
@bindThis
public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> {
const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId });

View File

@ -26,6 +26,7 @@ export * as 'admin/ad/update' from './endpoints/admin/ad/update.js';
export * as 'admin/announcements/create' from './endpoints/admin/announcements/create.js';
export * as 'admin/announcements/delete' from './endpoints/admin/announcements/delete.js';
export * as 'admin/announcements/list' from './endpoints/admin/announcements/list.js';
export * as 'admin/announcements/reset-reads' from './endpoints/admin/announcements/reset-reads.js';
export * as 'admin/announcements/update' from './endpoints/admin/announcements/update.js';
export * as 'admin/avatar-decorations/create' from './endpoints/admin/avatar-decorations/create.js';
export * as 'admin/avatar-decorations/delete' from './endpoints/admin/avatar-decorations/delete.js';

View File

@ -53,6 +53,37 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
icon: {
type: 'string',
optional: false, nullable: false,
enum: ['info', 'warning', 'error', 'success'],
},
display: {
type: 'string',
optional: false, nullable: false,
enum: ['normal', 'banner', 'dialog'],
},
isActive: {
type: 'boolean',
optional: false, nullable: false,
},
forExistingUsers: {
type: 'boolean',
optional: false, nullable: false,
},
silence: {
type: 'boolean',
optional: false, nullable: false,
},
needConfirmationToRead: {
type: 'boolean',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
reads: {
type: 'number',
optional: false, nullable: false,
@ -125,7 +156,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
silence: announcement.silence,
needConfirmationToRead: announcement.needConfirmationToRead,
userId: announcement.userId,
reads: reads.get(announcement)!,
reads: reads.get(announcement) ?? 0,
}));
});
}

View File

@ -0,0 +1,53 @@
/*
* 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 { AnnouncementsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:announcements',
errors: {
noSuchAnnouncement: {
message: 'No such announcement.',
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
announcementId: { type: 'string', format: 'misskey:id' },
},
required: ['announcementId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const announcement = await this.announcementsRepository.findOneBy({ id: ps.announcementId });
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await this.announcementService.resetReads(announcement.id, me);
});
}
}

View File

@ -96,6 +96,8 @@ export const moderationLogTypes = [
'updateUserAnnouncement',
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'resetReadsForGlobalAnnouncement',
'resetReadsForUserAnnouncement',
'resetPassword',
'suspendRemoteInstance',
'unsuspendRemoteInstance',
@ -241,6 +243,17 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
resetReadsForGlobalAnnouncement: {
announcementId: string;
announcement: any;
};
resetReadsForUserAnnouncement: {
announcementId: string;
announcement: any;
userId: string;
userUsername: string;
userHost: string | null;
};
resetPassword: {
userId: string;
userUsername: string;

View File

@ -39,7 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.needConfirmationToRead }}
<template #caption>{{ i18n.ts._announcement.needConfirmationToReadDescription }}</template>
</MkSwitch>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<div class="_buttons">
<MkButton v-if="announcement" @click="resetReads()">{{ i18n.ts.resetReads }}</MkButton>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div>
<div :class="$style.footer">
@ -117,6 +120,20 @@ async function done() {
}
}
async function resetReads() {
if (!props.announcement) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.resetReadsAreYouSure({ x: props.announcement.title }),
});
if (canceled) return;
await os.apiWithDialog('admin/announcements/reset-reads', {
announcementId: props.announcement.id,
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',

View File

@ -152,7 +152,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'announcements'" class="_gaps">
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkSelect v-model="announcementsStatus">
<template #label>{{ i18n.ts.filter }}</template>

View File

@ -33,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton rounded primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="announcement.id != null && announcement.isActive" rounded @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
<MkButton v-if="announcement.id != null && !announcement.isActive" rounded @click="unarchive(announcement)"><i class="ti ti-restore"></i> {{ i18n.ts.unarchive }}</MkButton>
<MkButton v-if="announcement.id != null && announcement.isActive && announcement.reads > 0" rounded @click="resetReads(announcement)">{{ i18n.ts.resetReads }}</MkButton>
<MkButton v-if="announcement.id != null" rounded danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</template>
@ -70,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
{{ i18n.ts._announcement.needConfirmationToRead }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<p v-if="announcement.reads > 0">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
</div>
</MkFolder>
<MkLoading v-if="loadingMore"/>
@ -84,6 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { ref, computed, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -141,6 +143,19 @@ function del(announcement) {
});
}
async function resetReads(announcement) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.resetReadsAreYouSure({ x: announcement.title }),
});
if (canceled) return;
await os.apiWithDialog('admin/announcements/reset-reads', {
announcementId: announcement.id,
});
refresh();
}
async function archive(announcement) {
await os.apiWithDialog('admin/announcements/update', {
...announcement,

View File

@ -63,9 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>
<span v-else-if="log.type === 'updateGlobalAnnouncement'">: {{ log.info.before.title }}</span>
<span v-else-if="log.type === 'deleteGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>
<span v-else-if="log.type === 'resetReadsForGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>
<span v-else-if="log.type === 'createUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'updateUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deleteUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetReadsForUserAnnouncement'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deleteNote'">: @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}</span>
<span v-else-if="log.type === 'deleteDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span>
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>

View File

@ -122,6 +122,9 @@ type AdminAnnouncementsListRequest = operations['admin___announcements___list'][
// @public (undocumented)
type AdminAnnouncementsListResponse = operations['admin___announcements___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AdminAnnouncementsResetReadsRequest = operations['admin___announcements___reset-reads']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminAnnouncementsUpdateRequest = operations['admin___announcements___update']['requestBody']['content']['application/json'];
@ -1493,6 +1496,7 @@ declare namespace entities {
AdminAnnouncementsDeleteRequest,
AdminAnnouncementsListRequest,
AdminAnnouncementsListResponse,
AdminAnnouncementsResetReadsRequest,
AdminAnnouncementsUpdateRequest,
AdminAvatarDecorationsCreateRequest,
AdminAvatarDecorationsCreateResponse,
@ -2836,6 +2840,12 @@ type ModerationLog = {
} | {
type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
type: 'resetReadsForGlobalAnnouncement';
info: ModerationLogPayloads['resetReadsForGlobalAnnouncement'];
} | {
type: 'resetReadsForUserAnnouncement';
info: ModerationLogPayloads['resetReadsForUserAnnouncement'];
} | {
type: 'resetPassword';
info: ModerationLogPayloads['resetPassword'];

View File

@ -184,6 +184,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:announcements*
*/
request<E extends 'admin/announcements/reset-reads', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -28,6 +28,7 @@ import type {
AdminAnnouncementsDeleteRequest,
AdminAnnouncementsListRequest,
AdminAnnouncementsListResponse,
AdminAnnouncementsResetReadsRequest,
AdminAnnouncementsUpdateRequest,
AdminAvatarDecorationsCreateRequest,
AdminAvatarDecorationsCreateResponse,
@ -657,6 +658,7 @@ export type Endpoints = {
'admin/announcements/create': { req: AdminAnnouncementsCreateRequest; res: AdminAnnouncementsCreateResponse };
'admin/announcements/delete': { req: AdminAnnouncementsDeleteRequest; res: EmptyResponse };
'admin/announcements/list': { req: AdminAnnouncementsListRequest; res: AdminAnnouncementsListResponse };
'admin/announcements/reset-reads': { req: AdminAnnouncementsResetReadsRequest; res: EmptyResponse };
'admin/announcements/update': { req: AdminAnnouncementsUpdateRequest; res: EmptyResponse };
'admin/avatar-decorations/create': { req: AdminAvatarDecorationsCreateRequest; res: AdminAvatarDecorationsCreateResponse };
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };

View File

@ -31,6 +31,7 @@ export type AdminAnnouncementsCreateResponse = operations['admin___announcements
export type AdminAnnouncementsDeleteRequest = operations['admin___announcements___delete']['requestBody']['content']['application/json'];
export type AdminAnnouncementsListRequest = operations['admin___announcements___list']['requestBody']['content']['application/json'];
export type AdminAnnouncementsListResponse = operations['admin___announcements___list']['responses']['200']['content']['application/json'];
export type AdminAnnouncementsResetReadsRequest = operations['admin___announcements___reset-reads']['requestBody']['content']['application/json'];
export type AdminAnnouncementsUpdateRequest = operations['admin___announcements___update']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsCreateResponse = operations['admin___avatar-decorations___create']['responses']['200']['content']['application/json'];

View File

@ -161,6 +161,15 @@ export type paths = {
*/
post: operations['admin___announcements___list'];
};
'/admin/announcements/reset-reads': {
/**
* admin/announcements/reset-reads
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:announcements*
*/
post: operations['admin___announcements___reset-reads'];
};
'/admin/announcements/update': {
/**
* admin/announcements/update
@ -6562,6 +6571,16 @@ export type operations = {
text: string;
title: string;
imageUrl: string | null;
/** @enum {string} */
icon: 'info' | 'warning' | 'error' | 'success';
/** @enum {string} */
display: 'normal' | 'banner' | 'dialog';
isActive: boolean;
forExistingUsers: boolean;
silence: boolean;
needConfirmationToRead: boolean;
/** Format: id */
userId: string | null;
reads: number;
})[];
};
@ -6598,6 +6617,58 @@ export type operations = {
};
};
};
/**
* admin/announcements/reset-reads
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:announcements*
*/
'admin___announcements___reset-reads': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
announcementId: string;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @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'];
};
};
};
};
/**
* admin/announcements/update
* @description No description provided.

View File

@ -314,6 +314,17 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
resetReadsForGlobalAnnouncement: {
announcementId: string;
announcement: Announcement;
};
resetReadsForUserAnnouncement: {
announcementId: string;
announcement: Announcement;
userId: string;
userUsername: string;
userHost: string | null;
};
resetPassword: {
userId: string;
userUsername: string;

View File

@ -111,6 +111,12 @@ export type ModerationLog = {
} | {
type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
type: 'resetReadsForGlobalAnnouncement';
info: ModerationLogPayloads['resetReadsForGlobalAnnouncement'];
} | {
type: 'resetReadsForUserAnnouncement';
info: ModerationLogPayloads['resetReadsForUserAnnouncement'];
} | {
type: 'resetPassword';
info: ModerationLogPayloads['resetPassword'];