diff --git a/locales/index.d.ts b/locales/index.d.ts index 4a19eaf125..25c640495e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -619,6 +619,7 @@ export interface Locale { "enableInfiniteScroll": string; "visibility": string; "poll": string; + "schedulePost": string; "useCw": string; "enablePlayer": string; "disablePlayer": string; @@ -1715,6 +1716,7 @@ export interface Locale { "gtlAvailable": string; "ltlAvailable": string; "canPublicNote": string; + "canScheduleNote": string; "canEditNote": string; "canInvite": string; "inviteLimit": string; @@ -2558,6 +2560,16 @@ export interface Locale { }; }; }; + "_schedulePost": { + "list": string; + "postDate": string; + "postTime": string; + "localTime": string; + "addSchedule": string; + "willBePostedAtX": string; + "deleteAreYouSure": string; + "deleteAndEditConfirm": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d3f9afab51..94a1790dae 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -616,6 +616,7 @@ invisibleNote: "非公開の投稿" enableInfiniteScroll: "自動でもっと見る" visibility: "公開範囲" poll: "アンケート" +schedulePost: "予約投稿" useCw: "内容を隠す" enablePlayer: "プレイヤーを開く" disablePlayer: "プレイヤーを閉じる" @@ -1625,6 +1626,7 @@ _role: ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canEditNote: "ノートの編集" + canScheduleNote: "予約投稿の許可" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" inviteLimitCycle: "招待コードの発行間隔" @@ -2445,3 +2447,13 @@ _externalResourceInstaller: _themeInstallFailed: title: "テーマのインストールに失敗しました" description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" + +_schedulePost: + list: "予約投稿一覧" + postDate: "日付" + postTime: "時刻" + localTime: "端末に設定されているタイムゾーンの時刻で投稿されます。" + addSchedule: "予約設定" + willBePostedAtX: "{date}に投稿予約しました。" + deleteAreYouSure: "予約投稿を削除しますか?" + deleteAndEditConfirm: "予約投稿を削除して編集しますか?" diff --git a/packages/backend/migration/1699437894737-schedulenote.js b/packages/backend/migration/1699437894737-schedulenote.js new file mode 100644 index 0000000000..d0188a45ee --- /dev/null +++ b/packages/backend/migration/1699437894737-schedulenote.js @@ -0,0 +1,12 @@ +export class Schedulenote1699437894737 { + name = 'Schedulenote1699437894737' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "note_schedule"`); + } +} diff --git a/packages/backend/migration/1699949373507-schedulenote2.js b/packages/backend/migration/1699949373507-schedulenote2.js new file mode 100644 index 0000000000..b8d8a6394b --- /dev/null +++ b/packages/backend/migration/1699949373507-schedulenote2.js @@ -0,0 +1,11 @@ +export class Schedulenote21699949373507 { + name = 'Schedulenote21699949373507' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_schedule" RENAME COLUMN "expiresAt" TO "scheduledAt"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_schedule" RENAME COLUMN "scheduledAt" TO "expiresAt"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 04c4de96a0..c36f89bd42 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -15,19 +15,16 @@ 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, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, 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 { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { MiNoteCreateOption as Option, MiMinimumUser as MinimumUser } from '@/types.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -116,35 +113,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 { #shutdownController = new AbortController(); @@ -368,7 +336,6 @@ export class NoteCreateService implements OnApplicationShutdown { data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); } } - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); setImmediate('post created', { signal: this.#shutdownController.signal }).then( diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 4444dc9787..abd35afee2 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -10,10 +10,11 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { QUEUE, baseQueueOptions } from '@/queue/const.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; +import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, ScheduleNotePostJobData } from '../queue/types.js'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; +export type ScheduleNotePostQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; @@ -33,6 +34,12 @@ const $endedPollNotification: Provider = { inject: [DI.config], }; +const $scheduleNotePost: Provider = { + provide: 'queue:scheduleNotePost', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)), + inject: [DI.config], +}; + const $deliver: Provider = { provide: 'queue:deliver', useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), @@ -75,6 +82,7 @@ const $webhookDeliver: Provider = { providers: [ $system, $endedPollNotification, + $scheduleNotePost, $deliver, $inbox, $db, @@ -85,6 +93,7 @@ const $webhookDeliver: Provider = { exports: [ $system, $endedPollNotification, + $scheduleNotePost, $deliver, $inbox, $db, @@ -97,6 +106,7 @@ export class QueueModule implements OnApplicationShutdown { constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -117,6 +127,7 @@ export class QueueModule implements OnApplicationShutdown { await Promise.all([ this.systemQueue.close(), this.endedPollNotificationQueue.close(), + this.scheduleNotePostQueue.close(), this.deliverQueue.close(), this.inboxQueue.close(), this.dbQueue.close(), diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index be378a899b..9b06884392 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -12,7 +12,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue, ScheduleNotePostQueue } from './QueueModule.js'; import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -25,6 +25,7 @@ export class QueueService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index d13d831385..d93f73a07d 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -28,6 +28,7 @@ export type RolePolicies = { ltlAvailable: boolean; canPublicNote: boolean; canEditNote: boolean; + canScheduleNote: boolean; canInvite: boolean; inviteLimit: number; inviteLimitCycle: number; @@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = { ltlAvailable: true, canPublicNote: true, canEditNote: true, + canScheduleNote: true, canInvite: false, inviteLimit: 0, inviteLimitCycle: 60 * 24 * 7, @@ -310,6 +312,7 @@ export class RoleService implements OnApplicationShutdown { 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)), canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 8411cb8229..afa919dfa9 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -15,6 +15,7 @@ export const DI = { //#region Repositories usersRepository: Symbol('usersRepository'), notesRepository: Symbol('notesRepository'), + scheduledNotesRepository: Symbol('scheduledNotesRepository'), announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 866fdfe6d4..1d3d0c873f 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,74 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { + MiAbuseUserReport, + MiAccessToken, + MiAd, + MiAnnouncement, + MiAnnouncementRead, + MiAntenna, + MiApp, + MiAuthSession, + MiAvatarDecoration, + MiBlocking, + MiChannel, + MiChannelFavorite, + MiChannelFollowing, + MiClip, + MiClipFavorite, + MiClipNote, + MiDriveFile, + MiDriveFolder, + MiEmoji, + MiFlash, + MiFlashLike, + MiFollowRequest, + MiFollowing, + MiGalleryLike, + MiGalleryPost, + MiHashtag, + MiInstance, + MiMeta, + MiModerationLog, + MiMuting, + MiNote, + MiNoteFavorite, + MiNoteReaction, + MiNoteThreadMuting, + MiNoteUnread, + MiPage, + MiPageLike, + MiPasswordResetRequest, + MiPoll, + MiPollVote, + MiPromoNote, + MiPromoRead, + MiRegistrationTicket, + MiRegistryItem, + MiRelay, + MiRenoteMuting, + MiRetentionAggregation, + MiRole, + MiRoleAssignment, + MiSignin, + MiSwSubscription, + MiUsedUsername, + MiUser, + MiUserIp, + MiUserKeypair, + MiUserList, + MiUserListFavorite, + MiUserListMembership, + MiUserMemo, + MiUserNotePining, + MiUserPending, + MiUserProfile, + MiUserPublickey, + MiUserSecurityKey, + MiWebhook, + MiScheduledNote, +} from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -21,6 +88,12 @@ const $notesRepository: Provider = { inject: [DI.db], }; +const $scheduledNotesRepository: Provider = { + provide: DI.scheduledNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiScheduledNote), + inject: [DI.db], +}; + const $announcementsRepository: Provider = { provide: DI.announcementsRepository, useFactory: (db: DataSource) => db.getRepository(MiAnnouncement), @@ -405,6 +478,7 @@ const $userMemosRepository: Provider = { providers: [ $usersRepository, $notesRepository, + $scheduledNotesRepository, $announcementsRepository, $announcementReadsRepository, $appsRepository, @@ -472,6 +546,7 @@ const $userMemosRepository: Provider = { exports: [ $usersRepository, $notesRepository, + $scheduledNotesRepository, $announcementsRepository, $announcementReadsRepository, $appsRepository, diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts new file mode 100644 index 0000000000..513ad2f845 --- /dev/null +++ b/packages/backend/src/models/ScheduledNote.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import type { MiNoteCreateOption } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('note_schedule') +export class MiScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Column('jsonb') + public note: MiNoteCreateOption; + + @Index() + @Column('varchar', { + length: 260, + }) + public userId: MiUser['id']; + + @Column('timestamp with time zone') + public scheduledAt: Date; +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index d7c327f164..10f5982ce5 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -68,6 +68,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import { MiScheduledNote } from './ScheduledNote.js'; import type { Repository } from 'typeorm'; export { @@ -104,6 +105,7 @@ export { MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, + MiScheduledNote, MiPage, MiPageLike, MiPasswordResetRequest, @@ -171,6 +173,7 @@ export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; export type NoteUnreadsRepository = Repository; +export type ScheduledNotesRepository = Repository; export type PagesRepository = Repository; export type PageLikesRepository = Repository; export type PasswordResetRequestsRepository = Repository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index cd611839a4..47af990231 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -76,6 +76,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -149,6 +150,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 e6327002c5..d17920efa4 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; @@ -71,6 +72,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor RelationshipProcessorService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, + ScheduleNotePostProcessorService, DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 5201bfed8e..572fb59ba3 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,6 +11,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; @@ -78,6 +79,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; + private schedulerNotePostQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -86,6 +88,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private queueLoggerService: QueueLoggerService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, + private scheduleNotePostProcessorService: ScheduleNotePostProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, @@ -320,6 +323,13 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`)); //#endregion + //#region schedule note post + this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST), + autorun: false, + }); + //#endregion + //#region ended poll notification this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), { ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), @@ -339,6 +349,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), + this.schedulerNotePostQueueWorker.run(), ]); } @@ -353,6 +364,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), + this.schedulerNotePostQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 87d075304d..fb08da9b1d 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -11,6 +11,7 @@ export const QUEUE = { INBOX: 'inbox', SYSTEM: 'system', ENDED_POLL_NOTIFICATION: 'endedPollNotification', + SCHEDULE_NOTE_POST: 'scheduleNotePost', DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts new file mode 100644 index 0000000000..40fc3f3e54 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type { ScheduledNotesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduleNotePostJobData } from '../types.js'; + +@Injectable() +export class ScheduleNotePostProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.scheduledNotesRepository.findOneBy({ id: job.data.scheduledNoteId }).then(async (data) => { + if (!data) { + this.logger.warn(`Schedule note ${job.data.scheduledNoteId} not found`); + } else { + data.note.createdAt = new Date(); + const me = await this.usersRepository.findOneByOrFail({ id: data.userId }); + await this.noteCreateService.create(me, data.note); + await this.scheduledNotesRepository.remove(data); + } + }); + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 9330c01528..b6895816c6 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,9 +6,13 @@ 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 { MiUser } from '@/models/User.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; +import { IPoll } from '@/models/Poll.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; +import { MiChannel } from '@/models/Channel.js'; +import { MiApp } from '@/models/App.js'; import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { @@ -104,6 +108,17 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduleNotePostJobData = { + scheduledNoteId: MiNote['id']; +} + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + 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 0a32e615d8..e8d90d65e0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -269,6 +269,8 @@ import * as ep___notes_children from './endpoints/notes/children.js'; 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_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; @@ -636,6 +638,8 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; 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_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; +const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; @@ -1006,6 +1010,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_clips, $notes_conversation, $notes_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_delete, $notes_update, $notes_favorites_create, @@ -1370,6 +1376,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_clips, $notes_conversation, $notes_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_delete, $notes_update, $notes_favorites_create, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a84e3bda77..ef52c41934 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -269,6 +269,8 @@ import * as ep___notes_children from './endpoints/notes/children.js'; 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_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; @@ -633,6 +635,8 @@ const eps = [ ['notes/clips', ep___notes_clips], ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], + ['notes/schedule/delete', ep___notes_schedule_delete], + ['notes/schedule/list', ep___notes_schedule_list], ['notes/delete', ep___notes_delete], ['notes/update', ep___notes_update], ['notes/favorites/create', ep___notes_favorites_create], diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index 901195e9a5..2f09e70b62 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -48,6 +48,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 25ad66e089..0423c64f1a 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -7,7 +7,8 @@ import ms from 'ms'; import { In } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { MiUser } from '@/models/User.js'; -import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, ScheduledNotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; +import type { MiNoteCreateOption } from '@/types.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; import type { MiChannel } from '@/models/Channel.js'; @@ -15,6 +16,9 @@ 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 { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; import { isPureRenote } from '@/misc/is-pure-renote.js'; import { ApiError } from '../../error.js'; @@ -39,9 +43,17 @@ export const meta = { properties: { createdNote: { type: 'object', - optional: false, nullable: false, + optional: false, nullable: true, ref: 'Note', }, + scheduledNoteId: { + type: 'string', + optional: true, nullable: true, + }, + scheduledNote: { + type: 'object', + optional: true, nullable: true, + }, }, }, @@ -111,6 +123,28 @@ export const meta = { code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', id: '33510210-8452-094c-6227-4a6c05d99f00', }, + + cannotCreateAlreadyExpiredSchedule: { + message: 'Schedule is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE', + id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07', + }, + specifyScheduleDate: { + message: 'Please specify schedule date.', + code: 'PLEASE_SPECIFY_SCHEDULE_DATE', + id: 'c93a6ad6-f7e2-4156-a0c2-3d03529e5e0f', + }, + noSuchSchedule: { + message: 'No such schedule.', + code: 'NO_SUCH_SCHEDULE', + id: '44dee229-8da1-4a61-856d-e3a4bbc12032', + }, + rolePermissionDenied: { + message: 'You are not assigned to a required role.', + code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', + id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a', + }, }, } as const; @@ -170,6 +204,13 @@ export const paramDef = { }, required: ['choices'], }, + schedule: { + type: 'object', + nullable: true, + properties: { + scheduledAt: { type: 'string', nullable: false }, + }, + }, }, // (re)note with text, files and poll are optional anyOf: [ @@ -178,6 +219,7 @@ export const paramDef = { { required: ['fileIds'] }, { required: ['mediaIds'] }, { required: ['poll'] }, + { required: ['schedule'] }, ], } as const; @@ -190,6 +232,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -201,6 +246,10 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + + private roleService: RoleService, + private queueService: QueueService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { let visibleUsers: MiUser[] = []; @@ -319,8 +368,7 @@ export default class extends Endpoint { // eslint- } } - // 投稿を作成 - const note = await this.noteCreateService.create(me, { + const note: MiNoteCreateOption = { createdAt: new Date(), files: files, poll: ps.poll ? { @@ -340,11 +388,51 @@ export default class extends Endpoint { // eslint- apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - - return { - createdNote: await this.noteEntityService.pack(note, me), }; + + if (ps.schedule) { + // 予約投稿 + const canCreateScheduledNote = (await this.roleService.getUserPolicies(me.id)).canScheduleNote; + if (!canCreateScheduledNote) { + throw new ApiError(meta.errors.rolePermissionDenied); + } + + if (!ps.schedule.scheduledAt) { + throw new ApiError(meta.errors.specifyScheduleDate); + } + + me.token = null; + const scheduledNoteId = this.idService.gen(new Date().getTime()); + await this.scheduledNotesRepository.insert({ + id: scheduledNoteId, + note: note, + userId: me.id, + scheduledAt: new Date(ps.schedule.scheduledAt), + }); + + const delay = new Date(ps.schedule.scheduledAt).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(delay.toString(), { + scheduledNoteId, + }, { + jobId: scheduledNoteId, + delay, + removeOnComplete: true, + }); + + return { + scheduledNoteId, + scheduledNote: note, + + // ↓互換性のため(微妙) + createdNote: null, + }; + } else { + // 投稿を作成 + const createdNoteRaw = await this.noteCreateService.create(me, note); + return { + createdNote: await this.noteEntityService.pack(createdNoteRaw, me), + }; + } }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts new file mode 100644 index 0000000000..e108016e80 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '490be23f-8c1f-4796-819f-94cb4f9d1630', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + scheduledNoteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['scheduledNoteId'], +} 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) => { + await this.scheduledNotesRepository.delete({ id: ps.scheduledNoteId }); + if (ps.scheduledNoteId) { + await this.queueService.ScheduleNotePostQueue.remove(ps.scheduledNoteId); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts new file mode 100644 index 0000000000..9f89cf2a7f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { IdService } from '@/core/IdService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canScheduleNote', + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private idService: IdService, + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.scheduledNotesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: me.id }); + + const scheduleNotes = await query.limit(ps.limit).getMany(); + const user = await this.userEntityService.pack(me, me); + const scheduleNotesPack = scheduleNotes.map((item) => { + return { + ...item, + scheduledAt: new Date(item.scheduledAt).toISOString(), + note: { + ...item.note, + user: user, + createdAt: new Date(item.scheduledAt).toISOString(), + isSchedule: true, + id: null, + scheduledNoteId: item.id, + }, + }; + }); + + return scheduleNotesPack; + }); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 3e35d5415e..a43d7bb01f 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -24,7 +24,16 @@ import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; import { MetaService } from '@/core/MetaService.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { + DbQueue, + DeliverQueue, + EndedPollNotificationQueue, + InboxQueue, + ObjectStorageQueue, + ScheduleNotePostQueue, + SystemQueue, + WebhookDeliverQueue, +} from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; @@ -98,6 +107,7 @@ export class ClientServerService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -215,6 +225,7 @@ export class ClientServerService { queues: [ this.systemQueue, this.endedPollNotificationQueue, + this.scheduleNotePostQueue, this.deliverQueue, this.inboxQueue, this.dbQueue, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 1fb3d6a6ce..6061d00ee1 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -3,6 +3,14 @@ * 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'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; + /** * note - 通知オンにしているユーザーが投稿した * follow - フォローされた @@ -253,6 +261,36 @@ export type ModerationLogPayloads = { }; }; +export type MiMinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type MiNoteCreateOption = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + schedule?: MiScheduledNote | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MiMinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MiMinimumUser[] | 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/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 96e6d34be1..c65bd3eeb0 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -488,9 +488,9 @@ onBeforeUnmount(() => { .indicator { position: absolute; top: 5px; - left: 13px; + right: 18px; color: var(--indicator); - font-size: 12px; + font-size: 8px; animation: blink 1s infinite; } diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 7c5ac1f9ee..accb637dd3 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- + + @@ -40,7 +41,8 @@ import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; const mock = inject('mock', false); defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note & {isSchedule? : boolean}; + scheduled?: boolean; }>(); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index f3ab6b2723..541b76bfe8 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -42,8 +91,12 @@ const showContent = $ref(false); margin: 0; padding: 0; font-size: 0.95em; + border-bottom: solid 0.5px var(--divider); +} +.button{ + margin-right: var(--margin); + margin-bottom: var(--margin); } - .avatar { flex-shrink: 0; display: block; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5a45804ca4..e868c39d5f 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -36,17 +36,13 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -72,7 +68,10 @@ SPDX-License-Identifier: AGPL-3.0-only - +
+ + +
@@ -116,6 +115,7 @@ import { formatTimeString } from '@/scripts/format-time-string.js'; import { Autocomplete } from '@/scripts/autocomplete.js'; import * as os from '@/os.js'; import { selectFiles } from '@/scripts/select-file.js'; +import { dateTimeFormat } from '@/scripts/intl-const.js'; import { bannerDark, bannerLight, @@ -134,6 +134,9 @@ import { deepClone } from '@/scripts/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement } from '@/scripts/achievements.js'; +import MkScheduleEditor from '@/components/MkScheduleEditor.vue'; +import { listSchedulePost } from '@/os.js'; + const modal = inject('modal'); let gamingType = computed(defaultStore.makeGetterSetter('gamingType')); @@ -188,6 +191,9 @@ let poll = $ref<{ expiresAt: string | null; expiredAfter: string | null; } | null>(null); +let schedule = $ref<{ + scheduledAt: string | null; +}| null>(null); let useCw = $ref(false); let showPreview = $ref(defaultStore.state.showPreview); watch($$(showPreview), () => defaultStore.set('showPreview', showPreview)); @@ -246,7 +252,9 @@ const submitText = $computed((): string => { ? i18n.ts.quote : props.reply ? i18n.ts.reply - : i18n.ts.note; + : schedule + ? i18n.ts._schedulePost.addSchedule + : i18n.ts.note; }); const textLength = $computed((): number => { @@ -407,6 +415,16 @@ function togglePoll() { } } +function toggleSchedule() { + if (schedule) { + schedule = null; + } else { + schedule = { + scheduledAt: null, + }; + } +} + function addTag(tag: string) { insertTextAtCursor(textareaEl, ` #${tag} `); } @@ -552,7 +570,7 @@ function removeVisibleUser(user) { function clear() { text = ''; - files = []; + schedule = null;files = []; poll = null; quoteId = null; } @@ -735,7 +753,7 @@ async function post(ev?: MouseEvent) { replyId: props.reply ? props.reply.id : undefined, renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, channelId: props.channel ? props.channel.id : undefined, - poll: poll, + schedule, poll, cw: useCw ? cw ?? '' : null, localOnly: localOnly, visibility: visibility, @@ -765,7 +783,7 @@ async function post(ev?: MouseEvent) { if (postAccount) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.id)?.token; + token = storedAccounts.find(x => x.id === postAccount?.id)?.token; } posting = true; @@ -790,6 +808,13 @@ async function post(ev?: MouseEvent) { if (notesCount === 1) { claimAchievement('notes1'); } + poll = null; + + if (postData.schedule?.scheduledAt) { + const d = new Date(postData.schedule.scheduledAt); + const str = dateTimeFormat.format(d); + os.toast(i18n.t('_schedulePost.willBePostedAtX', { date: str })); + } const text = postData.text ?? ''; const lowerCase = text.toLowerCase(); @@ -888,6 +913,45 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } +function openOtherSettingsMenu(ev: MouseEvent) { + let reactionAcceptanceIcon: string; + switch (reactionAcceptance) { + case 'likeOnly': + reactionAcceptanceIcon = 'ti ti-heart'; + break; + case 'likeOnlyForRemote': + reactionAcceptanceIcon = 'ti ti-heart-plus'; + break; + default: + reactionAcceptanceIcon = 'ti ti-icons'; + break; + } + + os.popupMenu([{ + type: 'button', + text: i18n.ts.reactionAcceptance, + icon: reactionAcceptanceIcon, + action: toggleReactionAcceptance, + }, ($i.policies?.canScheduleNote) ? { + type: 'button', + text: i18n.ts.schedulePost, + icon: 'ti ti-calendar-time', + indicate: (schedule != null), + action: toggleSchedule, + } : undefined, ...(($i.policies?.canScheduleNote) ? [null, { + type: 'button', + text: i18n.ts._schedulePost.list, + icon: 'ti ti-calendar-event', + action: () => { + // 投稿フォームが二重に出ないようにとじておく + emit('cancel'); + listSchedulePost(); + }, + }] : [])], ev.currentTarget ?? ev.target, { + align: 'right', + }); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -926,7 +990,12 @@ onMounted(() => { files = init.files; cw = init.cw; useCw = init.cw != null; - if (init.poll) { + if (init.isSchedule) { + schedule = { + scheduledAt: init.createdAt, + }; + } + if (init.poll) { poll = { choices: init.poll.choices.map(x => x.text), multiple: init.poll.multiple, @@ -1069,7 +1138,11 @@ defineExpose({ background: none; } - &.danger { + &.headerRightButtonActive { + color: var(--accent); + } + + &.danger { color: #ff2a2a; } } @@ -1160,6 +1233,15 @@ defineExpose({ border-bottom: solid 0.5px var(--divider); } +.postOptionsRoot { + >* { + border-bottom: solid 0.5px var(--divider); + } + >:last-child { + border-bottom: none; + } +} + .hashtags { z-index: 1; padding-top: 8px; diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue new file mode 100644 index 0000000000..2035dcc450 --- /dev/null +++ b/packages/frontend/src/components/MkScheduleEditor.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue new file mode 100644 index 0000000000..23bcb6d25e --- /dev/null +++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue @@ -0,0 +1,64 @@ + + + + + + + diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 675efd6091..8724bfab74 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -62,6 +62,7 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canEditNote', + 'canScheduleNote', 'canInvite', 'inviteLimit', 'inviteLimitCycle', diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 78a0945ddb..6e999c2aad 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -172,6 +172,14 @@ export const navbarItemDef = reactive({ show: computed(() => $i != null), to: `/@${$i?.username}`, }, + scheduledNotes: { + title: i18n.ts._schedulePost.list, + icon: 'ti ti-calendar-event', + show: computed(() => $i && $i.policies?.canScheduleNote), + action: (ev) => { + os.listSchedulePost(); + }, + }, cacheClear: { title: i18n.ts.cacheClear, icon: 'ti ti-trash', diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 5d6133d035..0dafdba4db 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -449,7 +449,13 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) { }, 'closed'); }); } - +export async function listSchedulePost() { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), { + }, { + }, 'closed'); + }); +} export async function selectDriveFile(multiple: boolean): Promise { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 29125c7958..702e003d40 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -180,6 +180,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+