diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts index 821645b7bb..9fd80fc521 100644 --- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -32,9 +32,9 @@ export class ScheduleNotePostProcessorService { @bindThis public async process(job: Bull.Job): Promise { - this.scheduledNotesRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => { + this.scheduledNotesRepository.findOneBy({ id: job.data.scheduledNoteId }).then(async (data) => { if (!data) { - this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`); + 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 }); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 178ffaf1ed..b6895816c6 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -109,7 +109,7 @@ export type EndedPollNotificationJobData = { }; export type ScheduleNotePostJobData = { - scheduleNoteId: MiNote['id']; + scheduledNoteId: MiNote['id']; } type MinimumUser = { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index df02d3acb7..8a04ec540f 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,8 @@ 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 { DI } from '@/di-symbols.js'; import { isPureRenote } from '@/misc/is-pure-renote.js'; import { ApiError } from '../../error.js'; @@ -39,9 +42,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, + }, }, }, @@ -105,6 +116,22 @@ 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', + }, }, } as const; @@ -164,6 +191,13 @@ export const paramDef = { }, required: ['choices'], }, + schedule: { + type: 'object', + nullable: true, + properties: { + expiresAt: { type: 'integer', nullable: false }, + }, + }, }, // (re)note with text, files and poll are optional anyOf: [ @@ -172,6 +206,7 @@ export const paramDef = { { required: ['fileIds'] }, { required: ['mediaIds'] }, { required: ['poll'] }, + { required: ['schedule'] }, ], } as const; @@ -184,6 +219,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, @@ -195,6 +233,9 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + + private queueService: QueueService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { let visibleUsers: MiUser[] = []; @@ -311,8 +352,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 ? { @@ -332,11 +372,45 @@ 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) { + if (!ps.schedule.expiresAt) { + 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, + expiresAt: new Date(ps.schedule.expiresAt), + }); + + const delay = new Date(ps.schedule.expiresAt).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(String(delay), { + 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 index 1e2b5b91a7..e108016e80 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -6,6 +6,7 @@ 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'; @@ -31,9 +32,9 @@ export const meta = { export const paramDef = { type: 'object', properties: { - noteId: { type: 'string', format: 'misskey:id' }, + scheduledNoteId: { type: 'string', format: 'misskey:id' }, }, - required: ['noteId'], + required: ['scheduledNoteId'], } as const; @Injectable() @@ -41,9 +42,14 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.scheduledNotesRepository) private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { - await this.scheduledNotesRepository.delete({ id: ps.noteId }); + 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 index 9fa2f79593..3b8c029983 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -93,6 +93,7 @@ export default class extends Endpoint { // eslint- user: user, createdAt: new Date(item.expiresAt), isSchedule: true, + // ↓TODO: NoteのIDに予約投稿IDを入れたくない(本来別ものなため) id: item.id, }, }; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 769a42fa06..1535609a05 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -40,7 +40,9 @@ const props = defineProps<{ }>(); async function deleteScheduleNote() { - await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id }) + if (!props.note.isSchedule) return; + // スケジュールつきノートの場合は、ノートIDのフィールドに予約投稿ID(scheduledNoteId)が入るので注意!!!! + await os.apiWithDialog('notes/schedule/delete', { scheduledNoteId: props.note.id }) .then(() => { isDeleted.value = true; }); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index d3645d35d6..83de95d880 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -751,7 +751,7 @@ async function post(ev?: MouseEvent) { renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, channelId: props.channel ? props.channel.id : undefined, schedule, - poll: poll, + poll, cw: useCw ? cw ?? '' : null, localOnly: localOnly, visibility: visibility, @@ -783,11 +783,11 @@ 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; - os.api(postData.schedule ? 'notes/schedule/create' : 'notes/create', postData, token).then(() => { + os.api('notes/create', postData, token).then(() => { if (props.freezeAfterPosted) { posted = true; } else { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 4164435adb..6319c49ebe 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1748,6 +1748,9 @@ export type Endpoints = { expiresAt?: null | number; expiredAfter?: null | number; }; + schedule?: null | { + expiresAt?: null | number; + }; }; res: { createdNote: Note; @@ -1771,13 +1774,18 @@ export type Endpoints = { }; 'notes/schedule/delete': { req: { - noteId: Note['id']; + scheduledNoteId: Note['id']; }; res: null; }; 'notes/schedule/list': { req: TODO; - res: Note[]; + res: { + id: Note['id']; + userId: User['id']; + expiresAt: number; + note: Note; + }[]; }; 'notes/favorites/create': { req: { @@ -3055,7 +3063,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:635:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts +// src/api.types.ts:643:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts // src/entities.ts:627:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 30a507480a..12030be37e 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -507,11 +507,19 @@ export type Endpoints = { expiresAt?: null | number; expiredAfter?: null | number; }; + schedule?: null | { + expiresAt?: null | number; + }; }; res: { createdNote: Note }; }; 'notes/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/schedule/create': { req: Partial & { schedule: { expiresAt: number; } }; res: { createdNote: Note }; }; - 'notes/schedule/delete': { req: { noteId: Note['id']; }; res: null; }; - 'notes/schedule/list': { req: TODO; res: Note[]; }; + 'notes/schedule/delete': { req: { scheduledNoteId: Note['id']; }; res: null; }; + 'notes/schedule/list': { req: TODO; res: { + id: Note['id']; + userId: User['id']; + expiresAt: number; + note: Note; + }[]; }; 'notes/favorites/create': { req: { noteId: Note['id']; }; res: null; }; 'notes/favorites/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/featured': { req: TODO; res: Note[]; };