(enhance) `notes/create` で予約投稿できるように

This commit is contained in:
kakkokari-gtyih 2023-11-09 23:31:16 +09:00
parent 1995400dac
commit 2bc15c09ed
9 changed files with 122 additions and 23 deletions

View File

@ -32,9 +32,9 @@ export class ScheduleNotePostProcessorService {
@bindThis @bindThis
public async process(job: Bull.Job<ScheduleNotePostJobData>): Promise<void> { public async process(job: Bull.Job<ScheduleNotePostJobData>): Promise<void> {
this.scheduledNotesRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => { this.scheduledNotesRepository.findOneBy({ id: job.data.scheduledNoteId }).then(async (data) => {
if (!data) { if (!data) {
this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`); this.logger.warn(`Schedule note ${job.data.scheduledNoteId} not found`);
} else { } else {
data.note.createdAt = new Date(); data.note.createdAt = new Date();
const me = await this.usersRepository.findOneByOrFail({ id: data.userId }); const me = await this.usersRepository.findOneByOrFail({ id: data.userId });

View File

@ -109,7 +109,7 @@ export type EndedPollNotificationJobData = {
}; };
export type ScheduleNotePostJobData = { export type ScheduleNotePostJobData = {
scheduleNoteId: MiNote['id']; scheduledNoteId: MiNote['id'];
} }
type MinimumUser = { type MinimumUser = {

View File

@ -7,7 +7,8 @@ import ms from 'ms';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { MiUser } from '@/models/User.js'; 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 { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiChannel } from '@/models/Channel.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 { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.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 { DI } from '@/di-symbols.js';
import { isPureRenote } from '@/misc/is-pure-renote.js'; import { isPureRenote } from '@/misc/is-pure-renote.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -39,9 +42,17 @@ export const meta = {
properties: { properties: {
createdNote: { createdNote: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: true,
ref: 'Note', 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', code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
id: '33510210-8452-094c-6227-4a6c05d99f00', 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; } as const;
@ -164,6 +191,13 @@ export const paramDef = {
}, },
required: ['choices'], required: ['choices'],
}, },
schedule: {
type: 'object',
nullable: true,
properties: {
expiresAt: { type: 'integer', nullable: false },
},
},
}, },
// (re)note with text, files and poll are optional // (re)note with text, files and poll are optional
anyOf: [ anyOf: [
@ -172,6 +206,7 @@ export const paramDef = {
{ required: ['fileIds'] }, { required: ['fileIds'] },
{ required: ['mediaIds'] }, { required: ['mediaIds'] },
{ required: ['poll'] }, { required: ['poll'] },
{ required: ['schedule'] },
], ],
} as const; } as const;
@ -184,6 +219,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.scheduledNotesRepository)
private scheduledNotesRepository: ScheduledNotesRepository,
@Inject(DI.blockingsRepository) @Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository, private blockingsRepository: BlockingsRepository,
@ -195,6 +233,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
private queueService: QueueService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let visibleUsers: MiUser[] = []; let visibleUsers: MiUser[] = [];
@ -311,8 +352,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
// 投稿を作成 const note: MiNoteCreateOption = {
const note = await this.noteCreateService.create(me, {
createdAt: new Date(), createdAt: new Date(),
files: files, files: files,
poll: ps.poll ? { poll: ps.poll ? {
@ -332,11 +372,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
apMentions: ps.noExtractMentions ? [] : undefined, apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : 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),
};
}
}); });
} }
} }

View File

@ -6,6 +6,7 @@
import ms from 'ms'; import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { ScheduledNotesRepository } from '@/models/_.js'; import type { ScheduledNotesRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -31,9 +32,9 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
noteId: { type: 'string', format: 'misskey:id' }, scheduledNoteId: { type: 'string', format: 'misskey:id' },
}, },
required: ['noteId'], required: ['scheduledNoteId'],
} as const; } as const;
@Injectable() @Injectable()
@ -41,9 +42,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.scheduledNotesRepository) @Inject(DI.scheduledNotesRepository)
private scheduledNotesRepository: ScheduledNotesRepository, private scheduledNotesRepository: ScheduledNotesRepository,
private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { 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);
}
}); });
} }
} }

View File

@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
user: user, user: user,
createdAt: new Date(item.expiresAt), createdAt: new Date(item.expiresAt),
isSchedule: true, isSchedule: true,
// ↓TODO: NoteのIDに予約投稿IDを入れたくない本来別ものなため
id: item.id, id: item.id,
}, },
}; };

View File

@ -40,7 +40,9 @@ const props = defineProps<{
}>(); }>();
async function deleteScheduleNote() { 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(() => { .then(() => {
isDeleted.value = true; isDeleted.value = true;
}); });

View File

@ -751,7 +751,7 @@ async function post(ev?: MouseEvent) {
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
channelId: props.channel ? props.channel.id : undefined, channelId: props.channel ? props.channel.id : undefined,
schedule, schedule,
poll: poll, poll,
cw: useCw ? cw ?? '' : null, cw: useCw ? cw ?? '' : null,
localOnly: localOnly, localOnly: localOnly,
visibility: visibility, visibility: visibility,
@ -783,11 +783,11 @@ async function post(ev?: MouseEvent) {
if (postAccount) { if (postAccount) {
const storedAccounts = await getAccounts(); const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.id === postAccount.id)?.token; token = storedAccounts.find(x => x.id === postAccount?.id)?.token;
} }
posting = true; posting = true;
os.api(postData.schedule ? 'notes/schedule/create' : 'notes/create', postData, token).then(() => { os.api('notes/create', postData, token).then(() => {
if (props.freezeAfterPosted) { if (props.freezeAfterPosted) {
posted = true; posted = true;
} else { } else {

View File

@ -1748,6 +1748,9 @@ export type Endpoints = {
expiresAt?: null | number; expiresAt?: null | number;
expiredAfter?: null | number; expiredAfter?: null | number;
}; };
schedule?: null | {
expiresAt?: null | number;
};
}; };
res: { res: {
createdNote: Note; createdNote: Note;
@ -1771,13 +1774,18 @@ export type Endpoints = {
}; };
'notes/schedule/delete': { 'notes/schedule/delete': {
req: { req: {
noteId: Note['id']; scheduledNoteId: Note['id'];
}; };
res: null; res: null;
}; };
'notes/schedule/list': { 'notes/schedule/list': {
req: TODO; req: TODO;
res: Note[]; res: {
id: Note['id'];
userId: User['id'];
expiresAt: number;
note: Note;
}[];
}; };
'notes/favorites/create': { 'notes/favorites/create': {
req: { 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: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: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: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/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 // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts

View File

@ -507,11 +507,19 @@ export type Endpoints = {
expiresAt?: null | number; expiresAt?: null | number;
expiredAfter?: null | number; expiredAfter?: null | number;
}; };
schedule?: null | {
expiresAt?: null | number;
};
}; res: { createdNote: Note }; }; }; res: { createdNote: Note }; };
'notes/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/delete': { req: { noteId: Note['id']; }; res: null; };
'notes/schedule/create': { req: Partial<Note> & { schedule: { expiresAt: number; } }; res: { createdNote: Note }; }; 'notes/schedule/create': { req: Partial<Note> & { schedule: { expiresAt: number; } }; res: { createdNote: Note }; };
'notes/schedule/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/schedule/delete': { req: { scheduledNoteId: Note['id']; }; res: null; };
'notes/schedule/list': { req: TODO; res: Note[]; }; '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/create': { req: { noteId: Note['id']; }; res: null; };
'notes/favorites/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/favorites/delete': { req: { noteId: Note['id']; }; res: null; };
'notes/featured': { req: TODO; res: Note[]; }; 'notes/featured': { req: TODO; res: Note[]; };