Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 予約投稿 #129

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class CreateScheduledNoteCreation1722954278805 {
name = 'CreateScheduledNoteCreation1722954278805'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD COLUMN "scheduledAt" TIMESTAMP WITH TIME ZONE`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "scheduledAt"`);
}
}
16 changes: 11 additions & 5 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ type Option = {
url?: string | null;
app?: MiApp | null;
deleteAt?: Date | null;
scheduledAt?: Date | null;
};

@Injectable()
Expand Down Expand Up @@ -236,7 +237,7 @@ export class NoteCreateService implements OnApplicationShutdown {
isCat: MiUser['isCat'];
isIndexable: MiUser['isIndexable'];
isSensitive: MiUser['isSensitive'];
}, data: Option, silent = false): Promise<MiNote> {
}, data: Option, silent = false, waitToPublish?: (note: MiNote) => Promise<void> ): Promise<MiNote> {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
Expand All @@ -247,6 +248,8 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}

const isDraft = data.scheduledAt != null;

// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && (data.channel == null) && data.reply.channelId) {
Expand Down Expand Up @@ -380,11 +383,11 @@ export class NoteCreateService implements OnApplicationShutdown {

tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32);

if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply?.userId)) {
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply?.userId }));
}

if (data.visibility === 'specified') {
if (!isDraft && data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');

for (const u of data.visibleUsers) {
Expand All @@ -409,6 +412,8 @@ export class NoteCreateService implements OnApplicationShutdown {
() => { /* aborted, ignore this */ },
);

if (waitToPublish) await waitToPublish(note);

return note;
}

Expand Down Expand Up @@ -544,6 +549,7 @@ export class NoteCreateService implements OnApplicationShutdown {
isSensitive: MiUser['isSensitive'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
const meta = await this.metaService.fetch();
const isDraft = data.scheduledAt != null;

this.notesChart.update(note, true);
if (meta.enableChartsForRemoteUser || (user.host == null)) {
Expand All @@ -561,7 +567,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}

// ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') {
if (!isDraft && (data.visibility === 'public' || data.visibility === 'home')) {
this.hashtagService.updateHashtags(user, tags);
}

Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/entities/NoteEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export class NoteEntityService implements OnModuleInit {
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
event: note.hasEvent ? this.populateEvent(note) : undefined,
deleteAt: note.deleteAt?.toISOString() ?? undefined,
scheduledAt: note.scheduledAt == null ? undefined : typeof note.scheduledAt === 'string' ? note.scheduledAt : note.scheduledAt.toISOString(),

...(meId && Object.keys(note.reactions).length > 0 ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/Note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export class MiNote {
})
public deleteAt: Date | null;

@Column('timestamp with time zone', {
nullable: true,
})
public scheduledAt: Date | null;

@Index()
@Column({
...id(),
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/queue/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { MiUser } from '@/models/User.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { IActivity } from '@/core/activitypub/type.js';
import type { IPoll } from '@/models/Poll.js';
import type httpSignature from '@peertube/http-signature';

export type DeliverJobData = {
Expand Down Expand Up @@ -59,6 +60,7 @@ export type DbJobMap = {
importUserLists: DbUserImportJobData;
importCustomEmojis: DbUserImportJobData;
deleteAccount: DbUserDeleteJobData;
scheduledNotePost: DbScheduledNotePostData;
}

export type DbJobDataWithUser = {
Expand Down Expand Up @@ -97,6 +99,18 @@ export type DbUserImportToDbJobData = {
withReplies?: boolean;
};

export type DbScheduledNotePostData = {
user: ThinUser;
option: {
visibility: MiNote['visibility'];
visibleUserIds: MiUser['id'][] | null;
replyId?: MiNote['replyId'];
renoteId?: MiNote['renoteId'];
poll?: IPoll;
};
noteId: MiNote['id'];
}

export type DbAbuseReportJobData = MiAbuseUserReport;

export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>;
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/server/api/endpoints/notes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export const meta = {
code: 'CANNOT_SCHEDULE_DELETE_EARLIER_THAN_NOW',
id: '9f04994a-3aa2-11ef-a495-177eea74788f',
},

scheduledTimeIsPast: {
message: 'The scheduled time is past.',
code: 'SCHEDULED_TIME_IS_PAST',
id: 'd6ccda8e-5430-11ef-8876-0242ac120002',
},
},
} as const;

Expand Down Expand Up @@ -216,6 +222,7 @@ export const paramDef = {
deleteAfter: { type: 'integer', nullable: true, minimum: 1 },
},
},
scheduledAt: { type: 'integer', nullable: true },
},
// (re)note with text, files and poll are optional
if: {
Expand Down Expand Up @@ -373,8 +380,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.poll.expiresAt < Date.now()) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
}
if (ps.poll.expiresAt && ps.scheduledAt && ps.poll.expiresAt < ps.scheduledAt) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
}
} else if (typeof ps.poll.expiredAfter === 'number') {
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
if (ps.scheduledAt != null) {
ps.poll.expiresAt = ps.scheduledAt + ps.poll.expiredAfter;
} else {
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
}
}
}

Expand All @@ -388,6 +402,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}

let delay: number | null = null;
if (ps.scheduledAt) {
delay = ps.scheduledAt - Date.now();
if (delay < 0) {
delay = null;
throw new ApiError(meta.errors.scheduledTimeIsPast);
}
}

let channel: MiChannel | null = null;
if (ps.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
Expand Down Expand Up @@ -427,6 +450,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : ps.scheduledDelete?.deleteAfter ? new Date(Date.now() + ps.scheduledDelete.deleteAfter) : null,
scheduledAt: delay != null ? new Date(ps.scheduledAt!) : null,
});

return {
Expand Down
Loading