diff --git a/locales/en-US.yml b/locales/en-US.yml index 28652cdbe..3ec46cc61 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1319,6 +1319,12 @@ emailAddressLogin: "Login with email address" usernameLogin: "Login with username" autoloadDrafts: "Automatically load drafts when opening the posting form" drafts: "Drafts" +unsent: "Unsent" +schedule: "Schedule" +scheduled: "Scheduled" +unschedule: "Unschedule" +setScheduledTime: "Set scheduled time" +willBePostedAt: "Note will be posted at {x}" _bubbleGame: howToPlay: "How to play" @@ -1787,6 +1793,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canScheduleNote: "Can schedule notes" canInitiateConversation: "Can mention, reply or quote" canCreateContent: "Can create contents" canUpdateContent: "Can edit contents" @@ -2474,6 +2481,9 @@ _notification: roleAssigned: "Role given" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" + noteScheduled: "Note has been scheduled" + scheduledNotePosted: "Scheduled note has been posted" + scheduledNoteError: "Scheduled note has problem with posting" testNotification: "Test notification" checkNotificationBehavior: "Check notification appearance" sendTestNotification: "Send test notification" @@ -2497,6 +2507,9 @@ _notification: followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" achievementEarned: "Achievement unlocked" + noteScheduled: "Note scheduled" + scheduledNotePosted: "Scheduled note posted" + scheduledNoteError: "Problem with scheduled note" app: "Notifications from linked apps" _actions: followBack: "followed you back" diff --git a/locales/index.d.ts b/locales/index.d.ts index 0143fadee..707e058e8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5338,6 +5338,30 @@ export interface Locale extends ILocale { * 下書き */ "drafts": string; + /** + * 未送信 + */ + "unsent": string; + /** + * 予約 + */ + "schedule": string; + /** + * 予約済み + */ + "scheduled": string; + /** + * 予約を解除 + */ + "unschedule": string; + /** + * 予約日時を設定 + */ + "setScheduledTime": string; + /** + * {x}に投稿されます + */ + "willBePostedAt": ParameterizedString<"x">; "_bubbleGame": { /** * 遊び方 @@ -6991,6 +7015,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * 予約投稿の許可 + */ + "canScheduleNote": string; /** * メンション、リプライ、引用の許可 */ @@ -9649,6 +9677,18 @@ export interface Locale extends ILocale { * 実績を獲得 */ "achievementEarned": string; + /** + * ノートが予約されました + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿されました + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートを投稿できませんでした + */ + "scheduledNoteError": string; /** * 通知テスト */ @@ -9738,6 +9778,18 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * ノートが予約された + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿された + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートが投稿できなかった + */ + "scheduledNoteError": string; /** * 連携アプリからの通知 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index df14061cd..5612736be 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1328,6 +1328,12 @@ emailAddressLogin: "メールアドレスでログイン" usernameLogin: "ユーザー名でログイン" autoloadDrafts: "投稿フォームを開いたときに下書きを自動で読み込む" drafts: "下書き" +unsent: "未送信" +schedule: "予約" +scheduled: "予約済み" +unschedule: "予約を解除" +setScheduledTime: "予約日時を設定" +willBePostedAt: "{x}に投稿されます" _bubbleGame: howToPlay: "遊び方" @@ -1801,6 +1807,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canScheduleNote: "予約投稿の許可" canInitiateConversation: "メンション、リプライ、引用の許可" canCreateContent: "コンテンツの作成" canUpdateContent: "コンテンツの編集" @@ -2538,6 +2545,9 @@ _notification: roleAssigned: "ロールが付与されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" + noteScheduled: "ノートが予約されました" + scheduledNotePosted: "予約済みのノートが投稿されました" + scheduledNoteError: "予約済みのノートを投稿できませんでした" testNotification: "通知テスト" checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" @@ -2562,6 +2572,9 @@ _notification: followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + noteScheduled: "ノートが予約された" + scheduledNotePosted: "予約済みのノートが投稿された" + scheduledNoteError: "予約済みのノートが投稿できなかった" app: "連携アプリからの通知" _actions: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 929e6ab6e..d90bd9adf 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1316,6 +1316,12 @@ emailAddressLogin: "이메일 주소로 로그인" usernameLogin: "사용자명으로 로그인" autoloadDrafts: "글 작성 시 자동으로 임시 저장된 글 불러오기" drafts: "임시 저장" +unsent: "미전송" +schedule: "예약" +scheduled: "예약됨" +unschedule: "예약 취소" +setScheduledTime: "예약 시간 설정" +willBePostedAt: "{x}에 게시됩니다" _bubbleGame: howToPlay: "설명" @@ -1784,6 +1790,7 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" + canScheduleNote: "노트 예약 허용" mentionMax: "노트에 넣을 수 있는 멘션 수" canCreateContent: "컨텐츠 생성 허용" canUpdateContent: "컨텐츠 수정 허용" @@ -2456,9 +2463,12 @@ _notification: pollEnded: "투표 결과가 발표되었습니다" newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" - roleAssigned: "역할이 부여 되었습니다." + roleAssigned: "역할이 부여 되었습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" + noteScheduled: "노트가 예약되었습니다" + scheduledNotePosted: "예약된 노트가 게시되었습니다" + scheduledNoteError: "예약된 노트를 게시하지 못했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" @@ -2482,6 +2492,9 @@ _notification: followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" achievementEarned: "도전 과제 획득" + noteScheduled: "노트가 예약됨" + scheduledNotePosted: "예약된 노트가 게시됨" + scheduledNoteError: "예약된 노트를 게시하지 못함" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" diff --git a/packages/backend/assets/tabler-badges/badges.png b/packages/backend/assets/tabler-badges/badges.png new file mode 100644 index 000000000..f02880a71 Binary files /dev/null and b/packages/backend/assets/tabler-badges/badges.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-check.png b/packages/backend/assets/tabler-badges/calendar-check.png new file mode 100644 index 000000000..1f1e1951a Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-check.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-exclamation.png b/packages/backend/assets/tabler-badges/calendar-exclamation.png new file mode 100644 index 000000000..7944fac88 Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-exclamation.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-time.png b/packages/backend/assets/tabler-badges/calendar-time.png new file mode 100644 index 000000000..0443baee2 Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-time.png differ diff --git a/packages/backend/migration/1736923279563-ScheduledNote.js b/packages/backend/migration/1736923279563-ScheduledNote.js new file mode 100644 index 000000000..9ed8f2ad1 --- /dev/null +++ b/packages/backend/migration/1736923279563-ScheduledNote.js @@ -0,0 +1,21 @@ +export class ScheduledNote1736923279563 { + name = 'ScheduledNote1736923279563' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_scheduled" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "scheduledAt" TIMESTAMP WITH TIME ZONE, "reason" character varying(256), "userId" character varying(32) NOT NULL, "draft" jsonb NOT NULL, CONSTRAINT "PK_14ca8fa67f70dc68ebab8900f4b" PRIMARY KEY ("id")); COMMENT ON COLUMN "note_scheduled"."createdAt" IS 'The created date of the Note.'; COMMENT ON COLUMN "note_scheduled"."scheduledAt" IS 'The scheduled date of the Note.'; COMMENT ON COLUMN "note_scheduled"."userId" IS 'The ID of author.'`); + await queryRunner.query(`CREATE INDEX "IDX_7ddf8710a9faee81081592ec35" ON "note_scheduled" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_bbe52891059217fc31e73e84e2" ON "note_scheduled" ("scheduledAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b148b24837cc7a2707ae1f0975" ON "note_scheduled" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_dfeab22d6bbc4799193997553a" ON "note_scheduled" ("userId", "scheduledAt") `); + await queryRunner.query(`ALTER TABLE "note_scheduled" ADD CONSTRAINT "FK_b148b24837cc7a2707ae1f0975a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_scheduled" DROP CONSTRAINT "FK_b148b24837cc7a2707ae1f0975a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_dfeab22d6bbc4799193997553a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b148b24837cc7a2707ae1f0975"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bbe52891059217fc31e73e84e2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7ddf8710a9faee81081592ec35"`); + await queryRunner.query(`DROP TABLE "note_scheduled"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index ba89f6022..7fd89f58d 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -104,6 +104,7 @@ import { UserAccountMoveLogEntityService } from './entities/UserAccountMoveLogEn import { MutingEntityService } from './entities/MutingEntityService.js'; import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; +import { ScheduledNoteEntityService } from './entities/ScheduledNoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; import { NotificationEntityService } from './entities/NotificationEntityService.js'; @@ -245,6 +246,7 @@ const $UserAccountMoveLogEntityService: Provider = { provide: 'UserAccountMoveLo const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; +const $ScheduledNoteEntityService: Provider = { provide: 'ScheduledNoteEntityService', useExisting: ScheduledNoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService }; @@ -385,6 +387,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MutingEntityService, RenoteMutingEntityService, NoteEntityService, + ScheduledNoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, NotificationEntityService, @@ -521,6 +524,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, + $ScheduledNoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, $NotificationEntityService, @@ -657,6 +661,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MutingEntityService, RenoteMutingEntityService, NoteEntityService, + ScheduledNoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, NotificationEntityService, @@ -792,6 +797,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, + $ScheduledNoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, $NotificationEntityService, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ea8b9432f..71e430c2d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -13,16 +13,28 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; +import type { + ChannelFollowingsRepository, + ChannelsRepository, + FollowingsRepository, + InstancesRepository, + MiFollowing, + NotesRepository, + NoteThreadMutingsRepository, + ScheduledNotesRepository, + UserListMembershipsRepository, + UserProfilesRepository, + UsersRepository +} from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; import { IdService } from '@/core/IdService.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; +import type { NoteCreateOption, MinimumUser } from '@/types.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -118,35 +130,6 @@ class NotificationManager { } } -type MinimumUser = { - id: MiUser['id']; - host: MiUser['host']; - username: MiUser['username']; - uri: MiUser['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: MiNote | null; - renote?: MiNote | null; - files?: MiDriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - reactionAcceptance?: MiNote['reactionAcceptance']; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: MiChannel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: MiApp | null; -}; - @Injectable() export class NoteCreateService implements OnApplicationShutdown { private logger: Logger; @@ -169,6 +152,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -229,7 +215,7 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; isCat: MiUser['isCat']; - }, data: Option, silent = false): Promise { + }, data: NoteCreateOption, silent = false): Promise { // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { @@ -425,18 +411,30 @@ export class NoteCreateService implements OnApplicationShutdown { throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', `Notes including mentions are limited to ${policies.mentionLimit} users.`); } - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (!data.scheduledAt) { + const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate('post created', { signal: this.#shutdownController.signal }).then( - () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); - return note; + return note; + } else { + if (!policies.canScheduleNote) { + throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.'); + } + + const draft = await this.insertScheduledNote(user, data); + + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + + return draft; + } } @bindThis - private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { const insert = new MiNote({ id: this.idService.gen(data.createdAt?.getTime()), createdAt: data.createdAt!, @@ -534,13 +532,40 @@ export class NoteCreateService implements OnApplicationShutdown { } } + @bindThis + private async insertScheduledNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption) { + const insert = new MiScheduledNote({ + id: this.idService.gen(data.createdAt?.getTime()), + createdAt: data.createdAt!, + scheduledAt: data.scheduledAt!, + userId: user.id, + draft: data, + }); + + // 予約投稿を作成 + try { + await this.scheduledNotesRepository.insert(insert); + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + throw new IdentifiableError('5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc', 'There is already a scheduled note with the same time.'); + } + + this.logger.error(`Failed to create scheduled note: ${e}`, { error: e }); + + throw e; + } + } + @bindThis private async postNoteCreated(note: MiNote, user: { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; isBot: MiUser['isBot']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + }, data: NoteCreateOption, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { const meta = await this.metaService.fetch(); this.notesChart.update(note, true); @@ -792,12 +817,12 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isRenote(note: Option): note is Option & { renote: MiNote } { + private isRenote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } { return note.renote != null; } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } & ( + private isQuote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } ) { // NOTE: SYNC WITH misc/is-renote.ts @@ -873,7 +898,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote) { if (data.localOnly) return null; const content = this.isRenote(data) && !this.isQuote(data) diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index a41c9c96e..fb5501c42 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; import type { Config } from '@/config.js'; @@ -34,6 +35,11 @@ export class QueueService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) { + this.ensureRepeatJobs(); + } + + @bindThis + private ensureRepeatJobs() { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, @@ -69,6 +75,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkMissingScheduledNote', { + }, { + repeat: { pattern: '*/5 * * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -382,6 +394,18 @@ export class QueueService { }); } + @bindThis + public createScheduledNoteJob(draftId: MiScheduledNote['id'], scheduledAt: Date) { + return this.systemQueue.add('scheduledNote', { + draftId, + }, { + jobId: `scheduledNote:${draftId}`, + delay: Math.max(scheduledAt.getTime() - Date.now(), 0) + Math.floor(Math.random() * 500 + 250), + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean, withReplies?: boolean }[]) { const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 1c205dca0..1b8091cc2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -36,6 +36,7 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canScheduleNote: boolean; canInitiateConversation: boolean; canCreateContent: boolean; canUpdateContent: boolean; @@ -77,6 +78,7 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canScheduleNote: true, canInitiateConversation: true, canCreateContent: true, canUpdateContent: true, @@ -389,6 +391,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)), canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 1e6ff5a5a..9effcfac9 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -352,7 +352,7 @@ export class ApNoteService { poll, uri: note.id, url: url, - }, silent); + }, silent) as MiNote; } catch (err: any) { if (err.name !== 'duplicated') { throw err; diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a580ce44d..bd8f9a1cf 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -20,14 +20,16 @@ import { RoleEntityService } from './RoleEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { ScheduledNoteEntityService } from './ScheduledNoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private scheduledNoteEntityService: ScheduledNoteEntityService; constructor( private moduleRef: ModuleRef, @@ -52,6 +54,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.scheduledNoteEntityService = this.moduleRef.get('ScheduledNoteEntityService'); } /** @@ -84,6 +87,11 @@ export class NotificationEntityService implements OnModuleInit { // if the note has been deleted, don't show this notification if (needsNote && !noteIfNeed) return null; + const needsDraft = 'draftId' in notification; + const draftIfNeed = needsDraft ? this.scheduledNoteEntityService.pack(notification.draftId, { id: meId }) : undefined; + // if the draft has been deleted, don't show this notification + if (needsDraft && !draftIfNeed) return null; + const needsUser = 'notifierId' in notification; const userIfNeed = needsUser ? ( hint?.packedUsers != null @@ -116,6 +124,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, reactions, }); } else if (notification.type === 'renote:grouped') { @@ -139,6 +148,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, users, }); } @@ -158,6 +168,7 @@ export class NotificationEntityService implements OnModuleInit { userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(draftIfNeed != null ? { draft: draftIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, } : {}), diff --git a/packages/backend/src/core/entities/ScheduledNoteEntityService.ts b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts new file mode 100644 index 000000000..07f1c2bca --- /dev/null +++ b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class ScheduledNoteEntityService { + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + } + + @bindThis + public async pack( + src: MiScheduledNote['id'] | MiScheduledNote, + me: { id: MiUser['id'] }, + ) : Promise> { + const item = typeof src === 'object' ? src : await this.scheduledNotesRepository.findOneByOrFail({ id: src, userId: me.id }); + + return { + id: item.id, + updatedAt: item.createdAt.toISOString(), + scheduledAt: item.scheduledAt?.toISOString() ?? null, + reason: item.reason ?? undefined, + channel: item.draft.channel ? { + id: item.draft.channel.id, + name: item.draft.channel.name, + } : undefined, + renote: item.draft.renote ? { + id: item.draft.renote.id, + text: (item.draft.renote.cw ?? item.draft.renote.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.renote.userId, + username: item.draft.renote.user!.username, + host: item.draft.renote.user!.host, + }, + } : undefined, + reply: item.draft.reply ? { + id: item.draft.reply.id, + text: (item.draft.reply.cw ?? item.draft.reply.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.reply.userId, + username: item.draft.reply.user!.username, + host: item.draft.reply.user!.host, + }, + } : undefined, + data: { + text: item.draft.text ?? null, + useCw: !!item.draft.cw, + cw: item.draft.cw ?? null, + visibility: item.draft.visibility as 'public' | 'followers' | 'home' | 'specified', + localOnly: item.draft.localOnly ?? false, + files: item.draft.files ? await this.driveFileEntityService.packMany(item.draft.files, me) : [], + poll: item.draft.poll ? { ...item.draft.poll, expiresAt: item.draft.poll.expiresAt?.getTime() ?? null, expiredAfter: null } : null, + visibleUserIds: item.draft.visibility === 'specified' ? item.draft.visibleUsers?.map(x => x.id) : undefined, + }, + }; + } + + @bindThis + public async packMany( + drafts: (MiScheduledNote['id'] | MiScheduledNote)[], + me: { id: MiUser['id'] }, + ) : Promise[]> { + return (await Promise.allSettled(drafts.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index a70cce451..8b7b10b92 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -21,6 +21,7 @@ export const DI = { announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), + scheduledNotesRepository: Symbol('scheduledNotesRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 5f169c37d..10b144b16 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -31,6 +31,7 @@ import { packedMutingSchema } from '@/models/json-schema/muting.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js'; import { packedNotificationSchema } from '@/models/json-schema/notification.js'; import { packedPageLikeSchema, packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; @@ -77,6 +78,7 @@ export const refs = { Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, + NoteDraft: packedNoteDraftSchema, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, Notification: packedNotificationSchema, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index df88b9963..4747b51b5 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -5,6 +5,7 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; +import { MiScheduledNote } from './ScheduledNote.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; @@ -77,6 +78,21 @@ export type MiNotification = { id: string; createdAt: string; achievement: string; +} | { + type: 'noteScheduled'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; +} | { + type: 'scheduledNotePosted'; + id: string; + createdAt: string; + noteId: MiNote['id']; +} | { + type: 'scheduledNoteError'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 83eee55ff..ced1ff812 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -39,6 +39,7 @@ import { MiModerationLog, MiMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -117,6 +118,12 @@ const $avatarDecorationsRepository: Provider = { inject: [DI.db], }; +const $scheduledNotesRepository: Provider = { + provide: DI.scheduledNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiScheduledNote), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -517,6 +524,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -590,6 +598,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts new file mode 100644 index 000000000..d573e2514 --- /dev/null +++ b/packages/backend/src/models/ScheduledNote.ts @@ -0,0 +1,54 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import type { NoteCreateOption } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('note_scheduled') +@Index(['userId', 'scheduledAt'], { unique: true }) +export class MiScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The scheduled date of the Note.', + nullable: true + }) + public scheduledAt: Date | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public reason: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('jsonb') + public draft: NoteCreateOption; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 800af98b1..7bf282a18 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -34,6 +34,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -108,6 +109,7 @@ export { MiMuting, MiRenoteMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -181,6 +183,7 @@ export type ModerationLogsRepository = Repository; export type MutingsRepository = Repository; export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; +export type ScheduledNotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts new file mode 100644 index 000000000..7b81838fe --- /dev/null +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -0,0 +1,179 @@ +export const packedNoteDraftSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time' + }, + scheduledAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + reason: { + type: 'string', + optional: true, nullable: false + }, + channel: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + name: { + type: 'string', + optional: false, nullable: false + }, + }, + }, + renote: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + reply: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + data: { + type: 'object', + optional: false, nullable: false, + properties: { + text: { + type: 'string', + optional: false, nullable: true, + }, + useCw: { + type: 'boolean', + optional: false, nullable: false, + }, + cw: { + type: 'string', + optional: false, nullable: true, + }, + visibility: { + type: 'string', + optional: false, nullable: false, + enum: ['public', 'home', 'followers', 'specified'], + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + files: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + poll: { + type: 'object', + optional: false, nullable: true, + properties: { + choices: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + multiple: { + type: 'boolean', + optional: false, nullable: false, + }, + expiresAt: { + type: 'integer', + optional: false, nullable: true, + }, + expiredAfter: { + type: 'integer', + optional: false, nullable: true, + minimum: 1 + }, + }, + }, + visibleUserIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b4c444275..e68240897 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -296,6 +296,51 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['noteScheduled'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePosted'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNoteError'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 166a085f5..6cbea1708 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canScheduleNote: { + type: 'boolean', + optional: false, nullable: false, + }, canInitiateConversation: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 58a40ae3c..b272f7d65 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -44,6 +44,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -162,6 +163,7 @@ export const entities = [ MiRenoteMuting, MiBlocking, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index f3588544f..d7f0a56e7 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; @@ -36,6 +37,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; @@ -52,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + CheckMissingScheduledNoteProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, @@ -75,6 +78,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor CleanRemoteFilesProcessorService, RelationshipProcessorService, ReportAbuseProcessorService, + ScheduledNoteProcessorService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 1e68f750a..dc1429944 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -35,10 +35,12 @@ import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesP import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; @@ -113,11 +115,13 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, private relationshipProcessorService: RelationshipProcessorService, private reportAbuseProcessorService: ReportAbuseProcessorService, - private tickChartsProcessorService: TickChartsProcessorService, private resyncChartsProcessorService: ResyncChartsProcessorService, + private scheduledNoteProcessorService: ScheduledNoteProcessorService, + private tickChartsProcessorService: TickChartsProcessorService, private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private checkMissingScheduledNoteProcessorService: CheckMissingScheduledNoteProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -141,11 +145,13 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { switch (job.name) { + case 'scheduledNote': return this.scheduledNoteProcessorService.process(job); case 'tickCharts': return this.tickChartsProcessorService.process(); case 'resyncCharts': return this.resyncChartsProcessorService.process(); case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'checkMissingScheduledNote': return this.checkMissingScheduledNoteProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } diff --git a/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts new file mode 100644 index 000000000..8c441a9cc --- /dev/null +++ b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { QueueService } from '@/core/QueueService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +@Injectable() +export class CheckMissingScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(): Promise { + this.logger.info(`checking missing scheduled note tasks`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:check`, 3 * 60 * 1000, 1, 1000); + } catch (e) { + this.logger.warn(`check is already being processed`); + return; + } + + const query = this.scheduledNotesRepository.createQueryBuilder('draft') + .where('draft.scheduledAt < now() - interval \'5 minutes\'').orderBy('draft.createdAt', 'ASC'); + + let lastId = '0'; + while (true) { + const drafts = await query.andWhere('draft.id > :lastId', { lastId }).limit(100).getMany(); + + if (drafts.length === 0) { + break; + } + + for (const draft of drafts.filter(draft => draft.scheduledAt !== null)) { + const jobState = await this.queueService.systemQueue.getJobState(`scheduledNote:${draft.id}`); + if (jobState !== 'unknown') continue; + + this.logger.warn(`found missing scheduled note task: ${draft.id}`); + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + } + + lastId = drafts[drafts.length - 1].id; + } + } +} diff --git a/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts new file mode 100644 index 000000000..3ca28941e --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import * as Bull from 'bullmq'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import { MiNote, type ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { ScheduledNoteJobData } from '../types.js'; + +@Injectable() +export class ScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private notificationService: NotificationService, + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`processing ${job.data.draftId}`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:${job.data.draftId}`, 30 * 1000, 1, 100); + } catch (e) { + this.logger.warn(`draft=${job.data.draftId} is already being processed`); + return 'ok'; + } + + const draft = await this.scheduledNotesRepository.findOne({ + where: { id: job.data.draftId, reason: IsNull() }, + relations: ['user'], + }); + + if (draft == null) { + this.logger.warn(`draft not found: ${job.data.draftId}`); + return 'ok'; + } + + if (!draft.user || draft.user.isSuspended) { + this.logger.warn(`user is suspended: ${draft.userId}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + return 'ok'; + } + + try { + const note = (await this.noteCreateService.create(draft.user, { + ...draft.draft, + createdAt: new Date(), + scheduledAt: null, + })) as MiNote; + + await this.scheduledNotesRepository.delete({ id: draft.id }); + + this.notificationService.createNotification(draft.userId, "scheduledNotePosted", { + noteId: note.id, + }); + + return 'ok'; + } catch (e) { + if (e instanceof IdentifiableError) { + if ([ + 'e11b3a16-f543-4885-8eb1-66cad131dbfd', + '689ee33f-f97c-479a-ac49-1b9f8140af99', + '9f466dab-c856-48cd-9e65-ff90ff750580', + '85ab9bd7-3a41-4530-959d-f07073900109', + 'd450b8a9-48e4-4dab-ae36-f4db763fda7c', + ].includes(e.id)) { + this.logger.warn(`creating note from draft=${draft.id} failed: ${e.message}`); + + await this.scheduledNotesRepository.update({ id: draft.id }, { + scheduledAt: null, + reason: e.message, + }); + + this.notificationService.createNotification(draft.userId, "scheduledNoteError", { + draftId: draft.id, + }); + + return e.message; + } + } + throw e; + } + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 49aedec58..ca2662155 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,6 +6,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiUser } from '@/models/User.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook } from '@/models/Webhook.js'; @@ -116,6 +117,10 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduledNoteJobData = { + draftId: MiScheduledNote['id']; +}; + export type WebhookDeliverJobData = { type: string; content: unknown; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 924eac7a1..33e93a814 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -682,6 +684,8 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; +const $notes_scheduled_cancel: Provider = { provide: 'ep:notes/scheduled/cancel', useClass: ep___notes_scheduled_cancel.default }; +const $notes_scheduled_list: Provider = { provide: 'ep:notes/scheduled/list', useClass: ep___notes_scheduled_list.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; @@ -1081,6 +1085,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, @@ -1474,6 +1480,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 987228be6..a5f51328c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -680,6 +682,8 @@ const eps = [ ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], ['notes/delete', ep___notes_delete], + ['notes/scheduled/cancel', ep___notes_scheduled_cancel], + ['notes/scheduled/list', ep___notes_scheduled_list], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 963f3bbfd..c90c9daf9 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,7 @@ import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -148,6 +149,31 @@ export const meta = { id: '66819f28-9525-389d-4b0a-4974363fbbbf', }, + cannotScheduleToPast: { + message: 'Cannot schedule to the past.', + code: 'CANNOT_SCHEDULE_TO_PAST', + id: 'e577d185-8179-4a17-b47f-6093985558e6', + }, + + cannotScheduleToFarFuture: { + message: 'Cannot schedule to the far future.', + code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE', + id: 'ea102856-e8da-4ae9-a98a-0326821bd177', + }, + + cannotScheduleSameTime: { + message: 'Cannot schedule multiple notes at the same time.', + code: 'CANNOT_SCHEDULE_SAME_TIME', + id: '187a8fab-fd83-4ae6-a46c-0f6f07784634', + }, + + rolePermissionDenied: { + message: 'You are not assigned to a required role.', + code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', + id: '12f1d5d2-f7ec-4d7c-b608-e873f4b20327', + status: 403, + }, }, } as const; @@ -207,6 +233,7 @@ export const paramDef = { }, required: ['choices'], }, + scheduledAt: { type: 'integer', nullable: true }, noCreatedNote: { type: 'boolean', default: false }, }, // (re)note with text, files and poll are optional @@ -263,6 +290,7 @@ export default class extends Endpoint { // eslint- private loggerService: LoggerService, private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me, _token, _file, _cleanup, ip, headers) => { const logger = this.loggerService.getLogger('api:notes:create'); @@ -318,7 +346,7 @@ export default class extends Endpoint { // eslint- let renote: MiNote | null = null; if (ps.renoteId != null) { // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + renote = await this.notesRepository.findOne({ where: { id: ps.renoteId }, relations: ['user'] }); if (renote == null) { logger.error('No such renote target.', { renoteId: ps.renoteId }); @@ -371,7 +399,7 @@ export default class extends Endpoint { // eslint- let reply: MiNote | null = null; if (ps.replyId != null) { // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + reply = await this.notesRepository.findOne({ where: { id: ps.replyId }, relations: ['user'] }); if (reply == null) { logger.error('No such reply target.', { replyId: ps.replyId }); @@ -384,11 +412,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReplyToInvisibleNote); } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } else if ( me.isBot ) { - const replayuser = await this.usersRepository.findOneBy({ id: reply.userId }); - if (replayuser?.isBot) { - throw new ApiError(meta.errors.replyingToAnotherBot); - } + } else if (me.isBot && reply.user!.isBot) { + throw new ApiError(meta.errors.replyingToAnotherBot); } // Check blocking @@ -427,10 +452,28 @@ export default class extends Endpoint { // eslint- } } + let scheduledAt: Date | null = null; + if (ps.scheduledAt) { + const now = new Date(); + scheduledAt = new Date(ps.scheduledAt); + scheduledAt.setMilliseconds(0); + + if (scheduledAt < now) { + logger.error('Cannot schedule to the past.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToPast); + } + + if (scheduledAt.getTime() - now.getTime() > ms('1year')) { + logger.error('Cannot schedule to the far future.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToFarFuture); + } + } + // 投稿を作成 try { const note = await this.noteCreateService.create(me, { createdAt: new Date(), + scheduledAt: ps.scheduledAt ? scheduledAt : null, files: files, poll: ps.poll ? { choices: ps.poll.choices, @@ -454,10 +497,18 @@ export default class extends Endpoint { // eslint- // 1分間、リクエストの処理結果を記録 await this.redisForTimelines.set(`note:idempotent:${me.id}:${hash}`, note.id, 'EX', 60); - logger.info('Successfully created a note.', { noteId: note.id }); - if (ps.noCreatedNote) return; + if (!scheduledAt) { + logger.info('Successfully created a note.', { noteId: note.id }); + } else { + this.notificationService.createNotification(me.id, "noteScheduled", { + draftId: note.id, + }); + logger.info('Successfully scheduled a note.', { draftId: note.id }); + } + + if (ps.noCreatedNote || scheduledAt) return; else return { - createdNote: await this.noteEntityService.pack(note, me), + createdNote: await this.noteEntityService.pack(note as MiNote, me), }; } catch (err) { // エラーが発生した場合、まだ処理中として記録されている場合はリクエストの処理結果を削除 @@ -468,6 +519,8 @@ export default class extends Endpoint { // eslint- if (err instanceof IdentifiableError) { if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions); + if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied); + if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime); } throw err; diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts new file mode 100644 index 000000000..b3627521e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ApiError } from '@/server/api/error.js'; +import ms from 'ms'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canCreateContent', + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes', + + errors: { + noSuchDraft: { + message: 'No such draft', + code: 'NO_SUCH_DRAFT', + id: '91c2ad21-fb45-4f2a-ba4c-ea749b262947', + } + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + draftId: { type: 'string', format: 'misskey:id' }, + }, + required: ['draftId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.scheduledNotesRepository.findOneBy({ id: ps.draftId, userId: me.id }); + if (!draft) throw new ApiError(meta.errors.noSuchDraft); + + await this.queueService.systemQueue.remove(`scheduledNote:${draft.id}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts new file mode 100644 index 000000000..c27a7b9a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { ScheduledNoteEntityService } from '@/core/entities/ScheduledNoteEntityService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canScheduleNote', + + kind: 'write:notes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private scheduledNoteEntityService: ScheduledNoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.scheduledNotesRepository.createQueryBuilder('draft').where('draft.userId = :userId', { userId: me.id }); + const drafts = await query.orderBy('draft.scheduledAt', 'ASC', 'NULLS FIRST').offset(ps.offset).limit(ps.limit).getMany(); + + return await this.scheduledNoteEntityService.packMany(drafts, me); + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e125b074f..945eb27b5 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -3,6 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { IPoll } from '@/models/Poll.js'; +import type { MiChannel } from '@/models/Channel.js'; +import type { MiApp } from '@/models/App.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; + /** * note - 通知オンにしているユーザーが投稿した * follow - フォローされた @@ -16,6 +23,9 @@ * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 + * noteScheduled - 予約投稿が予約された + * scheduledNotePosted - 予約投稿が投稿された + * scheduledNoteError - 予約投稿がエラーになった * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -32,6 +42,9 @@ export const notificationTypes = [ 'followRequestAccepted', 'roleAssigned', 'achievementEarned', + 'noteScheduled', + 'scheduledNotePosted', + 'scheduledNoteError', 'app', 'test', ] as const; @@ -338,6 +351,36 @@ export type ModerationLogPayloads = { } }; +export type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type NoteCreateOption = { + createdAt?: Date | null; + scheduledAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; +}; + export type Serialized = { [K in keyof T]: T[K] extends Date diff --git a/packages/frontend/src/components/MkDraftsDialog.vue b/packages/frontend/src/components/MkDraftsDialog.vue index 755bb2cf1..289707cd2 100644 --- a/packages/frontend/src/components/MkDraftsDialog.vue +++ b/packages/frontend/src/components/MkDraftsDialog.vue @@ -10,14 +10,18 @@ -
+ + + + +
{{ i18n.ts.nothing }}
-
+
@@ -35,7 +39,13 @@
- +
+
+ + +
+ +
@@ -56,24 +66,94 @@
-
+ + + + diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 1450a3022..f40dc1b2b 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -25,6 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_noteScheduled]: notification.type === 'noteScheduled', + [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', + [$style.t_scheduledNoteError]: notification.type === 'scheduledNoteError', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > @@ -37,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + +