From eeef3965b71431e183db41f481c8410d6c99ffe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Tue, 1 Aug 2023 18:59:01 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Revert=20"fix:=20=E9=9D=9E=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=82=A4=E3=83=B3=E7=8A=B6=E6=85=8B=E3=81=A7=E3=81=8A?= =?UTF-8?q?=E7=9F=A5=E3=82=89=E3=81=9B=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=81=8C=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=81=AE=E4=BF=AE=E6=AD=A3=20(#131)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 233d967b82b0c70473630e4b6be038eeddba24c8. --- packages/backend/src/server/api/endpoints/announcements.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 72a80f7700..d1a71ffe38 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -99,10 +99,6 @@ export default class extends Endpoint { query.orWhere('announcement."userId" = :userId', { userId: me.id }); } } else { - query.select([ - 'announcement.*', - 'FALSE as "isRead"', - ]); query.where('announcement."userId" IS NULL'); } From 04fefb20565920688f2081590ae506ca03ebe6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Tue, 15 Aug 2023 16:44:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Revert=20"feat:=20=E3=81=8A=E7=9F=A5?= =?UTF-8?q?=E3=82=89=E3=81=9B=E3=81=AE=E5=84=AA=E5=85=88=E9=A0=86=E4=BD=8D?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=20(#118)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit fe0f7a91a3fad3097e1b3cc5db4c9fdc3862defd. --- locales/en-US.yml | 1 - locales/index.d.ts | 1 - locales/ja-JP.yml | 1 - ...690463372775-announcement-display-order.js | 18 ----- .../src/models/entities/Announcement.ts | 7 -- .../endpoints/admin/announcements/create.ts | 6 -- .../api/endpoints/admin/announcements/list.ts | 25 +++---- .../endpoints/admin/announcements/update.ts | 2 - .../src/server/api/endpoints/announcements.ts | 49 +++++++------ .../src/pages/admin/announcements.vue | 71 +++++++------------ packages/frontend/src/pages/announcements.vue | 18 ++--- packages/frontend/src/ui/visitor.vue | 1 - 12 files changed, 69 insertions(+), 131 deletions(-) delete mode 100644 packages/backend/migration/1690463372775-announcement-display-order.js diff --git a/locales/en-US.yml b/locales/en-US.yml index 234ab73a69..49b0fde360 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -251,7 +251,6 @@ noSuchUser: "User not found" lookup: "Lookup" announcements: "Announcements" imageUrl: "Image URL" -displayOrder: "Position" remove: "Delete" removed: "Successfully deleted" removeAreYouSure: "Are you sure that you want to remove \"{x}\"?" diff --git a/locales/index.d.ts b/locales/index.d.ts index e97eec6c6d..e7f5a821b3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -254,7 +254,6 @@ export interface Locale { "lookup": string; "announcements": string; "imageUrl": string; - "displayOrder": string; "remove": string; "removed": string; "removeAreYouSure": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a4d877954e..a5ec475469 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -251,7 +251,6 @@ noSuchUser: "ユーザーが見つかりません" lookup: "照会" announcements: "お知らせ" imageUrl: "画像URL" -displayOrder: "表示順" remove: "削除" removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" diff --git a/packages/backend/migration/1690463372775-announcement-display-order.js b/packages/backend/migration/1690463372775-announcement-display-order.js deleted file mode 100644 index 81baa55897..0000000000 --- a/packages/backend/migration/1690463372775-announcement-display-order.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class AnnouncementDisplayOrder1690463372775 { - name = 'AnnouncementDisplayOrder1690463372775' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "announcement" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); - await queryRunner.query(`CREATE INDEX "IDX_b64d293ca4bef21e91963054b0" ON "announcement" ("displayOrder") `); - } - - async down(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_b64d293ca4bef21e91963054b0"`); - await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "displayOrder"`); - } -} diff --git a/packages/backend/src/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 4929a791f8..7a782b1e1d 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -38,13 +38,6 @@ export class Announcement { }) public imageUrl: string | null; - // UIに表示する際の並び順用(大きいほど先頭) - @Index() - @Column('integer', { - default: 0, - }) - public displayOrder: number; - @Index() @Column('varchar', { ...id(), diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 38f69a8925..246efcf14c 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -47,10 +47,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - displayOrder: { - type: 'number', - optional: false, nullable: false, - }, userId: { type: 'string', optional: false, nullable: true, @@ -69,7 +65,6 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, - displayOrder: { type: 'number' }, userId: { type: 'string', nullable: true, format: 'misskey:id' }, closeDuration: { type: 'number', nullable: false }, }, @@ -93,7 +88,6 @@ export default class extends Endpoint { title: ps.title, text: ps.text, imageUrl: ps.imageUrl, - displayOrder: ps.displayOrder, userId: ps.userId ?? null, closeDuration: ps.closeDuration, }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index a0d554d945..288b4e6659 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -53,10 +53,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - displayOrder: { - type: 'number', - optional: false, nullable: false, - }, userId: { type: 'string', optional: false, nullable: true, @@ -83,7 +79,8 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - offset: { type: 'integer', default: 0 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' }, }, required: [], @@ -102,25 +99,20 @@ export default class extends Endpoint { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private queryService: QueryService, private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.announcementsRepository.createQueryBuilder('announcement'); + const builder = this.announcementsRepository.createQueryBuilder('announcement'); if (ps.userId) { - query.where('"userId" = :userId', { userId: ps.userId }); + builder.where('"userId" = :userId', { userId: ps.userId }); } else { - query.where('"userId" IS NULL'); + builder.where('"userId" IS NULL'); } - query.orderBy({ - 'announcement."displayOrder"': 'DESC', - 'announcement."createdAt"': 'DESC', - }); + const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); - const announcements = await query - .offset(ps.offset) - .limit(ps.limit) - .getMany(); + const announcements = await query.limit(ps.limit).getMany(); const reads = new Map(); @@ -144,7 +136,6 @@ export default class extends Endpoint { title: announcement.title, text: announcement.text, imageUrl: announcement.imageUrl, - displayOrder: announcement.displayOrder, userId: announcement.userId, user: packedUsers.find(user => user.id === announcement.userId), reads: reads.get(announcement)!, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 9290b13706..2e18f1966f 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -31,7 +31,6 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 0 }, - displayOrder: { type: 'number' }, userId: { type: 'string', nullable: true, format: 'misskey:id' }, closeDuration: { type: 'number', nullable: false }, }, @@ -63,7 +62,6 @@ export default class extends Endpoint { text: ps.text, /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ imageUrl: ps.imageUrl || null, - displayOrder: ps.displayOrder, userId: ps.userId ?? null, closeDuration: ps.closeDuration, }); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index d1a71ffe38..b951c2bce2 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -5,9 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; -import { Announcement, AnnouncementRead } from '@/models/index.js'; +import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; export const meta = { tags: ['meta'], @@ -70,8 +70,9 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - offset: { type: 'integer', default: 0 }, withUnreads: { type: 'boolean', default: false }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, privateOnly: { type: 'boolean', default: false }, }, required: [], @@ -83,37 +84,39 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.announcementsRepository.createQueryBuilder('announcement'); + const builder = this.announcementsRepository.createQueryBuilder('announcement'); if (me) { - query.leftJoinAndSelect(AnnouncementRead, 'reads', 'reads."announcementId" = announcement.id AND reads."userId" = :userId', { userId: me.id }); - query.select([ - 'announcement.*', - 'CASE WHEN reads.id IS NULL THEN FALSE ELSE TRUE END as "isRead"', - ]); if (ps.privateOnly) { - query.where('announcement."userId" = :userId', { userId: me.id }); + builder.where('"userId" = :userId', { userId: me.id }); } else { - query.where('announcement."userId" IS NULL'); - query.orWhere('announcement."userId" = :userId', { userId: me.id }); + builder.where('"userId" IS NULL'); + builder.orWhere('"userId" = :userId', { userId: me.id }); } } else { - query.where('announcement."userId" IS NULL'); + builder.where('"userId" IS NULL'); } - query.orderBy({ - '"isRead"': 'ASC', - 'announcement."displayOrder"': 'DESC', - 'announcement."createdAt"': 'DESC', - }); + const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); + const announcements = await query.limit(ps.limit).getMany(); - const announcements = await query - .offset(ps.offset) - .limit(ps.limit) - .getRawMany(); + if (me) { + const reads = (await this.announcementReadsRepository.findBy({ + userId: me.id, + })).map(x => x.announcementId); - return (ps.withUnreads ? announcements.filter(i => !i.isRead) : announcements).map((a) => ({ + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ ...a, createdAt: a.createdAt.toISOString(), updatedAt: a.updatedAt?.toISOString() ?? null, diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index a713048f1f..7e493f5c67 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -35,9 +35,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - @@ -51,7 +48,6 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts.loadMore }} @@ -70,31 +66,33 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -const announceTitleEl = $shallowRef(null); -const user = ref(null); -const offset = ref(0); -const hasMore = ref(false); - let announcements: any[] = $ref([]); -function insertEmoji(ev: MouseEvent): void { - os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl); -} +const user = ref(null); +const announceTitleEl = $shallowRef(null); -function selectUserFilter(): void { +function selectUserFilter() { os.selectUser().then(_user => { user.value = _user; }); } -function editUser(announcement): void { +function editUser(an) { os.selectUser().then(_user => { - announcement.userId = _user.id; - announcement.user = _user; + an.userId = _user.id; + an.user = _user; }); } -function add(): void { +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl); +} + +os.api('admin/announcements/list').then(announcementResponse => { + announcements = announcementResponse; +}); + +function add() { announcements.unshift({ id: null, title: '', @@ -102,12 +100,11 @@ function add(): void { imageUrl: null, userId: null, user: null, - displayOrder: 0, closeDuration: 10, }); } -function remove(announcement): void { +function remove(announcement) { os.confirm({ type: 'warning', text: i18n.t('removeAreYouSure', { x: announcement.title }), @@ -118,14 +115,14 @@ function remove(announcement): void { }); } -function save(announcement): void { +function save(announcement) { if (announcement.id == null) { os.api('admin/announcements/create', announcement).then(() => { os.alert({ type: 'success', text: i18n.ts.saved, }); - fetch(true); + refresh(); }).catch(err => { os.alert({ type: 'error', @@ -147,26 +144,15 @@ function save(announcement): void { } } -function fetch(resetOffset = false): void { - if (resetOffset) { - announcements = []; - offset.value = 0; - } - - os.api('admin/announcements/list', { - offsetMode: true, - offset: offset.value, - limit: 10, - userId: user.value?.id, - }).then(announcementResponse => { - announcements = announcements.concat(announcementResponse); - hasMore.value = announcementResponse?.length === 10; - offset.value += announcements.length; +function refresh() { + os.api('admin/announcements/list', { userId: user.value?.id }).then(announcementResponse => { + announcements = announcementResponse; }); } -watch(user, () => fetch(true)); -fetch(); +watch(user, refresh); + +refresh(); const headerActions = $computed(() => [{ asFullButton: true, @@ -182,10 +168,3 @@ definePageMetadata({ icon: 'ti ti-speakerphone', }); - - diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index f5887faf85..a82d1da4c4 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 7e493f5c67..2e80d028f7 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -8,26 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - - - -
-
@{{ user.username }}
-
- {{ i18n.ts.selectUser }} - {{ i18n.ts.remove }} -
-
-
-
- - + + @@ -35,13 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - - -

{{ i18n.t('nUsersRead', { n: announcement.reads }) }}

- - {{ i18n.ts.specifyUser }}
{{ i18n.ts.save }} {{ i18n.ts.remove }} @@ -54,40 +32,17 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index c34ba4f83c..0ebdfc46d4 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -172,7 +172,6 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const inChannel = inject('inChannel', null); diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 559133ef46..fcde45405e 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -37,7 +37,6 @@ import { userPage } from '@/filters/user'; defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 9648b7230a..e3c9e2bd5f 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -31,7 +31,6 @@ import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const showContent = $ref(false); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 9304f17177..42823b88f0 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -17,7 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + +

@@ -30,6 +35,7 @@ import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: boolean | Ref; disabled?: boolean; + helpText?: string; }>(); const emit = defineEmits<{ @@ -41,10 +47,6 @@ const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); - - if (!checked.value) { - - } }; @@ -140,4 +142,10 @@ const toggle = () => { display: none; } } + +.help { + margin-left: 0.5em; + font-size: 85%; + vertical-align: top; +} diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue new file mode 100644 index 0000000000..ce41b3116d --- /dev/null +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -0,0 +1,145 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 2e80d028f7..bb903af459 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -7,9 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
-
+
+ {{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }} + + + + + + +
@@ -19,13 +30,33 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + {{ i18n.ts._announcement.forExistingUsers }} + + + {{ i18n.ts._announcement.needConfirmationToRead }} +

{{ i18n.t('nUsersRead', { n: announcement.reads }) }}

{{ i18n.ts.save }} - {{ i18n.ts.remove }} + {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) + {{ i18n.ts.delete }}
-
+
@@ -37,9 +68,13 @@ import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkFolder from '@/components/MkFolder.vue'; let announcements: any[] = $ref([]); @@ -49,17 +84,22 @@ os.api('admin/announcements/list').then(announcementResponse => { function add() { announcements.unshift({ + _id: Math.random().toString(36), id: null, - title: '', + title: 'New announcement', text: '', imageUrl: null, + icon: 'info', + display: 'normal', + forExistingUsers: false, + needConfirmationToRead: false, }); } -function remove(announcement) { +function del(announcement) { os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: announcement.title }), + text: i18n.t('deleteAreYouSure', { x: announcement.title }), }).then(({ canceled }) => { if (canceled) return; announcements = announcements.filter(x => x !== announcement); @@ -67,32 +107,20 @@ function remove(announcement) { }); } -function save(announcement) { +async function archive(announcement) { + await os.apiWithDialog('admin/announcements/update', { + ...announcement, + isActive: false, + }); + refresh(); +} + +async function save(announcement) { if (announcement.id == null) { - os.api('admin/announcements/create', announcement).then(() => { - os.alert({ - type: 'success', - text: i18n.ts.saved, - }); - refresh(); - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); - }); + await os.apiWithDialog('admin/announcements/create', announcement); + refresh(); } else { - os.api('admin/announcements/update', announcement).then(() => { - os.alert({ - type: 'success', - text: i18n.ts.saved, - }); - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); - }); + os.apiWithDialog('admin/announcements/update', announcement); } } diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index c04db7c4b5..e08c13f159 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -5,20 +5,36 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -27,28 +43,64 @@ SPDX-License-Identifier: AGPL-3.0-only import { } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -import { $i } from '@/account'; +import { $i, updateAccount } from '@/account'; -const pagination = { +const paginationCurrent = { endpoint: 'announcements' as const, limit: 10, + params: { + isActive: true, + }, }; -// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい -function read(items, announcement, i) { - items[i] = { - ...announcement, - isRead: true, - }; +const paginationPast = { + endpoint: 'announcements' as const, + limit: 10, + params: { + isActive: false, + }, +}; + +const paginationEl = ref>(); + +const tab = ref('current'); + +async function read(announcement) { + if (announcement.needConfirmationToRead) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts._announcement.readConfirmTitle, + text: i18n.t('_announcement.readConfirmText', { title: announcement.title }), + }); + if (confirm.canceled) return; + } + + if (!paginationEl.value) return; + paginationEl.value.updateItem(announcement.id, a => { + a.isRead = true; + return a; + }); os.api('i/read-announcement', { announcementId: announcement.id }); + updateAccount({ + unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), + }); } const headerActions = $computed(() => []); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'current', + title: i18n.ts.currentAnnouncements, + icon: 'ti ti-flare', +}, { + key: 'past', + title: i18n.ts.pastAnnouncements, + icon: 'ti ti-point', +}]); definePageMetadata({ title: i18n.ts.announcements, @@ -56,27 +108,34 @@ definePageMetadata({ }); - diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 266479610f..a6e2a7411c 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only isSensitive {{ i18n.ts.localOnly }} - {{ i18n.ts.delete }} + {{ i18n.ts.delete }}
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index e7c4ae88b5..af0ac2191b 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -86,7 +86,7 @@ const tagUsersPagination = $computed(() => ({ endpoint: 'hashtags/users' as const, limit: 30, params: { - tag: this.tag, + tag: props.tag, origin: 'combined', sort: '+follower', }, diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index e6fd9418d4..c94a6a1605 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -133,6 +133,31 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + +
+ {{ i18n.ts.new }} + + + + +
+
+ @@ -186,7 +211,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index f047474741..3518891811 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -113,6 +114,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); +const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const columnComponents = { main: XMainColumn, diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 1c09df8394..d9cb81b5ef 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -8,7 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -105,6 +110,7 @@ import { useScrollPositionManager } from '@/nirax'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); +const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 91c13a40fc..48604fd3b8 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -30,6 +30,10 @@ type Announcement = { text: string; title: string; imageUrl: string | null; + display: 'normal' | 'banner' | 'dialog'; + icon: 'info' | 'warning' | 'error' | 'success'; + needConfirmationToRead: boolean; + forYou: boolean; isRead?: boolean; }; @@ -2473,6 +2477,7 @@ type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 1be7e11911..299cd7233a 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -106,6 +106,7 @@ export type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; @@ -418,6 +419,10 @@ export type Announcement = { text: string; title: string; imageUrl: string | null; + display: 'normal' | 'banner' | 'dialog'; + icon: 'info' | 'warning' | 'error' | 'success'; + needConfirmationToRead: boolean; + forYou: boolean; isRead?: boolean; }; From 3c2b83c3ae3d3e17c1b598aa612f1efe8978e2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Thu, 17 Aug 2023 03:08:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20io=E3=82=AB=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=A0=E3=81=A8=E7=AB=B6=E5=90=88=E3=81=99=E3=82=8B=E3=83=9E?= =?UTF-8?q?=E3=82=A4=E3=82=B0=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/1691649257651-refine-announcement.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/migration/1691649257651-refine-announcement.js b/packages/backend/migration/1691649257651-refine-announcement.js index d8d63f3103..16fe987b0f 100644 --- a/packages/backend/migration/1691649257651-refine-announcement.js +++ b/packages/backend/migration/1691649257651-refine-announcement.js @@ -6,19 +6,19 @@ export class RefineAnnouncement1691649257651 { await queryRunner.query(`ALTER TABLE "announcement" ADD "needConfirmationToRead" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "announcement" ADD "isActive" boolean NOT NULL DEFAULT true`); await queryRunner.query(`ALTER TABLE "announcement" ADD "forExistingUsers" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "announcement" ADD "userId" character varying(32)`); + // await queryRunner.query(`ALTER TABLE "announcement" ADD "userId" character varying(32)`); await queryRunner.query(`CREATE INDEX "IDX_bc1afcc8ef7e9400cdc3c0a87e" ON "announcement" ("isActive") `); await queryRunner.query(`CREATE INDEX "IDX_da795d3a83187e8832005ba19d" ON "announcement" ("forExistingUsers") `); - await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); - await queryRunner.query(`ALTER TABLE "announcement" ADD CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + // await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); + // await queryRunner.query(`ALTER TABLE "announcement" ADD CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); } async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "announcement" DROP CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8"`); - await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); + // await queryRunner.query(`ALTER TABLE "announcement" DROP CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8"`); + // await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); await queryRunner.query(`DROP INDEX "public"."IDX_da795d3a83187e8832005ba19d"`); await queryRunner.query(`DROP INDEX "public"."IDX_bc1afcc8ef7e9400cdc3c0a87e"`); - await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); + // await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forExistingUsers"`); await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isActive"`); await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "needConfirmationToRead"`); From 6893e5d60b6686ed9e9c01cbc18bea890c00024b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Sat, 19 Aug 2023 04:48:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E3=81=8A=E7=9F=A5=E3=82=89?= =?UTF-8?q?=E3=81=9B=E3=81=AE=E7=A2=BA=E8=AA=8D=E5=BE=85=E6=A9=9F=E6=99=82?= =?UTF-8?q?=E9=96=93=E3=83=BB=E5=84=AA=E5=85=88=E9=A0=86=E4=BD=8D=E6=A9=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b7fd6bf33a835fd73c2a86eb007074d3680f6efd によるリワーク This reverts commit eeef3965b71431e183db41f481c8410d6c99ffe4 This reverts commit 04fefb20565920688f2081590ae506ca03ebe6ee This reverts commit 576251200f7d7910e5fc50e2964a5196b573038a --- locales/en-US.yml | 1 + locales/index.d.ts | 2 + locales/ja-JP.yml | 2 + .../1688647797135-userannouncement.js | 20 + ...690463372775-announcement-display-order.js | 18 + .../backend/src/core/AnnouncementService.ts | 385 ++++++++++++++---- packages/backend/src/core/CoreModule.ts | 6 + .../entities/AnnouncementEntityService.ts | 71 ++++ .../src/models/entities/Announcement.ts | 14 + .../src/models/json-schema/announcement.ts | 8 + .../endpoints/admin/announcements/create.ts | 48 ++- .../endpoints/admin/announcements/delete.ts | 9 +- .../api/endpoints/admin/announcements/list.ts | 85 ++-- .../endpoints/admin/announcements/update.ts | 22 +- .../src/server/api/endpoints/announcements.ts | 14 +- .../api/endpoints/i/read-announcement.ts | 2 +- .../backend/test/unit/AnnouncementService.ts | 18 +- packages/frontend/src/boot/main-boot.ts | 42 +- .../src/components/MkAnnouncementDialog.vue | 52 ++- .../MkUserAnnouncementEditDialog.vue | 60 ++- .../src/pages/admin/announcements.vue | 120 ++++-- packages/frontend/src/pages/announcements.vue | 34 +- packages/frontend/src/pages/user-info.vue | 51 ++- packages/frontend/src/ui/visitor.vue | 1 + packages/misskey-js/etc/misskey-js.api.md | 7 +- packages/misskey-js/src/api.types.ts | 2 +- packages/misskey-js/src/entities.ts | 2 + 27 files changed, 832 insertions(+), 264 deletions(-) create mode 100644 packages/backend/migration/1688647797135-userannouncement.js create mode 100644 packages/backend/migration/1690463372775-announcement-display-order.js create mode 100644 packages/backend/src/core/entities/AnnouncementEntityService.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 49b0fde360..234ab73a69 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -251,6 +251,7 @@ noSuchUser: "User not found" lookup: "Lookup" announcements: "Announcements" imageUrl: "Image URL" +displayOrder: "Position" remove: "Delete" removed: "Successfully deleted" removeAreYouSure: "Are you sure that you want to remove \"{x}\"?" diff --git a/locales/index.d.ts b/locales/index.d.ts index e447caf55d..fc858aa288 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -254,6 +254,7 @@ export interface Locale { "lookup": string; "announcements": string; "imageUrl": string; + "displayOrder": string; "remove": string; "removed": string; "removeAreYouSure": string; @@ -1075,6 +1076,7 @@ export interface Locale { "additionalEmojiDictionary": string; "installed": string; "branding": string; + "dialogCloseDuration": string; "enableServerMachineStats": string; "enableIdenticonGeneration": string; "turnOffToImprovePerformance": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d54d7eb208..ccb84da12c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -251,6 +251,7 @@ noSuchUser: "ユーザーが見つかりません" lookup: "照会" announcements: "お知らせ" imageUrl: "画像URL" +displayOrder: "表示順" remove: "削除" removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" @@ -1072,6 +1073,7 @@ goToMisskey: "Misskeyへ" additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" branding: "ブランディング" +dialogCloseDuration: "ダイアログを閉じるまでの待機時間" enableServerMachineStats: "サーバーのマシン情報を公開する" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" diff --git a/packages/backend/migration/1688647797135-userannouncement.js b/packages/backend/migration/1688647797135-userannouncement.js new file mode 100644 index 0000000000..6b3ca6cd6b --- /dev/null +++ b/packages/backend/migration/1688647797135-userannouncement.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Userannouncement1688647797135 { + name = 'Userannouncement1688647797135' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "userId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "closeDuration" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "closeDuration"`); + } +} diff --git a/packages/backend/migration/1690463372775-announcement-display-order.js b/packages/backend/migration/1690463372775-announcement-display-order.js new file mode 100644 index 0000000000..81baa55897 --- /dev/null +++ b/packages/backend/migration/1690463372775-announcement-display-order.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AnnouncementDisplayOrder1690463372775 { + name = 'AnnouncementDisplayOrder1690463372775' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`CREATE INDEX "IDX_b64d293ca4bef21e91963054b0" ON "announcement" ("displayOrder") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_b64d293ca4bef21e91963054b0"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 482aeee39f..e00bd2582c 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -4,14 +4,17 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import { DI } from '@/di-symbols.js'; +import { Brackets, In } from 'typeorm'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; +import { Announcement, AnnouncementRead } from '@/models/index.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { bindThis } from '@/decorators.js'; -import { Packed } from '@/misc/json-schema.js'; -import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; @Injectable() export class AnnouncementService { @@ -22,63 +25,52 @@ export class AnnouncementService { @Inject(DI.announcementReadsRepository) private announcementReadsRepository: AnnouncementReadsRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, + private userEntityService: UserEntityService, + private announcementEntityService: AnnouncementEntityService, private globalEventService: GlobalEventService, - ) { - } + ) {} @bindThis - public async getReads(userId: User['id']): Promise { - return this.announcementReadsRepository.findBy({ - userId: userId, - }); - } + public async create( + values: Partial, + ): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { + const announcement = await this.announcementsRepository + .insert({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: null, + title: values.title, + text: values.text, + imageUrl: values.imageUrl, + icon: values.icon, + display: values.display, + forExistingUsers: values.forExistingUsers, + needConfirmationToRead: values.needConfirmationToRead, + closeDuration: values.closeDuration, + displayOrder: values.displayOrder, + userId: values.userId, + }) + .then((x) => + this.announcementsRepository.findOneByOrFail(x.identifiers[0]), + ); - @bindThis - public async getUnreadAnnouncements(user: User): Promise { - const readsQuery = this.announcementReadsRepository.createQueryBuilder('read') - .select('read.announcementId') - .where('read.userId = :userId', { userId: user.id }); - - const q = this.announcementsRepository.createQueryBuilder('announcement') - .where('announcement.isActive = true') - .andWhere(new Brackets(qb => { - qb.orWhere('announcement.userId = :userId', { userId: user.id }); - qb.orWhere('announcement.userId IS NULL'); - })) - .andWhere(new Brackets(qb => { - qb.orWhere('announcement.forExistingUsers = false'); - qb.orWhere('announcement.createdAt > :createdAt', { createdAt: user.createdAt }); - })) - .andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`); - - q.setParameters(readsQuery.getParameters()); - - return q.getMany(); - } - - @bindThis - public async create(values: Partial): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { - const announcement = await this.announcementsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - updatedAt: null, - title: values.title, - text: values.text, - imageUrl: values.imageUrl, - icon: values.icon, - display: values.display, - forExistingUsers: values.forExistingUsers, - needConfirmationToRead: values.needConfirmationToRead, - userId: values.userId, - }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); - - const packed = (await this.packMany([announcement]))[0]; + const packed = await this.announcementEntityService.pack( + announcement, + null, + ); if (values.userId) { - this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { - announcement: packed, - }); + this.globalEventService.publishMainStream( + values.userId, + 'announcementCreated', + { + announcement: packed, + }, + ); } else { this.globalEventService.publishBroadcastStream('announcementCreated', { announcement: packed, @@ -92,44 +84,271 @@ export class AnnouncementService { } @bindThis - public async read(user: User, announcementId: Announcement['id']): Promise { + public async list( + userId: User['id'] | null, + limit: number, + offset: number, + moderator: User, + ): Promise<(Announcement & { userInfo: Packed<'UserLite'> | null, readCount: number })[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + if (userId) { + query.andWhere('announcement."userId" = :userId', { userId: userId }); + } else { + query.andWhere('announcement."userId" IS NULL'); + } + + query.orderBy({ + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + const announcements = await query + .limit(limit) + .offset(offset) + .getMany(); + + const reads = new Map(); + + for (const announcement of announcements) { + reads.set(announcement, await this.announcementReadsRepository.countBy({ + announcementId: announcement.id, + })); + } + + const users = await this.usersRepository.findBy({ + id: In(announcements.map(a => a.userId).filter(id => id != null)), + }); + const packedUsers = await this.userEntityService.packMany(users, moderator, { + detail: false, + }); + + return announcements.map(announcement => ({ + ...announcement, + userInfo: packedUsers.find(u => u.id === announcement.userId) ?? null, + readCount: reads.get(announcement) ?? 0, + })); + } + + @bindThis + public async update( + announcementId: Announcement['id'], + values: Partial, + ): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { + const oldAnnouncement = await this.announcementsRepository.findOneByOrFail({ + id: announcementId, + }); + + if (oldAnnouncement.userId && oldAnnouncement.userId !== values.userId) { + await this.announcementReadsRepository.delete({ + announcementId: announcementId, + userId: oldAnnouncement.userId, + }); + } + + const announcement = await this.announcementsRepository + .update(announcementId, { + updatedAt: new Date(), + isActive: values.isActive, + title: values.title, + text: values.text, + imageUrl: values.imageUrl !== '' ? values.imageUrl : null, + icon: values.icon, + display: values.display, + forExistingUsers: values.forExistingUsers, + needConfirmationToRead: values.needConfirmationToRead, + closeDuration: values.closeDuration, + displayOrder: values.displayOrder, + userId: values.userId, + }) + .then(() => + this.announcementsRepository.findOneByOrFail({ id: announcementId }), + ); + + const packed = await this.announcementEntityService.pack( + announcement, + values.userId ? { id: values.userId } : null, + ); + + if (values.userId) { + this.globalEventService.publishMainStream( + values.userId, + 'announcementCreated', + { + announcement: packed, + }, + ); + } else { + this.globalEventService.publishBroadcastStream('announcementCreated', { + announcement: packed, + }); + } + + return { + raw: announcement, + packed: packed, + }; + } + + @bindThis + public async delete(announcementId: Announcement['id']): Promise { + await this.announcementReadsRepository.delete({ + announcementId: announcementId, + }); + await this.announcementsRepository.delete({ id: announcementId }); + } + + @bindThis + public async getAnnouncements( + me: User | null, + limit: number, + offset: number, + isActive?: boolean, + ): Promise[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + if (me) { + query.leftJoin( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.select([ + 'announcement.*', + 'CASE WHEN read.id IS NULL THEN FALSE ELSE TRUE END as "isRead"', + ]); + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + } else { + query.select([ + 'announcement.*', + 'NULL as "isRead"', + ]); + query.andWhere('announcement."userId" IS NULL'); + query.andWhere('announcement."forExistingUsers" = false'); + } + + if (isActive !== undefined) { + query.andWhere('announcement."isActive" = :isActive', { + isActive: isActive, + }); + } + + query.orderBy({ + '"isRead"': 'ASC', + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + return this.announcementEntityService.packMany( + await query + .limit(limit) + .offset(offset) + .getRawMany(), + me, + ); + } + + @bindThis + public async getUnreadAnnouncements(me: User): Promise[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + query.leftJoinAndSelect( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.andWhere('read.id IS NULL'); + query.andWhere('announcement."isActive" = true'); + + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + + query.orderBy({ + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + return this.announcementEntityService.packMany( + await query.getMany(), + me, + ); + } + + @bindThis + public async countUnreadAnnouncements(me: User): Promise { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + query.leftJoinAndSelect( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.andWhere('read.id IS NULL'); + query.andWhere('announcement."isActive" = true'); + + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + + return query.getCount(); + } + + @bindThis + public async markAsRead( + me: User, + announcementId: Announcement['id'], + ): Promise { try { await this.announcementReadsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), announcementId: announcementId, - userId: user.id, + userId: me.id, }); } catch (e) { return; } - if ((await this.getUnreadAnnouncements(user)).length === 0) { - this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); + if ((await this.countUnreadAnnouncements(me)) === 0) { + this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); } } - - @bindThis - public async packMany( - announcements: Announcement[], - me?: { id: User['id'] } | null | undefined, - options?: { - reads?: AnnouncementRead[]; - }, - ): Promise[]> { - const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: announcement.createdAt.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - text: announcement.text, - title: announcement.title, - imageUrl: announcement.imageUrl, - icon: announcement.icon, - display: announcement.display, - needConfirmationToRead: announcement.needConfirmationToRead, - forYou: announcement.userId === me?.id, - isRead: reads.some(read => read.announcementId === announcement.id), - })); - } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 51d4f9cfa9..23055d0fba 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -72,6 +72,7 @@ import PerUserDriveChart from './chart/charts/per-user-drive.js'; import ApRequestChart from './chart/charts/ap-request.js'; import { ChartManagementService } from './chart/ChartManagementService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; @@ -198,6 +199,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -324,6 +326,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -445,6 +448,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRequestChart, $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -566,6 +570,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -686,6 +691,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRequestChart, $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts new file mode 100644 index 0000000000..ffe480fb1a --- /dev/null +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { + AnnouncementReadsRepository, + AnnouncementsRepository, +} from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { Announcement, User } from '@/models/index.js'; + +@Injectable() +export class AnnouncementEntityService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + ) { + } + + @bindThis + public async pack( + src: Announcement['id'] | Announcement & { isRead?: boolean | null }, + me: { id: User['id'] } | null | undefined, + ): Promise> { + const announcement = typeof src === 'object' + ? src + : await this.announcementsRepository.findOneByOrFail({ + id: src, + }) as Announcement & { isRead?: boolean | null }; + + if (me && announcement.isRead === undefined) { + announcement.isRead = await this.announcementReadsRepository.countBy({ + announcementId: announcement.id, + userId: me.id, + }).then(count => count > 0); + } + + return { + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forYou: announcement.userId === me?.id, + needConfirmationToRead: announcement.needConfirmationToRead, + closeDuration: announcement.closeDuration, + displayOrder: announcement.displayOrder, + isRead: announcement.isRead !== null ? announcement.isRead : undefined, + }; + } + + @bindThis + public async packMany( + announcements: (Announcement['id'] | Announcement & { isRead?: boolean | null } | Announcement)[], + me: { id: User['id'] } | null | undefined, + ) : Promise[]> { + return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 18c26faab0..7dcd0c1755 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -60,12 +60,26 @@ export class Announcement { }) public needConfirmationToRead: boolean; + @Column('integer', { + nullable: false, + default: 0, + }) + public closeDuration: number; + @Index() @Column('boolean', { default: true, }) public isActive: boolean; + // UIに表示する際の並び順用(大きいほど先頭) + @Index() + @Column('integer', { + nullable: false, + default: 0, + }) + public displayOrder: number; + @Index() @Column('boolean', { default: false, diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts index c7e24c7f29..e23620e992 100644 --- a/packages/backend/src/models/json-schema/announcement.ts +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -50,6 +50,14 @@ export const packedAnnouncementSchema = { type: 'boolean', optional: false, nullable: false, }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, + displayOrder: { + type: 'number', + optional: false, nullable: false, + }, isRead: { type: 'boolean', optional: true, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 6c5520c2ef..c60d0d1889 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -45,6 +45,34 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + icon: { + type: 'string', + optional: false, nullable: false, + }, + display: { + type: 'string', + optional: false, nullable: false, + }, + forExistingUsers: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, + displayOrder: { + type: 'number', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, }, }, } as const; @@ -59,6 +87,8 @@ export const paramDef = { display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, forExistingUsers: { type: 'boolean', default: false }, needConfirmationToRead: { type: 'boolean', default: false }, + closeDuration: { type: 'number', default: 0 }, + displayOrder: { type: 'number', default: 0 }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], @@ -81,10 +111,26 @@ export default class extends Endpoint { display: ps.display, forExistingUsers: ps.forExistingUsers, needConfirmationToRead: ps.needConfirmationToRead, + closeDuration: ps.closeDuration, + displayOrder: ps.displayOrder, userId: ps.userId, }); - return packed; + return { + id: packed.id, + createdAt: packed.createdAt, + updatedAt: packed.updatedAt, + title: packed.title, + text: packed.text, + imageUrl: packed.imageUrl, + icon: packed.icon, + display: packed.display, + forExistingUsers: raw.forExistingUsers, + needConfirmationToRead: packed.needConfirmationToRead, + closeDuration: packed.closeDuration, + displayOrder: packed.displayOrder, + userId: raw.userId, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts index 6066a3ceaf..02dc7e7896 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; +import type { AnnouncementsRepository } from '@/models/index.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -38,13 +39,15 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - await this.announcementsRepository.delete(announcement.id); + await this.announcementService.delete(announcement.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 4da3f457f9..7dac500799 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -3,12 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js'; -import type { Announcement } from '@/models/entities/Announcement.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { DI } from '@/di-symbols.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -39,19 +36,56 @@ export const meta = { optional: false, nullable: true, format: 'date-time', }, - text: { - type: 'string', + isActive: { + type: 'boolean', optional: false, nullable: false, }, title: { type: 'string', optional: false, nullable: false, }, + text: { + type: 'string', + optional: false, nullable: false, + }, imageUrl: { type: 'string', optional: false, nullable: true, }, - reads: { + icon: { + type: 'string', + optional: false, nullable: false, + }, + display: { + type: 'string', + optional: false, nullable: false, + }, + forExistingUsers: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, + displayOrder: { + type: 'number', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: true, + ref: 'UserLite', + }, + readCount: { type: 'number', optional: false, nullable: false, }, @@ -64,8 +98,7 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], @@ -75,46 +108,28 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - private queryService: QueryService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - if (ps.userId) { - query.andWhere('announcement.userId = :userId', { userId: ps.userId }); - } else { - query.andWhere('announcement.userId IS NULL'); - } - - const announcements = await query.limit(ps.limit).getMany(); - - const reads = new Map(); - - for (const announcement of announcements) { - reads.set(announcement, await this.announcementReadsRepository.countBy({ - announcementId: announcement.id, - })); - } + const announcements = await this.announcementService.list(ps.userId ?? null, ps.limit, ps.offset, me); return announcements.map(announcement => ({ id: announcement.id, createdAt: announcement.createdAt.toISOString(), updatedAt: announcement.updatedAt?.toISOString() ?? null, + isActive: announcement.isActive, title: announcement.title, text: announcement.text, imageUrl: announcement.imageUrl, icon: announcement.icon, display: announcement.display, - isActive: announcement.isActive, forExistingUsers: announcement.forExistingUsers, needConfirmationToRead: announcement.needConfirmationToRead, + closeDuration: announcement.closeDuration, + displayOrder: announcement.displayOrder, userId: announcement.userId, - reads: reads.get(announcement)!, + user: announcement.userInfo, + readCount: announcement.readCount, })); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 7efc7c0402..a7fe791c20 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; +import type { AnnouncementsRepository } from '@/models/index.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -35,6 +36,8 @@ export const paramDef = { display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, forExistingUsers: { type: 'boolean' }, needConfirmationToRead: { type: 'boolean' }, + closeDuration: { type: 'number', default: 0 }, + displayOrder: { type: 'number', default: 0 }, isActive: { type: 'boolean' }, }, required: ['id'], @@ -46,24 +49,15 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - await this.announcementsRepository.update(announcement.id, { - updatedAt: new Date(), - title: ps.title, - text: ps.text, - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ - imageUrl: ps.imageUrl || null, - display: ps.display, - icon: ps.icon, - forExistingUsers: ps.forExistingUsers, - needConfirmationToRead: ps.needConfirmationToRead, - isActive: ps.isActive, - }); + await this.announcementService.update(announcement.id, ps); }); } } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 070e6f0d77..372c002f14 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -31,8 +31,7 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, isActive: { type: 'boolean', default: true }, }, required: [], @@ -52,16 +51,7 @@ export default class extends Endpoint { private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) - .where('announcement.isActive = :isActive', { isActive: ps.isActive }) - .andWhere(new Brackets(qb => { - if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); - qb.orWhere('announcement.userId IS NULL'); - })); - - const announcements = await query.limit(ps.limit).getMany(); - - return this.announcementService.packMany(announcements, me); + return this.announcementService.getAnnouncements(me, ps.limit, ps.offset, ps.isActive); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index 412532939c..ed26658d4f 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -33,7 +33,7 @@ export default class extends Endpoint { private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - await this.announcementService.read(me, ps.announcementId); + await this.announcementService.markAsRead(me, ps.announcementId); }); } } diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index c97e081ba5..221eee204a 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -5,18 +5,19 @@ process.env.NODE_ENV = 'test'; -import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { jest } from '@jest/globals'; import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { genAid } from '@/misc/id/aid.js'; -import { CacheService } from '@/core/CacheService.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { IdService } from '@/core/IdService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { genAid } from '@/misc/id/aid.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -60,6 +61,7 @@ describe('AnnouncementService', () => { GlobalModule, ], providers: [ + AnnouncementEntityService, AnnouncementService, CacheService, IdService, diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 9ab1f6e14c..2038ef3455 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -89,15 +89,6 @@ export async function mainBoot() { }, {}, 'closed'); } - stream.on('announcementCreated', (ev) => { - const announcement = ev.announcement; - if (announcement.display === 'dialog') { - popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { - announcement, - }, {}, 'closed'); - } - }); - if ($i.isDeleted) { alert({ type: 'warning', @@ -224,6 +215,20 @@ export async function mainBoot() { updateAccount(i); }); + main.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + updateAccount({ + hasUnreadAnnouncement: true, + unreadAnnouncements: [...($i?.unreadAnnouncements ?? []), announcement], + }); + + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + main.on('readAllNotifications', () => { updateAccount({ hasUnreadNotification: false }); }); @@ -257,8 +262,25 @@ export async function mainBoot() { sound.play('antenna'); }); + stream.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + updateAccount({ + hasUnreadAnnouncement: true, + unreadAnnouncements: [...($i?.unreadAnnouncements ?? []), announcement], + }); + + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + main.on('readAllAnnouncements', () => { - updateAccount({ hasUnreadAnnouncement: false }); + updateAccount({ + hasUnreadAnnouncement: false, + unreadAnnouncements: [], + }); }); // トークンが再生成されたとき diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 8e11053813..0888ca3185 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -13,16 +13,16 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ announcement.title }} +
-
- {{ i18n.ts.ok }} +
+ {{ i18n.ts.gotIt }} ({{ sec }})
@@ -94,11 +116,7 @@ onMounted(() => { margin-right: 0.5em; } -.title { - font-weight: bold; -} - -.text { +.content { margin: 1em 0; } diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index ce41b3116d..387d493065 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -6,8 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index bb903af459..fc19102dab 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -8,7 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }} + + + + + + + +
+
@{{ user.username }}
+
+ {{ i18n.ts.selectUser }} + {{ i18n.ts.remove }} +
+
+
+
@@ -21,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- - + + @@ -49,40 +64,69 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._announcement.needConfirmationToRead }} -

{{ i18n.t('nUsersRead', { n: announcement.reads }) }}

+ + + + + + + +

{{ i18n.t('nUsersRead', { n: announcement.readCount }) }}

+ + {{ i18n.ts.specifyUser }}
- {{ i18n.ts.save }} - {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) + {{ i18n.ts.save }} + {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) {{ i18n.ts.delete }}
+ {{ i18n.ts.loadMore }}
+ + diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index e08c13f159..8b54cb28d9 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ announcement.title }} +
@@ -40,17 +40,18 @@ SPDX-License-Identifier: AGPL-3.0-only