feat: 個人宛てお知らせ機能 (#107)
* feat: 個人宛てお知らせ機能 * Remove unused import * Update packages/backend/src/server/api/endpoints/admin/announcements/create.ts Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Update packages/frontend/src/pages/announcements.vue Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Restore breakline * 一般向けAPIにはuserオブジェクトを提供しない * fix * Fix * Update packages/misskey-js/src/entities.ts Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Fix * Update misskey-js.api.md * Fix lint * 他のテーブルに合わせて character varying(32) にした * count クエリを1つにまとめた * user を pack するようにした * いろいろ修正 * 個人宛てのお知らせの表示を改善 * Update misskey-js.api.md * Merge migration scripts * Fix * Update packages/backend/migration/1688647797135-userannouncement.js Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Update packages/backend/src/models/entities/Announcement.ts Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com> * Fix * Update migration script --------- Co-authored-by: CyberRex <hspwinx86@gmail.com> Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										3
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1074,6 +1074,9 @@ export interface Locale { | ||||
|     "additionalEmojiDictionary": string; | ||||
|     "installed": string; | ||||
|     "branding": string; | ||||
|     "newUserAnnouncementAvailable": string; | ||||
|     "viewAnnouncement": string; | ||||
|     "dialogCloseDuration": string; | ||||
|     "enableServerMachineStats": string; | ||||
|     "enableIdenticonGeneration": string; | ||||
|     "turnOffToImprovePerformance": string; | ||||
|   | ||||
| @@ -1071,6 +1071,9 @@ goToMisskey: "Misskeyへ" | ||||
| additionalEmojiDictionary: "絵文字の追加辞書" | ||||
| installed: "インストール済み" | ||||
| branding: "ブランディング" | ||||
| newUserAnnouncementAvailable: "新着のあなた宛てのお知らせがあります" | ||||
| viewAnnouncement: "お知らせを見る" | ||||
| dialogCloseDuration: "ダイアログを閉じるまでの待機時間" | ||||
| enableServerMachineStats: "サーバーのマシン情報を公開する" | ||||
| enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" | ||||
| turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" | ||||
|   | ||||
							
								
								
									
										15
									
								
								packages/backend/migration/1688647797135-userannouncement.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/migration/1688647797135-userannouncement.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| 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"`); | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In, Not } from 'typeorm'; | ||||
| import { In, IsNull, Not } from 'typeorm'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import _Ajv from 'ajv'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| @@ -218,9 +218,11 @@ export class UserEntityService implements OnModuleInit { | ||||
| 			userId: userId, | ||||
| 		}); | ||||
|  | ||||
| 		const count = await this.announcementsRepository.countBy(reads.length > 0 ? { | ||||
| 			id: Not(In(reads.map(read => read.announcementId))), | ||||
| 		} : {}); | ||||
| 		const id = reads.length > 0 ? Not(In(reads.map(read => read.announcementId))) : undefined; | ||||
| 		const count = await this.announcementsRepository.countBy([ | ||||
| 			{ id, userId: IsNull() }, | ||||
| 			{ id, userId: userId }, | ||||
| 		]); | ||||
|  | ||||
| 		return count > 0; | ||||
| 	} | ||||
|   | ||||
| @@ -33,6 +33,19 @@ export class Announcement { | ||||
| 	}) | ||||
| 	public imageUrl: string | null; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('varchar', { | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 	}) | ||||
| 	public userId: string | null; | ||||
|  | ||||
| 	@Column('integer', { | ||||
| 		nullable: false, | ||||
| 		default: 0, | ||||
| 	}) | ||||
| 	public closeDuration: number; | ||||
|  | ||||
| 	constructor(data: Partial<Announcement>) { | ||||
| 		if (data == null) return; | ||||
|  | ||||
|   | ||||
| @@ -42,6 +42,14 @@ export const meta = { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 			userId: { | ||||
| 				type: 'string', | ||||
| 				optional: false, nullable: true, | ||||
| 			}, | ||||
| 			closeDuration: { | ||||
| 				type: 'number', | ||||
| 				optional: false, nullable: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -52,6 +60,8 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 1 }, | ||||
| 		userId: { type: 'string', nullable: true, format: 'misskey:id' }, | ||||
| 		closeDuration: { type: 'number', nullable: false }, | ||||
| 	}, | ||||
| 	required: ['title', 'text', 'imageUrl'], | ||||
| } as const; | ||||
| @@ -73,6 +83,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				title: ps.title, | ||||
| 				text: ps.text, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 				userId: ps.userId ?? null, | ||||
| 				closeDuration: ps.closeDuration, | ||||
| 			}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 			return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import type { AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { Announcement } from '@/models/entities/Announcement.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -46,10 +48,23 @@ export const meta = { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 				}, | ||||
| 				userId: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 				}, | ||||
| 				user: { | ||||
| 					type: 'object', | ||||
| 					optional: true, nullable: false, | ||||
| 					ref: 'UserLite', | ||||
| 				}, | ||||
| 				reads: { | ||||
| 					type: 'number', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 				closeDuration: { | ||||
| 					type: 'number', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| @@ -61,6 +76,7 @@ export const paramDef = { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		userId: { type: 'string', format: 'misskey:id' }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -75,10 +91,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		private queryService: QueryService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			const builder = this.announcementsRepository.createQueryBuilder('announcement'); | ||||
| 			if (ps.userId) { | ||||
| 				builder.where('"userId" = :userId', { userId: ps.userId }); | ||||
| 			} else { | ||||
| 				builder.where('"userId" IS NULL'); | ||||
| 			} | ||||
|  | ||||
| 			const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); | ||||
|  | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| @@ -90,6 +117,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				})); | ||||
| 			} | ||||
|  | ||||
| 			const users = await this.usersRepository.findBy({ | ||||
| 				id: In(announcements.map(a => a.userId).filter(id => id != null)), | ||||
| 			}); | ||||
| 			const packedUsers = await this.userEntityService.packMany(users, me, { | ||||
| 				detail: false, | ||||
| 			}); | ||||
|  | ||||
| 			return announcements.map(announcement => ({ | ||||
| 				id: announcement.id, | ||||
| 				createdAt: announcement.createdAt.toISOString(), | ||||
| @@ -97,7 +131,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				title: announcement.title, | ||||
| 				text: announcement.text, | ||||
| 				imageUrl: announcement.imageUrl, | ||||
| 				userId: announcement.userId, | ||||
| 				user: packedUsers.find(user => user.id === announcement.userId), | ||||
| 				reads: reads.get(announcement)!, | ||||
| 				closeDuration: announcement.closeDuration, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| @@ -26,8 +26,10 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 0 }, | ||||
| 		userId: { type: 'string', nullable: true, format: 'misskey:id' }, | ||||
| 		closeDuration: { type: 'number', nullable: false }, | ||||
| 	}, | ||||
| 	required: ['id', 'title', 'text', 'imageUrl'], | ||||
| 	required: ['id', 'title', 'text', 'imageUrl', 'closeDuration'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @@ -36,18 +38,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementsReadsRepository: AnnouncementReadsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 			if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
|  | ||||
| 			if (announcement.userId && announcement.userId !== ps.userId) { | ||||
| 				await this.announcementsReadsRepository.delete({ id: announcement.id, userId: announcement.userId }); | ||||
| 			} | ||||
|  | ||||
| 			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, | ||||
| 				userId: ps.userId ?? null, | ||||
| 				closeDuration: ps.closeDuration, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -48,6 +48,14 @@ export const meta = { | ||||
| 					type: 'boolean', | ||||
| 					optional: true, nullable: false, | ||||
| 				}, | ||||
| 				isPrivate: { | ||||
| 					type: 'boolean', | ||||
| 					optional: false, nullable: true, | ||||
| 				}, | ||||
| 				closeDuration: { | ||||
| 					type: 'number', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| @@ -60,6 +68,7 @@ export const paramDef = { | ||||
| 		withUnreads: { type: 'boolean', default: false }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		privateOnly: { type: 'boolean', default: false }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -77,8 +86,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			const builder = this.announcementsRepository.createQueryBuilder('announcement'); | ||||
| 			if (me) { | ||||
| 				if (ps.privateOnly) { | ||||
| 					builder.where('"userId" = :userId', { userId: me.id }); | ||||
| 				} else { | ||||
| 					builder.where('"userId" IS NULL'); | ||||
| 					builder.orWhere('"userId" = :userId', { userId: me.id }); | ||||
| 				} | ||||
| 			} else { | ||||
| 				builder.where('"userId" IS NULL'); | ||||
| 			} | ||||
|  | ||||
| 			const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| 			if (me) { | ||||
| @@ -95,6 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				...a, | ||||
| 				createdAt: a.createdAt.toISOString(), | ||||
| 				updatedAt: a.updatedAt?.toISOString() ?? null, | ||||
| 				isPrivate: !!a.userId, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
| @@ -47,7 +48,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			// Check if announcement exists | ||||
| 			const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } }); | ||||
| 			const announcementExist = await this.announcementsRepository.exist({ | ||||
| 				where: [ | ||||
| 					{ | ||||
| 						id: ps.announcementId, | ||||
| 						userId: IsNull(), | ||||
| 					}, | ||||
| 					{ | ||||
| 						id: ps.announcementId, | ||||
| 						userId: me.id, | ||||
| 					} | ||||
| 				] | ||||
| 			}); | ||||
|  | ||||
| 			if (!announcementExist) { | ||||
| 				throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { computed, createApp, watch, markRaw, version as vueVersion, defineAsync | ||||
| import { common } from './common'; | ||||
| import { version, ui, lang, updateLocale } from '@/config'; | ||||
| import { i18n, updateI18n } from '@/i18n'; | ||||
| import { confirm, alert, post, popup, toast } from '@/os'; | ||||
| import { confirm, alert, post, popup, toast, api } from '@/os'; | ||||
| import { useStream } from '@/stream'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; | ||||
| @@ -246,6 +246,11 @@ export async function mainBoot() { | ||||
| 		main.on('myTokenRegenerated', () => { | ||||
| 			signout(); | ||||
| 		}); | ||||
|  | ||||
| 		const unreadUserAnnouncementsList = await api('announcements', { privateOnly: true, withUnreads: true }); | ||||
| 		if (unreadUserAnnouncementsList.length > 0) { | ||||
| 			unreadUserAnnouncementsList.forEach((v) => popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementModal.vue')), { title: v.title, text: v.text, closeDuration: v.closeDuration, announcementId: v.id }, {}, 'closed')); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// shortcut | ||||
|   | ||||
| @@ -0,0 +1,60 @@ | ||||
| <template> | ||||
| <MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> | ||||
| 	<div :class="$style.root"> | ||||
| 		<div :class="$style.title">{{ i18n.ts.newUserAnnouncementAvailable }}</div> | ||||
| 		<MkButton :class="$style.gotIt" primary full :disabled="gotItDisabled" @click="gotIt">{{ i18n.ts.gotIt }}</MkButton> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { shallowRef } from 'vue'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { useRouter } from '@/router'; | ||||
| import { api } from '@/os'; | ||||
| const router = useRouter(); | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	title: string; | ||||
| 	text: string; | ||||
| 	announcementId: string; | ||||
| }>(); | ||||
|  | ||||
| async function gotIt() { | ||||
| 	await api('i/read-announcement', { announcementId: props.announcementId }); | ||||
| } | ||||
|   | ||||
| function jumpTo() { | ||||
| 	modal.value.close(); | ||||
| 	router.push('/announcements'); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	margin: auto; | ||||
| 	position: relative; | ||||
| 	padding: 32px; | ||||
| 	min-width: 320px; | ||||
| 	max-width: 480px; | ||||
| 	box-sizing: border-box; | ||||
| 	text-align: center; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| .version { | ||||
| 	margin: 1em 0; | ||||
| } | ||||
|  | ||||
| .gotIt { | ||||
| 	margin: 8px 0 0 0; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										90
									
								
								packages/frontend/src/components/MkUserAnnouncementModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								packages/frontend/src/components/MkUserAnnouncementModal.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| <template> | ||||
| <MkModal ref="modal" :zPriority="'middle'" @click="closeModal" @closed="$emit('closed')"> | ||||
| 	<div :class="$style.root"> | ||||
| 		<div :class="$style.title"><Mfm :text="props.title"/></div> | ||||
| 		<div :class="$style.text"> | ||||
| 			<Mfm :text="props.text"/> | ||||
| 		</div> | ||||
| 		<MkButton :class="$style.gotIt" primary full :disabled="gotItDisabled" @click="gotIt">{{ i18n.ts.gotIt }}<span v-if="secVisible">({{ sec }})</span></MkButton> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { onMounted, ref, shallowRef } from 'vue'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { api } from '@/os'; | ||||
|  | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
| const gotItDisabled = ref(true); | ||||
| const secVisible = ref(true); | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	title: string; | ||||
| 	text: string; | ||||
| 	announcementId: string | null; | ||||
| 	closeDuration: number; | ||||
| }>(); | ||||
|  | ||||
| const sec = ref(props.closeDuration); | ||||
|  | ||||
| async function gotIt() { | ||||
| 	gotItDisabled.value = true; | ||||
| 	if (props.announcementId) { | ||||
| 		await api('i/read-announcement', { announcementId: props.announcementId }); | ||||
| 	} | ||||
| 	modal.value.close(); | ||||
| } | ||||
|  | ||||
| function closeModal() { | ||||
| 	if (sec.value === 0) { | ||||
| 		modal.value.close(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	if (sec.value > 0 ) { | ||||
| 		const waitTimer = setInterval(() => { | ||||
| 			if (sec.value === 0) { | ||||
| 				clearInterval(waitTimer); | ||||
| 				gotItDisabled.value = false; | ||||
| 				secVisible.value = false; | ||||
| 			} else { | ||||
| 				gotItDisabled.value = true; | ||||
| 			} | ||||
| 			sec.value = sec.value - 1; | ||||
| 		}, 1000); | ||||
| 	} else { | ||||
| 		gotItDisabled.value = false; | ||||
| 		secVisible.value = false; | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	margin: auto; | ||||
| 	position: relative; | ||||
| 	padding: 32px; | ||||
| 	min-width: 320px; | ||||
| 	max-width: 480px; | ||||
| 	box-sizing: border-box; | ||||
| 	text-align: center; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| .text { | ||||
| 	margin: 1em 0; | ||||
| } | ||||
|  | ||||
| .gotIt { | ||||
| 	margin: 8px 0 0 0; | ||||
| } | ||||
| </style> | ||||
| @@ -3,10 +3,26 @@ | ||||
| 	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div class="_gaps_m"> | ||||
| 			<MkFolder> | ||||
| 				<template #label>{{ i18n.ts.options }}</template> | ||||
|  | ||||
| 				<MkFolder> | ||||
| 					<template #label>{{ i18n.ts.specifyUser }}</template> | ||||
| 					<template v-if="user" #suffix>@{{ user.username }}</template> | ||||
|  | ||||
| 					<div style="text-align: center;" class="_gaps"> | ||||
| 						<div v-if="user">@{{ user.username }}</div> | ||||
| 						<div> | ||||
| 							<MkButton v-if="user == null" primary rounded inline @click="selectUserFilter">{{ i18n.ts.selectUser }}</MkButton> | ||||
| 							<MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
| 			</MkFolder> | ||||
| 			<section v-for="announcement in announcements" class=""> | ||||
| 				<div class="_panel _gaps_m" style="padding: 24px;"> | ||||
| 					<MkInput v-model="announcement.title"> | ||||
| 						<template #label>{{ i18n.ts.title }}</template> | ||||
| 					<MkInput ref="announceTitleEl" v-model="announcement.title" :large="false"> | ||||
| 						<template #label>{{ i18n.ts.title }} <button v-tooltip="i18n.ts.emoji" :class="['_button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button></template> | ||||
| 					</MkInput> | ||||
| 					<MkTextarea v-model="announcement.text"> | ||||
| 						<template #label>{{ i18n.ts.text }}</template> | ||||
| @@ -14,7 +30,13 @@ | ||||
| 					<MkInput v-model="announcement.imageUrl"> | ||||
| 						<template #label>{{ i18n.ts.imageUrl }}</template> | ||||
| 					</MkInput> | ||||
| 					<MkInput v-model="announcement.closeDuration" type="number"> | ||||
| 						<template #label>{{ i18n.ts.dialogCloseDuration }}</template> | ||||
| 						<template #suffix>{{ i18n.ts._time.second }}</template> | ||||
| 					</MkInput> | ||||
| 					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> | ||||
| 					<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini> | ||||
| 					<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton> | ||||
| 					<div class="buttons _buttons"> | ||||
| 						<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> | ||||
| 						<MkButton class="button" inline danger @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> | ||||
| @@ -27,17 +49,40 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import { ref, watch } from 'vue'; | ||||
| import { UserLite } from 'misskey-js/built/entities'; | ||||
| import XHeader from './_header_.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import MkUserCardMini from '@/components/MkUserCardMini.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
|  | ||||
| let announcements: any[] = $ref([]); | ||||
|  | ||||
| const user = ref<UserLite>(null); | ||||
| const announceTitleEl = $shallowRef<HTMLInputElement | null>(null); | ||||
|  | ||||
| function selectUserFilter() { | ||||
| 	os.selectUser().then(_user => { | ||||
| 		user.value = _user; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function editUser(an) { | ||||
| 	os.selectUser().then(_user => { | ||||
| 		an.userId = _user.id; | ||||
| 		an.user = _user; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function insertEmoji(ev: MouseEvent) { | ||||
| 	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl); | ||||
| } | ||||
|  | ||||
| os.api('admin/announcements/list').then(announcementResponse => { | ||||
| 	announcements = announcementResponse; | ||||
| }); | ||||
| @@ -48,6 +93,9 @@ function add() { | ||||
| 		title: '', | ||||
| 		text: '', | ||||
| 		imageUrl: null, | ||||
| 		userId: null, | ||||
| 		user: null, | ||||
| 		closeDuration: 10, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| @@ -92,11 +140,13 @@ function save(announcement) { | ||||
| } | ||||
|  | ||||
| function refresh() { | ||||
| 	os.api('admin/announcements/list').then(announcementResponse => { | ||||
| 	os.api('admin/announcements/list', { userId: user.value?.id }).then(announcementResponse => { | ||||
| 		announcements = announcementResponse; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| watch(user, refresh); | ||||
|  | ||||
| refresh(); | ||||
|  | ||||
| const headerActions = $computed(() => [{ | ||||
|   | ||||
| @@ -3,8 +3,8 @@ | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m"> | ||||
| 			<section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel"> | ||||
| 				<div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> | ||||
| 			<section v-for="(announcement, i) in items" :key="announcement.id" :class="{ announcement: true, _panel: true, private: announcement.isPrivate }"> | ||||
| 				<div class="header"><span v-if="$i && !announcement.isRead"><span class="ti ti-speakerphone"></span></span><Mfm :text="announcement.title"/></div> | ||||
| 				<div class="content"> | ||||
| 					<Mfm :text="announcement.text"/> | ||||
| 					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| @@ -53,6 +53,11 @@ definePageMetadata({ | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .ruryvtyk { | ||||
|  | ||||
| 	> .private { | ||||
| 		border-left: 4px solid olivedrab; | ||||
| 	} | ||||
|  | ||||
| 	> .announcement { | ||||
| 		padding: 16px; | ||||
|  | ||||
| @@ -74,4 +79,16 @@ definePageMetadata({ | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @keyframes fade { | ||||
| 	0% { | ||||
| 		opacity: 0; | ||||
| 	} | ||||
| 	50% { | ||||
| 		opacity: 1; | ||||
| 	} | ||||
| 	100% { | ||||
| 		opacity: 0; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -26,6 +26,8 @@ type Announcement = { | ||||
|     title: string; | ||||
|     imageUrl: string | null; | ||||
|     isRead?: boolean; | ||||
|     isPrivate: boolean; | ||||
|     closeDuration: number; | ||||
| }; | ||||
|  | ||||
| // @public (undocumented) | ||||
| @@ -544,6 +546,7 @@ export type Endpoints = { | ||||
|             withUnreads?: boolean; | ||||
|             sinceId?: Announcement['id']; | ||||
|             untilId?: Announcement['id']; | ||||
|             privateOnly?: boolean; | ||||
|         }; | ||||
|         res: Announcement[]; | ||||
|     }; | ||||
|   | ||||
| @@ -73,7 +73,7 @@ export type Endpoints = { | ||||
| 	'admin/relays/remove': { req: TODO; res: TODO; }; | ||||
|  | ||||
| 	// announcements | ||||
| 	'announcements': { req: { limit?: number; withUnreads?: boolean; sinceId?: Announcement['id']; untilId?: Announcement['id']; }; res: Announcement[]; }; | ||||
| 	'announcements': { req: { limit?: number; withUnreads?: boolean; sinceId?: Announcement['id']; untilId?: Announcement['id']; privateOnly?: boolean; }; res: Announcement[]; }; | ||||
|  | ||||
| 	// antennas | ||||
| 	'antennas/create': { req: TODO; res: Antenna; }; | ||||
|   | ||||
| @@ -409,6 +409,8 @@ export type Announcement = { | ||||
| 	title: string; | ||||
| 	imageUrl: string | null; | ||||
| 	isRead?: boolean; | ||||
| 	isPrivate: boolean; | ||||
| 	closeDuration: number;  | ||||
| }; | ||||
|  | ||||
| export type Antenna = { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 riku6460
					riku6460