feat: 個別のお知らせにリンクで飛べるように (#13885)
* feat(announcement): 個別のお知らせにリンクで飛べるように (MisskeyIO#639)
(cherry picked from commit f6bf7f992a)
* fix
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
* fix
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
* 一覧ページではお知らせpanel全体を押せるように
* お知らせバーは個別ページに飛ばすように
* Update Changelog
* spdx
* attempt to fox test
* remove unnecessary thong
* `announcement` → `announcements/show`
* リンクを押せる場所をタイトルと日付部分のみに変更
---------
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
			
			
This commit is contained in:
		
							parent
							
								
									e0b47999fa
								
							
						
					
					
						commit
						3ffbf6296f
					
				|  | @ -25,6 +25,8 @@ | |||
| 
 | ||||
| ### Client | ||||
| - Feat: アップロードするファイルの名前をランダム文字列にできるように | ||||
| - Feat: 個別のお知らせにリンクで飛べるように   | ||||
|   (Cherry-picked from https://github.com/MisskeyIO/misskey) | ||||
| - Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように | ||||
| - Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように | ||||
| - Enhance: リアクション・いいねの総数を表示するように | ||||
|  |  | |||
|  | @ -4,13 +4,14 @@ | |||
|  */ | ||||
| 
 | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { Brackets, EntityNotFoundError } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { MiUser } from '@/models/User.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Packed } from '@/misc/json-schema.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| 
 | ||||
|  | @ -29,6 +30,7 @@ export class AnnouncementService { | |||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 		private announcementEntityService: AnnouncementEntityService, | ||||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
|  | @ -79,7 +81,7 @@ export class AnnouncementService { | |||
| 			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); | ||||
| 
 | ||||
| 		if (values.userId) { | ||||
| 			this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { | ||||
|  | @ -177,6 +179,24 @@ export class AnnouncementService { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> { | ||||
| 		const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId }); | ||||
| 		if (me) { | ||||
| 			if (announcement.userId && announcement.userId !== me.id) { | ||||
| 				throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId }); | ||||
| 			} | ||||
| 
 | ||||
| 			const read = await this.announcementReadsRepository.findOneBy({ | ||||
| 				announcementId: announcement.id, | ||||
| 				userId: me.id, | ||||
| 			}); | ||||
| 			return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me); | ||||
| 		} else { | ||||
| 			return this.announcementEntityService.pack(announcement, null); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> { | ||||
| 		try { | ||||
|  | @ -193,29 +213,4 @@ export class AnnouncementService { | |||
| 			this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		announcements: MiAnnouncement[], | ||||
| 		me?: { id: MiUser['id'] } | null | undefined, | ||||
| 		options?: { | ||||
| 			reads?: MiAnnouncementRead[]; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Announcement'>[]> { | ||||
| 		const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; | ||||
| 		return announcements.map(announcement => ({ | ||||
| 			id: announcement.id, | ||||
| 			createdAt: this.idService.parse(announcement.id).date.toISOString(), | ||||
| 			updatedAt: announcement.updatedAt?.toISOString() ?? null, | ||||
| 			text: announcement.text, | ||||
| 			title: announcement.title, | ||||
| 			imageUrl: announcement.imageUrl, | ||||
| 			icon: announcement.icon, | ||||
| 			display: announcement.display, | ||||
| 			needConfirmationToRead: announcement.needConfirmationToRead, | ||||
| 			silence: announcement.silence, | ||||
| 			forYou: announcement.userId === me?.id, | ||||
| 			isRead: reads.some(read => read.announcementId === announcement.id), | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -84,6 +84,7 @@ 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'; | ||||
|  | @ -223,6 +224,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 }; | ||||
|  | @ -363,6 +365,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | |||
| 		ChartManagementService, | ||||
| 
 | ||||
| 		AbuseUserReportEntityService, | ||||
| 		AnnouncementEntityService, | ||||
| 		AntennaEntityService, | ||||
| 		AppEntityService, | ||||
| 		AuthSessionEntityService, | ||||
|  | @ -499,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | |||
| 		$ChartManagementService, | ||||
| 
 | ||||
| 		$AbuseUserReportEntityService, | ||||
| 		$AnnouncementEntityService, | ||||
| 		$AntennaEntityService, | ||||
| 		$AppEntityService, | ||||
| 		$AuthSessionEntityService, | ||||
|  | @ -635,6 +639,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | |||
| 		ChartManagementService, | ||||
| 
 | ||||
| 		AbuseUserReportEntityService, | ||||
| 		AnnouncementEntityService, | ||||
| 		AntennaEntityService, | ||||
| 		AppEntityService, | ||||
| 		AuthSessionEntityService, | ||||
|  | @ -770,6 +775,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | |||
| 		$ChartManagementService, | ||||
| 
 | ||||
| 		$AbuseUserReportEntityService, | ||||
| 		$AnnouncementEntityService, | ||||
| 		$AntennaEntityService, | ||||
| 		$AppEntityService, | ||||
| 		$AuthSessionEntityService, | ||||
|  |  | |||
|  | @ -0,0 +1,71 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class AnnouncementEntityService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
| 
 | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async pack( | ||||
| 		src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null }, | ||||
| 		me?: { id: MiUser['id'] } | null | undefined, | ||||
| 	): Promise<Packed<'Announcement'>> { | ||||
| 		const announcement = typeof src === 'object' | ||||
| 			? src | ||||
| 			: await this.announcementsRepository.findOneByOrFail({ | ||||
| 				id: src, | ||||
| 			}) as MiAnnouncement & { isRead?: boolean | null }; | ||||
| 
 | ||||
| 		if (me && announcement.isRead === undefined) { | ||||
| 			announcement.isRead = await this.announcementReadsRepository | ||||
| 				.countBy({ | ||||
| 					announcementId: announcement.id, | ||||
| 					userId: me.id, | ||||
| 				}) | ||||
| 				.then((count: number) => count > 0); | ||||
| 		} | ||||
| 
 | ||||
| 		return { | ||||
| 			id: announcement.id, | ||||
| 			createdAt: this.idService.parse(announcement.id).date.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, | ||||
| 			silence: announcement.silence, | ||||
| 			isRead: announcement.isRead !== null ? announcement.isRead : undefined, | ||||
| 		}; | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[], | ||||
| 		me?: { id: MiUser['id'] } | null | undefined, | ||||
| 	) : Promise<Packed<'Announcement'>[]> { | ||||
| 		return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) | ||||
| 			.filter(result => result.status === 'fulfilled') | ||||
| 			.map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value); | ||||
| 	} | ||||
| } | ||||
|  | @ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js' | |||
| import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; | ||||
| import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; | ||||
| import * as ep___announcements from './endpoints/announcements.js'; | ||||
| import * as ep___announcements_show from './endpoints/announcements/show.js'; | ||||
| import * as ep___antennas_create from './endpoints/antennas/create.js'; | ||||
| import * as ep___antennas_delete from './endpoints/antennas/delete.js'; | ||||
| import * as ep___antennas_list from './endpoints/antennas/list.js'; | ||||
|  | @ -455,6 +456,7 @@ const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', us | |||
| const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; | ||||
| const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; | ||||
| const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; | ||||
| const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; | ||||
| const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; | ||||
| const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; | ||||
| const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; | ||||
|  | @ -831,6 +833,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | |||
| 		$admin_roles_updateDefaultPolicies, | ||||
| 		$admin_roles_users, | ||||
| 		$announcements, | ||||
| 		$announcements_show, | ||||
| 		$antennas_create, | ||||
| 		$antennas_delete, | ||||
| 		$antennas_list, | ||||
|  | @ -1201,6 +1204,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ | |||
| 		$admin_roles_updateDefaultPolicies, | ||||
| 		$admin_roles_users, | ||||
| 		$announcements, | ||||
| 		$announcements_show, | ||||
| 		$antennas_create, | ||||
| 		$antennas_delete, | ||||
| 		$antennas_list, | ||||
|  |  | |||
|  | @ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js' | |||
| import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; | ||||
| import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; | ||||
| import * as ep___announcements from './endpoints/announcements.js'; | ||||
| import * as ep___announcements_show from './endpoints/announcements/show.js'; | ||||
| import * as ep___antennas_create from './endpoints/antennas/create.js'; | ||||
| import * as ep___antennas_delete from './endpoints/antennas/delete.js'; | ||||
| import * as ep___antennas_list from './endpoints/antennas/list.js'; | ||||
|  | @ -453,6 +454,7 @@ const eps = [ | |||
| 	['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], | ||||
| 	['admin/roles/users', ep___admin_roles_users], | ||||
| 	['announcements', ep___announcements], | ||||
| 	['announcements/show', ep___announcements_show], | ||||
| 	['antennas/create', ep___antennas_create], | ||||
| 	['antennas/delete', ep___antennas_delete], | ||||
| 	['antennas/list', ep___antennas_list], | ||||
|  |  | |||
|  | @ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; | |||
| import { Brackets } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js'; | ||||
| import type { AnnouncementsRepository } from '@/models/_.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['meta'], | ||||
|  | @ -44,11 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
| 
 | ||||
| 		private queryService: QueryService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 		private announcementEntityService: AnnouncementEntityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) | ||||
|  | @ -60,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 
 | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
| 
 | ||||
| 			return this.announcementService.packMany(announcements, me); | ||||
| 			return this.announcementEntityService.packMany(announcements, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,54 @@ | |||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and misskey-project | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { EntityNotFoundError } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['meta'], | ||||
| 
 | ||||
| 	requireCredential: false, | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'object', | ||||
| 		optional: false, nullable: false, | ||||
| 		ref: 'Announcement', | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchAnnouncement: { | ||||
| 			message: 'No such announcement.', | ||||
| 			code: 'NO_SUCH_ANNOUNCEMENT', | ||||
| 			id: 'b57b5e1d-4f49-404a-9edb-46b00268f121', | ||||
| 		}, | ||||
| 	}, | ||||
| } 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( | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			try { | ||||
| 				return await this.announcementService.getAnnouncement(ps.announcementId, me); | ||||
| 			} catch (err) { | ||||
| 				if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
| 				throw err; | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -10,6 +10,7 @@ import { ModuleMocker } from 'jest-mock'; | |||
| import { Test } from '@nestjs/testing'; | ||||
| import { GlobalModule } from '@/GlobalModule.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; | ||||
| import type { | ||||
| 	AnnouncementReadsRepository, | ||||
| 	AnnouncementsRepository, | ||||
|  | @ -67,6 +68,7 @@ describe('AnnouncementService', () => { | |||
| 			], | ||||
| 			providers: [ | ||||
| 				AnnouncementService, | ||||
| 				AnnouncementEntityService, | ||||
| 				CacheService, | ||||
| 				IdService, | ||||
| 			], | ||||
|  |  | |||
|  | @ -0,0 +1,142 @@ | |||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<Transition | ||||
| 			:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" | ||||
| 			:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" | ||||
| 			:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" | ||||
| 			:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" | ||||
| 			mode="out-in" | ||||
| 		> | ||||
| 			<div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement"> | ||||
| 				<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div> | ||||
| 				<div :class="$style.header"> | ||||
| 					<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> | ||||
| 					<span style="margin-right: 0.5em;"> | ||||
| 						<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 						<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 						<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 						<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 					</span> | ||||
| 					<Mfm :text="announcement.title"/> | ||||
| 				</div> | ||||
| 				<div :class="$style.content"> | ||||
| 					<Mfm :text="announcement.text"/> | ||||
| 					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 					<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> | ||||
| 						{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> | ||||
| 					</div> | ||||
| 					<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> | ||||
| 						{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer"> | ||||
| 					<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<MkError v-else-if="error" @retry="fetch()"/> | ||||
| 			<MkLoading v-else/> | ||||
| 		</Transition> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { ref, computed, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
| import { $i, updateAccount } from '@/account.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	announcementId: string; | ||||
| }>(); | ||||
| 
 | ||||
| const announcement = ref<Misskey.entities.Announcement | null>(null); | ||||
| const error = ref<any>(null); | ||||
| const path = computed(() => props.announcementId); | ||||
| 
 | ||||
| function fetch() { | ||||
| 	announcement.value = null; | ||||
| 	misskeyApi('announcements/show', { | ||||
| 		announcementId: props.announcementId, | ||||
| 	}).then(async _announcement => { | ||||
| 		announcement.value = _announcement; | ||||
| 	}).catch(err => { | ||||
| 		error.value = err; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| async function read(target: Misskey.entities.Announcement): Promise<void> { | ||||
| 	if (target.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.tsx._announcement.readConfirmText({ title: target.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
| 
 | ||||
| 	target.isRead = true; | ||||
| 	await misskeyApi('i/read-announcement', { announcementId: target.id }); | ||||
| 	if ($i) { | ||||
| 		updateAccount({ | ||||
| 			unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| watch(() => path.value, fetch, { immediate: true }); | ||||
| 
 | ||||
| const headerActions = computed(() => []); | ||||
| 
 | ||||
| const headerTabs = computed(() => []); | ||||
| 
 | ||||
| definePageMetadata(() => ({ | ||||
| 	title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements, | ||||
| 	icon: 'ti ti-speakerphone', | ||||
| })); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
| .announcement { | ||||
| 	padding: 16px; | ||||
| } | ||||
| 
 | ||||
| .forYou { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	line-height: 24px; | ||||
| 	font-size: 90%; | ||||
| 	white-space: pre; | ||||
| 	color: #d28a3f; | ||||
| } | ||||
| 
 | ||||
| .header { | ||||
| 	margin-bottom: 16px; | ||||
| 	font-weight: bold; | ||||
| 	font-size: 120%; | ||||
| } | ||||
| 
 | ||||
| .content { | ||||
| 	> img { | ||||
| 		display: block; | ||||
| 		max-height: 300px; | ||||
| 		max-width: 100%; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .footer { | ||||
| 	margin-top: 16px; | ||||
| } | ||||
| </style> | ||||
|  | @ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 								<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 								<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 							</span> | ||||
| 							<span>{{ announcement.title }}</span> | ||||
| 							<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA> | ||||
| 						</div> | ||||
| 						<div :class="$style.content"> | ||||
| 							<Mfm :text="announcement.text"/> | ||||
| 							<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 							<div style="opacity: 0.7; font-size: 85%;"> | ||||
| 								<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> | ||||
| 							</div> | ||||
| 							<MkA :to="`/announcements/${announcement.id}`"> | ||||
| 								<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> | ||||
| 									{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> | ||||
| 								</div> | ||||
| 								<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> | ||||
| 									{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> | ||||
| 								</div> | ||||
| 							</MkA> | ||||
| 						</div> | ||||
| 						<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> | ||||
| 							<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
|  | @ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>(); | |||
| 
 | ||||
| const tab = ref('current'); | ||||
| 
 | ||||
| async function read(announcement) { | ||||
| 	if (announcement.needConfirmationToRead) { | ||||
| async function read(target) { | ||||
| 	if (target.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }), | ||||
| 			text: i18n.tsx._announcement.readConfirmText({ title: target.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
| 
 | ||||
| 	if (!paginationEl.value) return; | ||||
| 	paginationEl.value.updateItem(announcement.id, a => { | ||||
| 	paginationEl.value.updateItem(target.id, a => { | ||||
| 		a.isRead = true; | ||||
| 		return a; | ||||
| 	}); | ||||
| 	misskeyApi('i/read-announcement', { announcementId: announcement.id }); | ||||
| 	misskeyApi('i/read-announcement', { announcementId: target.id }); | ||||
| 	updateAccount({ | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -193,6 +193,9 @@ const routes: RouteDef[] = [{ | |||
| }, { | ||||
| 	path: '/announcements', | ||||
| 	component: page(() => import('@/pages/announcements.vue')), | ||||
| }, { | ||||
| 	path: '/announcements/:announcementId', | ||||
| 	component: page(() => import('@/pages/announcement.vue')), | ||||
| }, { | ||||
| 	path: '/about', | ||||
| 	component: page(() => import('@/pages/about.vue')), | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')" | ||||
| 		:key="announcement.id" | ||||
| 		:class="$style.item" | ||||
| 		to="/announcements" | ||||
| 		:to="`/announcements/${announcement.id}`" | ||||
| 	> | ||||
| 		<span :class="$style.icon"> | ||||
| 			<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
|  |  | |||
|  | @ -336,6 +336,12 @@ type AnnouncementsRequest = operations['announcements']['requestBody']['content' | |||
| // @public (undocumented) | ||||
| type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json']; | ||||
| 
 | ||||
| // @public (undocumented) | ||||
| type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json']; | ||||
| 
 | ||||
| // @public (undocumented) | ||||
| type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json']; | ||||
| 
 | ||||
| // @public (undocumented) | ||||
| type Antenna = components['schemas']['Antenna']; | ||||
| 
 | ||||
|  | @ -1224,6 +1230,8 @@ declare namespace entities { | |||
|         AdminRolesUsersResponse, | ||||
|         AnnouncementsRequest, | ||||
|         AnnouncementsResponse, | ||||
|         AnnouncementsShowRequest, | ||||
|         AnnouncementsShowResponse, | ||||
|         AntennasCreateRequest, | ||||
|         AntennasCreateResponse, | ||||
|         AntennasDeleteRequest, | ||||
|  |  | |||
|  | @ -851,6 +851,17 @@ declare module '../api.js' { | |||
|       credential?: string | null, | ||||
|     ): Promise<SwitchCaseResponseType<E, P>>; | ||||
| 
 | ||||
|     /** | ||||
|      * No description provided. | ||||
|      *  | ||||
|      * **Credential required**: *No* | ||||
|      */ | ||||
|     request<E extends 'announcements/show', P extends Endpoints[E]['req']>( | ||||
|       endpoint: E, | ||||
|       params: P, | ||||
|       credential?: string | null, | ||||
|     ): Promise<SwitchCaseResponseType<E, P>>; | ||||
| 
 | ||||
|     /** | ||||
|      * No description provided. | ||||
|      *  | ||||
|  |  | |||
|  | @ -101,6 +101,8 @@ import type { | |||
| 	AdminRolesUsersResponse, | ||||
| 	AnnouncementsRequest, | ||||
| 	AnnouncementsResponse, | ||||
| 	AnnouncementsShowRequest, | ||||
| 	AnnouncementsShowResponse, | ||||
| 	AntennasCreateRequest, | ||||
| 	AntennasCreateResponse, | ||||
| 	AntennasDeleteRequest, | ||||
|  | @ -631,6 +633,7 @@ export type Endpoints = { | |||
| 	'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse }; | ||||
| 	'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse }; | ||||
| 	'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse }; | ||||
| 	'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse }; | ||||
| 	'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse }; | ||||
| 	'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse }; | ||||
| 	'antennas/list': { req: EmptyRequest; res: AntennasListResponse }; | ||||
|  |  | |||
|  | @ -104,6 +104,8 @@ export type AdminRolesUsersRequest = operations['admin___roles___users']['reques | |||
| export type AdminRolesUsersResponse = operations['admin___roles___users']['responses']['200']['content']['application/json']; | ||||
| export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json']; | ||||
| export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json']; | ||||
| export type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json']; | ||||
| export type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json']; | ||||
| export type AntennasCreateRequest = operations['antennas___create']['requestBody']['content']['application/json']; | ||||
| export type AntennasCreateResponse = operations['antennas___create']['responses']['200']['content']['application/json']; | ||||
| export type AntennasDeleteRequest = operations['antennas___delete']['requestBody']['content']['application/json']; | ||||
|  |  | |||
|  | @ -706,6 +706,15 @@ export type paths = { | |||
|      */ | ||||
|     post: operations['announcements']; | ||||
|   }; | ||||
|   '/announcements/show': { | ||||
|     /** | ||||
|      * announcements/show | ||||
|      * @description No description provided. | ||||
|      * | ||||
|      * **Credential required**: *No* | ||||
|      */ | ||||
|     post: operations['announcements___show']; | ||||
|   }; | ||||
|   '/antennas/create': { | ||||
|     /** | ||||
|      * antennas/create | ||||
|  | @ -9662,6 +9671,60 @@ export type operations = { | |||
|       }; | ||||
|     }; | ||||
|   }; | ||||
|   /** | ||||
|    * announcements/show | ||||
|    * @description No description provided. | ||||
|    * | ||||
|    * **Credential required**: *No* | ||||
|    */ | ||||
|   announcements___show: { | ||||
|     requestBody: { | ||||
|       content: { | ||||
|         'application/json': { | ||||
|           /** Format: misskey:id */ | ||||
|           announcementId: string; | ||||
|         }; | ||||
|       }; | ||||
|     }; | ||||
|     responses: { | ||||
|       /** @description OK (with results) */ | ||||
|       200: { | ||||
|         content: { | ||||
|           'application/json': components['schemas']['Announcement']; | ||||
|         }; | ||||
|       }; | ||||
|       /** @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']; | ||||
|         }; | ||||
|       }; | ||||
|     }; | ||||
|   }; | ||||
|   /** | ||||
|    * antennas/create | ||||
|    * @description No description provided. | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue