This commit is contained in:
syuilo 2025-09-26 03:50:28 +00:00 committed by GitHub
commit 4390ccd664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1128 additions and 486 deletions

View File

@ -4,6 +4,8 @@
- pnpm 10.16.0 が必要です - pnpm 10.16.0 が必要です
### General ### General
- Feat: 予約投稿ができるようになりました
- デフォルトで作成可能数は1になっています。適宜ロールのポリシーで設定を行ってください。
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました - Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
### Client ### Client

48
locales/index.d.ts vendored
View File

@ -5286,6 +5286,10 @@ export interface Locale extends ILocale {
* *
*/ */
"draft": string; "draft": string;
/**
* 稿
*/
"draftsAndScheduledNotes": string;
/** /**
* *
*/ */
@ -5553,6 +5557,26 @@ export interface Locale extends ILocale {
* *
*/ */
"createUserSpecifiedNote": string; "createUserSpecifiedNote": string;
/**
* 稿
*/
"schedulePost": string;
/**
* {x}稿
*/
"scheduleToPostOnX": ParameterizedString<"x">;
/**
* {x}稿
*/
"scheduledToPostOnX": ParameterizedString<"x">;
/**
*
*/
"schedule": string;
/**
*
*/
"scheduled": string;
"_compression": { "_compression": {
"_quality": { "_quality": {
/** /**
@ -7933,6 +7957,10 @@ export interface Locale extends ILocale {
* *
*/ */
"noteDraftLimit": string; "noteDraftLimit": string;
/**
* 稿
*/
"scheduledNoteLimit": string;
/** /**
* 使 * 使
*/ */
@ -10328,6 +10356,14 @@ export interface Locale extends ILocale {
* *
*/ */
"pollEnded": string; "pollEnded": string;
/**
* 稿
*/
"scheduledNotePosted": string;
/**
* 稿
*/
"scheduledNotePostFailed": string;
/** /**
* 稿 * 稿
*/ */
@ -12641,6 +12677,18 @@ export interface Locale extends ILocale {
* *
*/ */
"listDrafts": string; "listDrafts": string;
/**
* 稿
*/
"schedule": string;
/**
* 稿
*/
"listScheduledNotes": string;
/**
*
*/
"cancelSchedule": string;
}; };
/** /**
* *

View File

@ -1317,6 +1317,7 @@ acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
draft: "下書き" draft: "下書き"
draftsAndScheduledNotes: "下書きと予約投稿"
confirmOnReact: "リアクションする際に確認する" confirmOnReact: "リアクションする際に確認する"
reactAreYouSure: "\" {emoji} \" をリアクションしますか?" reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
@ -1383,6 +1384,11 @@ customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カ
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。" themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!" thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
createUserSpecifiedNote: "ユーザー指定ノートを作成" createUserSpecifiedNote: "ユーザー指定ノートを作成"
schedulePost: "投稿を予約"
scheduleToPostOnX: "{x}に投稿を予約します"
scheduledToPostOnX: "{x}に投稿が予約されています"
schedule: "予約"
scheduled: "予約"
_compression: _compression:
_quality: _quality:
@ -2056,6 +2062,7 @@ _role:
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
scheduledNoteLimit: "予約投稿の同時作成可能数"
watermarkAvailable: "ウォーターマーク機能の使用可否" watermarkAvailable: "ウォーターマーク機能の使用可否"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み" roleAssignedTo: "マニュアルロールにアサイン済み"
@ -2728,6 +2735,8 @@ _notification:
youReceivedFollowRequest: "フォローリクエストが来ました" youReceivedFollowRequest: "フォローリクエストが来ました"
yourFollowRequestAccepted: "フォローリクエストが承認されました" yourFollowRequestAccepted: "フォローリクエストが承認されました"
pollEnded: "アンケートの結果が出ました" pollEnded: "アンケートの結果が出ました"
scheduledNotePosted: "予約ノートが投稿されました"
scheduledNotePostFailed: "予約ノートの投稿に失敗しました"
newNote: "新しい投稿" newNote: "新しい投稿"
unreadAntennaNote: "アンテナ {name}" unreadAntennaNote: "アンテナ {name}"
roleAssigned: "ロールが付与されました" roleAssigned: "ロールが付与されました"
@ -3385,6 +3394,9 @@ _drafts:
restoreFromDraft: "下書きから復元" restoreFromDraft: "下書きから復元"
restore: "復元" restore: "復元"
listDrafts: "下書き一覧" listDrafts: "下書き一覧"
schedule: "投稿予約"
listScheduledNotes: "予約投稿一覧"
cancelSchedule: "予約解除"
qr: "二次元コード" qr: "二次元コード"
_qr: _qr:

View File

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ScheduledPost1758677617888 {
name = 'ScheduledPost1758677617888'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`);
}
}

View File

@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository) @Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private idService: IdService, private idService: IdService,
@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown {
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
} }
@bindThis
public async fetchAndCreate(user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
isCat: MiUser['isCat'];
}, data: {
createdAt: Date;
replyId: MiNote['id'] | null;
renoteId: MiNote['id'] | null;
fileIds: MiDriveFile['id'][];
text: string | null;
cw: string | null;
visibility: string;
visibleUserIds: MiUser['id'][];
channelId: MiChannel['id'] | null;
localOnly: boolean;
reactionAcceptance: MiNote['reactionAcceptance'];
poll: IPoll | null;
apMentions?: MinimumUser[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
}): Promise<MiNote> {
const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
id: In(data.visibleUserIds),
}) : [];
let files: MiDriveFile[] = [];
if (data.fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: user.id,
fileIds: data.fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds: data.fileIds })
.getMany();
if (files.length !== data.fileIds.length) {
throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
}
}
let renote: MiNote | null = null;
if (data.renoteId != null) {
// Fetch renote to note
renote = await this.notesRepository.findOne({
where: { id: data.renoteId },
relations: ['user', 'renote', 'reply'],
});
if (renote == null) {
throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
} else if (isRenote(renote) && !isQuote(renote)) {
throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
}
// Check blocking
if (renote.userId !== user.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: renote.userId,
blockeeId: user.id,
},
});
if (blockExist) {
throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
}
}
if (renote.visibility === 'followers' && renote.userId !== user.id) {
// 他人のfollowers noteはreject
throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
}
if (renote.channelId && renote.channelId !== data.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルが無い
throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
} else if (!renoteChannel.allowRenoteToExternal) {
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
}
}
}
let reply: MiNote | null = null;
if (data.replyId != null) {
// Fetch reply
reply = await this.notesRepository.findOne({
where: { id: data.replyId },
relations: ['user'],
});
if (reply == null) {
throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
} else if (isRenote(reply) && !isQuote(reply)) {
throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
} else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
} else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
}
// Check blocking
if (reply.userId !== user.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: user.id,
},
});
if (blockExist) {
throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
}
}
}
if (data.poll) {
if (data.poll.expiresAt != null) {
if (data.poll.expiresAt.getTime() < Date.now()) {
throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
}
}
}
let channel: MiChannel | null = null;
if (data.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
if (channel == null) {
throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
}
}
return this.create(user, {
createdAt: data.createdAt,
files: files,
poll: data.poll,
text: data.text,
reply,
renote,
cw: data.cw,
localOnly: data.localOnly,
reactionAcceptance: data.reactionAcceptance,
visibility: data.visibility,
visibleUsers,
channel,
apMentions: data.apMentions,
apHashtags: data.apHashtags,
apEmojis: data.apEmojis,
});
}
@bindThis @bindThis
public async create(user: { public async create(user: {
id: MiUser['id']; id: MiUser['id'];

View File

@ -5,32 +5,18 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import type { noteVisibilities, noteReactionAcceptances } from '@/types.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js'; import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { IPoll } from '@/models/Poll.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRenote, isQuote } from '@/misc/is-renote.js'; import { isRenote, isQuote } from '@/misc/is-renote.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { QueueService } from '@/core/QueueService.js';
export type NoteDraftOptions = { export type NoteDraftOptions = Omit<MiNoteDraft, 'id' | 'userId' | 'user' | 'reply' | 'renote' | 'channel'>;
replyId?: MiNote['id'] | null;
renoteId?: MiNote['id'] | null;
text?: string | null;
cw?: string | null;
localOnly?: boolean | null;
reactionAcceptance?: typeof noteReactionAcceptances[number];
visibility?: typeof noteVisibilities[number];
fileIds?: MiDriveFile['id'][];
visibleUserIds?: MiUser['id'][];
hashtag?: string;
channelId?: MiChannel['id'] | null;
poll?: (IPoll & { expiredAfter?: number | null }) | null;
};
@Injectable() @Injectable()
export class NoteDraftService { export class NoteDraftService {
@ -56,6 +42,7 @@ export class NoteDraftService {
private roleService: RoleService, private roleService: RoleService,
private idService: IdService, private idService: IdService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queueService: QueueService,
) { ) {
} }
@ -72,36 +59,43 @@ export class NoteDraftService {
@bindThis @bindThis
public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> { public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
//#region check draft limit //#region check draft limit
const policies = await this.roleService.getUserPolicies(me.id);
const currentCount = await this.noteDraftsRepository.countBy({ const currentCount = await this.noteDraftsRepository.countBy({
userId: me.id, userId: me.id,
}); });
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) { if (currentCount >= policies.noteDraftLimit) {
throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts'); throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
} }
if (data.isActuallyScheduled) {
const currentScheduledCount = await this.noteDraftsRepository.countBy({
userId: me.id,
isActuallyScheduled: true,
});
if (currentScheduledCount >= policies.scheduledNoteLimit) {
throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
}
}
//#endregion //#endregion
if (data.poll) { await this.validate(me, data);
if (typeof data.poll.expiresAt === 'number') {
if (data.poll.expiresAt < Date.now()) { const draft = await this.noteDraftsRepository.insertOne({
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); ...data,
} id: this.idService.gen(),
} else if (typeof data.poll.expiredAfter === 'number') { userId: me.id,
data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); });
}
if (draft.scheduledAt && draft.isActuallyScheduled) {
this.schedule(draft);
} }
const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
appliedDraft.id = this.idService.gen();
appliedDraft.userId = me.id;
const draft = this.noteDraftsRepository.save(appliedDraft);
return draft; return draft;
} }
@bindThis @bindThis
public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise<MiNoteDraft> { public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial<NoteDraftOptions>): Promise<MiNoteDraft> {
const draft = await this.noteDraftsRepository.findOneBy({ const draft = await this.noteDraftsRepository.findOneBy({
id: draftId, id: draftId,
userId: me.id, userId: me.id,
@ -111,19 +105,36 @@ export class NoteDraftService {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
} }
if (data.poll) { //#region check draft limit
if (typeof data.poll.expiresAt === 'number') { const policies = await this.roleService.getUserPolicies(me.id);
if (data.poll.expiresAt < Date.now()) {
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
} const currentScheduledCount = await this.noteDraftsRepository.countBy({
} else if (typeof data.poll.expiredAfter === 'number') { userId: me.id,
data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); isActuallyScheduled: true,
});
if (currentScheduledCount >= policies.scheduledNoteLimit) {
throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
} }
} }
//#endregion
const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data); await this.validate(me, data);
return await this.noteDraftsRepository.save(appliedDraft); const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
.set(data)
.where('id = :id', { id: draftId })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.clearSchedule(draftId).then(() => {
if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
this.schedule(updatedDraft);
}
});
return updatedDraft;
} }
@bindThis @bindThis
@ -138,6 +149,8 @@ export class NoteDraftService {
} }
await this.noteDraftsRepository.delete(draft.id); await this.noteDraftsRepository.delete(draft.id);
this.clearSchedule(draftId);
} }
@bindThis @bindThis
@ -154,27 +167,20 @@ export class NoteDraftService {
return draft; return draft;
} }
// 関連エンティティを取得し紐づける部分を共通化する
@bindThis @bindThis
public async checkAndSetDraftNoteOptions( public async validate(
me: MiLocalUser, me: MiLocalUser,
draft: MiNoteDraft, data: Partial<NoteDraftOptions>,
data: NoteDraftOptions, ): Promise<void> {
): Promise<MiNoteDraft> { if (data.pollExpiresAt != null) {
data.visibility ??= 'public'; if (data.pollExpiresAt.getTime() < Date.now()) {
data.localOnly ??= false; throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; }
if (data.channelId != null) {
data.visibility = 'public';
data.visibleUserIds = [];
data.localOnly = true;
} }
let appliedDraft = draft;
//#region visibleUsers //#region visibleUsers
let visibleUsers: MiUser[] = []; let visibleUsers: MiUser[] = [];
if (data.visibleUserIds != null) { if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
visibleUsers = await this.usersRepository.findBy({ visibleUsers = await this.usersRepository.findBy({
id: In(data.visibleUserIds), id: In(data.visibleUserIds),
}); });
@ -184,7 +190,7 @@ export class NoteDraftService {
//#region files //#region files
let files: MiDriveFile[] = []; let files: MiDriveFile[] = [];
const fileIds = data.fileIds ?? null; const fileIds = data.fileIds ?? null;
if (fileIds != null) { if (fileIds != null && fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file') files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', { .where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id, userId: me.id,
@ -288,27 +294,37 @@ export class NoteDraftService {
} }
} }
//#endregion //#endregion
}
appliedDraft = { @bindThis
...appliedDraft, public async schedule(draft: MiNoteDraft): Promise<void> {
visibility: data.visibility, if (!draft.isActuallyScheduled) return;
cw: data.cw ?? null, if (draft.scheduledAt == null) return;
fileIds: fileIds ?? [], if (draft.scheduledAt.getTime() <= Date.now()) return;
replyId: data.replyId ?? null,
renoteId: data.renoteId ?? null,
channelId: data.channelId ?? null,
text: data.text ?? null,
hashtag: data.hashtag ?? null,
hasPoll: data.poll != null,
pollChoices: data.poll ? data.poll.choices : [],
pollMultiple: data.poll ? data.poll.multiple : false,
pollExpiresAt: data.poll ? data.poll.expiresAt : null,
pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null,
visibleUserIds: data.visibleUserIds ?? [],
localOnly: data.localOnly,
reactionAcceptance: data.reactionAcceptance,
} satisfies MiNoteDraft;
return appliedDraft; const delay = draft.scheduledAt.getTime() - Date.now();
this.queueService.postScheduledNoteQueue.add(draft.id, {
noteDraftId: draft.id,
}, {
delay,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@bindThis
public async clearSchedule(draftId: MiNoteDraft['id']): Promise<void> {
const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
for (const job of jobs) {
if (job.data.noteDraftId === draftId) {
await job.remove();
}
}
} }
} }

View File

@ -16,11 +16,13 @@ import {
RelationshipJobData, RelationshipJobData,
UserWebhookDeliverJobData, UserWebhookDeliverJobData,
SystemWebhookDeliverJobData, SystemWebhookDeliverJobData,
PostScheduledNoteJobData,
} from '../queue/types.js'; } from '../queue/types.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
export type SystemQueue = Bull.Queue<Record<string, unknown>>; export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type PostScheduledNoteQueue = Bull.Queue<PostScheduledNoteJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>; export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>; export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue; export type DbQueue = Bull.Queue;
@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $postScheduledNote: Provider = {
provide: 'queue:postScheduledNote',
useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
inject: [DI.config],
};
const $deliver: Provider = { const $deliver: Provider = {
provide: 'queue:deliver', provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
providers: [ providers: [
$system, $system,
$endedPollNotification, $endedPollNotification,
$postScheduledNote,
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
exports: [ exports: [
$system, $system,
$endedPollNotification, $endedPollNotification,
$postScheduledNote,
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor( constructor(
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([ await Promise.all([
this.systemQueue.close(), this.systemQueue.close(),
this.endedPollNotificationQueue.close(), this.endedPollNotificationQueue.close(),
this.postScheduledNoteQueue.close(),
this.deliverQueue.close(), this.deliverQueue.close(),
this.inboxQueue.close(), this.inboxQueue.close(),
this.dbQueue.close(), this.dbQueue.close(),

View File

@ -31,6 +31,7 @@ import type {
DbQueue, DbQueue,
DeliverQueue, DeliverQueue,
EndedPollNotificationQueue, EndedPollNotificationQueue,
PostScheduledNoteQueue,
InboxQueue, InboxQueue,
ObjectStorageQueue, ObjectStorageQueue,
RelationshipQueue, RelationshipQueue,
@ -44,6 +45,7 @@ import type * as Bull from 'bullmq';
export const QUEUE_TYPES = [ export const QUEUE_TYPES = [
'system', 'system',
'endedPollNotification', 'endedPollNotification',
'postScheduledNote',
'deliver', 'deliver',
'inbox', 'inbox',
'db', 'db',
@ -92,6 +94,7 @@ export class QueueService {
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@ -717,6 +720,7 @@ export class QueueService {
switch (type) { switch (type) {
case 'system': return this.systemQueue; case 'system': return this.systemQueue;
case 'endedPollNotification': return this.endedPollNotificationQueue; case 'endedPollNotification': return this.endedPollNotificationQueue;
case 'postScheduledNote': return this.postScheduledNoteQueue;
case 'deliver': return this.deliverQueue; case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue; case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue; case 'db': return this.dbQueue;

View File

@ -69,6 +69,7 @@ export type RolePolicies = {
chatAvailability: 'available' | 'readonly' | 'unavailable'; chatAvailability: 'available' | 'readonly' | 'unavailable';
uploadableFileTypes: string[]; uploadableFileTypes: string[];
noteDraftLimit: number; noteDraftLimit: number;
scheduledNoteLimit: number;
watermarkAvailable: boolean; watermarkAvailable: boolean;
}; };
@ -116,6 +117,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
'audio/*', 'audio/*',
], ],
noteDraftLimit: 10, noteDraftLimit: 10,
scheduledNoteLimit: 1,
watermarkAvailable: true, watermarkAvailable: true,
}; };
@ -440,6 +442,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return [...set]; return [...set];
}), }),
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)), noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)), watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
}; };
} }

View File

@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit {
const packed: Packed<'NoteDraft'> = await awaitAll({ const packed: Packed<'NoteDraft'> = await awaitAll({
id: noteDraft.id, id: noteDraft.id,
createdAt: this.idService.parse(noteDraft.id).date.toISOString(), createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
scheduledAt: noteDraft.scheduledAt?.getTime() ?? null,
isActuallyScheduled: noteDraft.isActuallyScheduled,
userId: noteDraft.userId, userId: noteDraft.userId,
user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me), user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
text: text, text: text,
@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit {
visibility: noteDraft.visibility, visibility: noteDraft.visibility,
localOnly: noteDraft.localOnly, localOnly: noteDraft.localOnly,
reactionAcceptance: noteDraft.reactionAcceptance, reactionAcceptance: noteDraft.reactionAcceptance,
visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined, visibleUserIds: noteDraft.visibleUserIds,
hashtag: noteDraft.hashtag ?? undefined, hashtag: noteDraft.hashtag,
fileIds: noteDraft.fileIds, fileIds: noteDraft.fileIds,
files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds), files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds),
replyId: noteDraft.replyId, replyId: noteDraft.replyId,
renoteId: noteDraft.renoteId, renoteId: noteDraft.renoteId,
channelId: noteDraft.channelId ?? undefined, channelId: noteDraft.channelId,
channel: channel ? { channel: channel ? {
id: channel.id, id: channel.id,
name: channel.name, name: channel.name,
@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit {
allowRenoteToExternal: channel.allowRenoteToExternal, allowRenoteToExternal: channel.allowRenoteToExternal,
userId: channel.userId, userId: channel.userId,
} : undefined, } : undefined,
poll: noteDraft.hasPoll ? {
choices: noteDraft.pollChoices,
multiple: noteDraft.pollMultiple,
expiresAt: noteDraft.pollExpiresAt?.toISOString(),
expiredAfter: noteDraft.pollExpiredAfter,
} : null,
...(opts.detail ? { ...(opts.detail ? {
reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, { reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit {
detail: true, detail: true,
skipHide: opts.skipHide, skipHide: opts.skipHide,
})) : undefined, })) : undefined,
poll: noteDraft.hasPoll ? {
choices: noteDraft.pollChoices,
multiple: noteDraft.pollMultiple,
expiresAt: noteDraft.pollExpiresAt?.toISOString(),
expiredAfter: noteDraft.pollExpiredAfter,
} : undefined,
} : {} ), } : {} ),
}); });

View File

@ -21,7 +21,18 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.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() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {

View File

@ -126,7 +126,7 @@ export class MiNoteDraft {
@JoinColumn() @JoinColumn()
public channel: MiChannel | null; public channel: MiChannel | null;
// 以下、Pollについて追加 //#region 以下、Pollについて追加
@Column('boolean', { @Column('boolean', {
default: false, default: false,
@ -151,13 +151,15 @@ export class MiNoteDraft {
}) })
public pollExpiredAfter: number | null; public pollExpiredAfter: number | null;
// ここまで追加 //#endregion
constructor(data: Partial<MiNoteDraft>) { @Column('timestamp with time zone', {
if (data == null) return; nullable: true,
})
public scheduledAt: Date | null;
for (const [k, v] of Object.entries(data)) { @Column('boolean', {
(this as any)[k] = v; default: false,
} })
} public isActuallyScheduled: boolean;
} }

View File

@ -9,6 +9,7 @@ import { MiNote } from './Note.js';
import { MiAccessToken } from './AccessToken.js'; import { MiAccessToken } from './AccessToken.js';
import { MiRole } from './Role.js'; import { MiRole } from './Role.js';
import { MiDriveFile } from './DriveFile.js'; import { MiDriveFile } from './DriveFile.js';
import { MiNoteDraft } from './NoteDraft.js';
// misskey-js の notificationTypes と同期すべし // misskey-js の notificationTypes と同期すべし
export type MiNotification = { export type MiNotification = {
@ -60,6 +61,16 @@ export type MiNotification = {
createdAt: string; createdAt: string;
notifierId: MiUser['id']; notifierId: MiUser['id'];
noteId: MiNote['id']; noteId: MiNote['id'];
} | {
type: 'scheduledNotePosted';
id: string;
createdAt: string;
noteId: MiNote['id'];
} | {
type: 'scheduledNotePostFailed';
id: string;
createdAt: string;
noteDraftId: MiNoteDraft['id'];
} | { } | {
type: 'receiveFollowRequest'; type: 'receiveFollowRequest';
id: string; id: string;

View File

@ -23,7 +23,7 @@ export const packedNoteDraftSchema = {
}, },
cw: { cw: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: false, nullable: true,
}, },
userId: { userId: {
type: 'string', type: 'string',
@ -37,27 +37,23 @@ export const packedNoteDraftSchema = {
}, },
replyId: { replyId: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: false, nullable: true,
format: 'id', format: 'id',
example: 'xxxxxxxxxx',
}, },
renoteId: { renoteId: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: false, nullable: true,
format: 'id', format: 'id',
example: 'xxxxxxxxxx',
}, },
reply: { reply: {
type: 'object', type: 'object',
optional: true, nullable: true, optional: true, nullable: true,
ref: 'Note', ref: 'Note',
description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
}, },
renote: { renote: {
type: 'object', type: 'object',
optional: true, nullable: true, optional: true, nullable: true,
ref: 'Note', ref: 'Note',
description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
}, },
visibility: { visibility: {
type: 'string', type: 'string',
@ -66,7 +62,7 @@ export const packedNoteDraftSchema = {
}, },
visibleUserIds: { visibleUserIds: {
type: 'array', type: 'array',
optional: true, nullable: false, optional: false, nullable: false,
items: { items: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -75,7 +71,7 @@ export const packedNoteDraftSchema = {
}, },
fileIds: { fileIds: {
type: 'array', type: 'array',
optional: true, nullable: false, optional: false, nullable: false,
items: { items: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -93,11 +89,11 @@ export const packedNoteDraftSchema = {
}, },
hashtag: { hashtag: {
type: 'string', type: 'string',
optional: true, nullable: false, optional: false, nullable: true,
}, },
poll: { poll: {
type: 'object', type: 'object',
optional: true, nullable: true, optional: false, nullable: true,
properties: { properties: {
expiresAt: { expiresAt: {
type: 'string', type: 'string',
@ -124,9 +120,8 @@ export const packedNoteDraftSchema = {
}, },
channelId: { channelId: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: false, nullable: true,
format: 'id', format: 'id',
example: 'xxxxxxxxxx',
}, },
channel: { channel: {
type: 'object', type: 'object',
@ -160,12 +155,20 @@ export const packedNoteDraftSchema = {
}, },
localOnly: { localOnly: {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: false, nullable: false,
}, },
reactionAcceptance: { reactionAcceptance: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null], enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
}, },
scheduledAt: {
type: 'number',
optional: false, nullable: true,
},
isActuallyScheduled: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View File

@ -207,6 +207,36 @@ export const packedNotificationSchema = {
optional: false, nullable: false, 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: ['scheduledNotePostFailed'],
},
noteDraft: {
type: 'object',
ref: 'NoteDraft',
optional: false, nullable: false,
},
},
}, { }, {
type: 'object', type: 'object',
properties: { properties: {

View File

@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,
}, },
scheduledNoteLimit: {
type: 'integer',
optional: false, nullable: false,
},
watermarkAvailable: { watermarkAvailable: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = {
quote: { optional: true, ...notificationRecieveConfig }, quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig }, reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig }, pollEnded: { optional: true, ...notificationRecieveConfig },
scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig },

View File

@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js'; import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
UserWebhookDeliverProcessorService, UserWebhookDeliverProcessorService,
SystemWebhookDeliverProcessorService, SystemWebhookDeliverProcessorService,
EndedPollNotificationProcessorService, EndedPollNotificationProcessorService,
PostScheduledNoteProcessorService,
DeliverProcessorService, DeliverProcessorService,
InboxProcessorService, InboxProcessorService,
AggregateRetentionProcessorService, AggregateRetentionProcessorService,

View File

@ -14,6 +14,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
@ -85,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private relationshipQueueWorker: Bull.Worker; private relationshipQueueWorker: Bull.Worker;
private objectStorageQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker;
private endedPollNotificationQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker;
private postScheduledNoteQueueWorker: Bull.Worker;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -94,6 +96,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private postScheduledNoteProcessorService: PostScheduledNoteProcessorService,
private deliverProcessorService: DeliverProcessorService, private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService, private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@ -520,6 +523,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
}); });
} }
//#endregion //#endregion
//#region post scheduled note
{
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
if (this.config.sentryForBackend) {
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
} else {
return this.postScheduledNoteProcessorService.process(job);
}
}, {
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
autorun: false,
});
}
//#endregion
} }
@bindThis @bindThis
@ -534,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(), this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(), this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(), this.endedPollNotificationQueueWorker.run(),
this.postScheduledNoteQueueWorker.run(),
]); ]);
} }
@ -549,6 +568,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(), this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(), this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(), this.endedPollNotificationQueueWorker.close(),
this.postScheduledNoteQueueWorker.close(),
]); ]);
} }

View File

@ -12,6 +12,7 @@ export const QUEUE = {
INBOX: 'inbox', INBOX: 'inbox',
SYSTEM: 'system', SYSTEM: 'system',
ENDED_POLL_NOTIFICATION: 'endedPollNotification', ENDED_POLL_NOTIFICATION: 'endedPollNotification',
POST_SCHEDULED_NOTE: 'postScheduledNote',
DB: 'db', DB: 'db',
RELATIONSHIP: 'relationship', RELATIONSHIP: 'relationship',
OBJECT_STORAGE: 'objectStorage', OBJECT_STORAGE: 'objectStorage',

View File

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { NoteDraftsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { PostScheduledNoteJobData } from '../types.js';
@Injectable()
export class PostScheduledNoteProcessorService {
private logger: Logger;
constructor(
@Inject(DI.noteDraftsRepository)
private noteDraftsRepository: NoteDraftsRepository,
private noteCreateService: NoteCreateService,
private notificationService: NotificationService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note');
}
@bindThis
public async process(job: Bull.Job<PostScheduledNoteJobData>): Promise<void> {
const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] });
if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) {
return;
}
try {
const note = await this.noteCreateService.fetchAndCreate(draft.user, {
createdAt: new Date(),
fileIds: draft.fileIds,
poll: draft.hasPoll ? {
choices: draft.pollChoices,
multiple: draft.pollMultiple,
expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null,
} : null,
text: draft.text ?? null,
replyId: draft.replyId,
renoteId: draft.renoteId,
cw: draft.cw,
localOnly: draft.localOnly,
reactionAcceptance: draft.reactionAcceptance,
visibility: draft.visibility,
visibleUserIds: draft.visibleUserIds,
channelId: draft.channelId,
});
// await不要
this.noteDraftsRepository.remove(draft);
// await不要
this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', {
noteId: note.id,
});
} catch (err) {
this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', {
noteDraftId: draft.id,
});
}
}
}

View File

@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = {
noteId: MiNote['id']; noteId: MiNote['id'];
}; };
export type PostScheduledNoteJobData = {
noteDraftId: string;
};
export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = { export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = {
type: T; type: T;
content: SystemWebhookPayload<T>; content: SystemWebhookPayload<T>;

View File

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -49,6 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,

View File

@ -103,6 +103,8 @@ export const meta = {
quote: { optional: true, ...notificationRecieveConfig }, quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig }, reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig }, pollEnded: { optional: true, ...notificationRecieveConfig },
scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig },

View File

@ -209,6 +209,8 @@ export const paramDef = {
quote: notificationRecieveConfig, quote: notificationRecieveConfig,
reaction: notificationRecieveConfig, reaction: notificationRecieveConfig,
pollEnded: notificationRecieveConfig, pollEnded: notificationRecieveConfig,
scheduledNotePosted: notificationRecieveConfig,
scheduledNotePostFailed: notificationRecieveConfig,
receiveFollowRequest: notificationRecieveConfig, receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig, followRequestAccepted: notificationRecieveConfig,
roleAssigned: notificationRecieveConfig, roleAssigned: notificationRecieveConfig,

View File

@ -6,17 +6,10 @@
import ms from 'ms'; 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 { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiChannel } from '@/models/Channel.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; 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 { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -223,168 +216,28 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let visibleUsers: MiUser[] = [];
if (ps.visibleUserIds) {
visibleUsers = await this.usersRepository.findBy({
id: In(ps.visibleUserIds),
});
}
let files: MiDriveFile[] = [];
const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
if (fileIds != null) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchFile);
}
}
let renote: MiNote | null = null;
if (ps.renoteId != null) {
// Fetch renote to note
renote = await this.notesRepository.findOne({
where: { id: ps.renoteId },
relations: ['user', 'renote', 'reply'],
});
if (renote == null) {
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (isRenote(renote) && !isQuote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
}
// Check blocking
if (renote.userId !== me.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: renote.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
if (renote.visibility === 'followers' && renote.userId !== me.id) {
// 他人のfollowers noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
if (renote.channelId && renote.channelId !== ps.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルが無い
throw new ApiError(meta.errors.noSuchChannel);
} else if (!renoteChannel.allowRenoteToExternal) {
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
}
}
}
let reply: MiNote | null = null;
if (ps.replyId != null) {
// Fetch reply
reply = await this.notesRepository.findOne({
where: { id: ps.replyId },
relations: ['user'],
});
if (reply == null) {
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
}
// Check blocking
if (reply.userId !== me.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
}
if (ps.poll) {
if (typeof ps.poll.expiresAt === 'number') {
if (ps.poll.expiresAt < Date.now()) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
}
} else if (typeof ps.poll.expiredAfter === 'number') {
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
}
}
let channel: MiChannel | null = null;
if (ps.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
}
}
// 投稿を作成
try { try {
const note = await this.noteCreateService.create(me, { const note = await this.noteCreateService.fetchAndCreate(me, {
createdAt: new Date(), createdAt: new Date(),
files: files, fileIds: ps.fileIds ?? ps.mediaIds ?? [],
poll: ps.poll ? { poll: ps.poll ? {
choices: ps.poll.choices, choices: ps.poll.choices,
multiple: ps.poll.multiple ?? false, multiple: ps.poll.multiple ?? false,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
} : undefined, } : null,
text: ps.text ?? undefined, text: ps.text ?? null,
reply, replyId: ps.replyId ?? null,
renote, renoteId: ps.renoteId ?? null,
cw: ps.cw, cw: ps.cw ?? null,
localOnly: ps.localOnly, localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance, reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility, visibility: ps.visibility,
visibleUsers, visibleUserIds: ps.visibleUserIds ?? [],
channel, channelId: ps.channelId ?? null,
apMentions: ps.noExtractMentions ? [] : undefined, apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined,
@ -393,16 +246,46 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return { return {
createdNote: await this.noteEntityService.pack(note, me), createdNote: await this.noteEntityService.pack(note, me),
}; };
} catch (e) { } catch (err) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof IdentifiableError) { if (err instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') { if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords); throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { } else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions); throw new ApiError(meta.errors.containsTooManyMentions);
} else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') {
throw new ApiError(meta.errors.noSuchFile);
} else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') {
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') {
throw new ApiError(meta.errors.cannotReRenote);
} else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') {
throw new ApiError(meta.errors.youHaveBeenBlocked);
} else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') {
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') {
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') {
throw new ApiError(meta.errors.noSuchChannel);
} else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') {
throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
} else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') {
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
} else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') {
throw new ApiError(meta.errors.youHaveBeenBlocked);
} else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
} else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') {
throw new ApiError(meta.errors.noSuchChannel);
} }
} }
throw e; throw err;
} }
}); });
} }

View File

@ -124,6 +124,12 @@ export const meta = {
id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
}, },
tooManyScheduledNotes: {
message: 'You cannot create scheduled notes any more.',
code: 'TOO_MANY_SCHEDULED_NOTES',
id: '22ae69eb-09e3-4541-a850-773cfa45e693',
},
cannotRenoteToExternal: { cannotRenoteToExternal: {
message: 'Cannot Renote to External.', message: 'Cannot Renote to External.',
code: 'CANNOT_RENOTE_TO_EXTERNAL', code: 'CANNOT_RENOTE_TO_EXTERNAL',
@ -162,7 +168,7 @@ export const paramDef = {
fileIds: { fileIds: {
type: 'array', type: 'array',
uniqueItems: true, uniqueItems: true,
minItems: 1, minItems: 0,
maxItems: 16, maxItems: 16,
items: { type: 'string', format: 'misskey:id' }, items: { type: 'string', format: 'misskey:id' },
}, },
@ -183,8 +189,10 @@ export const paramDef = {
}, },
required: ['choices'], required: ['choices'],
}, },
scheduledAt: { type: 'integer', nullable: true },
isActuallyScheduled: { type: 'boolean', default: false },
}, },
required: [], required: ['visibility', 'visibleUserIds', 'cw', 'hashtag', 'localOnly', 'reactionAcceptance', 'replyId', 'renoteId', 'channelId', 'text', 'fileIds', 'poll', 'scheduledAt', 'isActuallyScheduled'],
} as const; } as const;
@Injectable() @Injectable()
@ -196,22 +204,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.create(me, { const draft = await this.noteDraftService.create(me, {
fileIds: ps.fileIds, fileIds: ps.fileIds,
poll: ps.poll ? { pollChoices: ps.poll?.choices ?? [],
choices: ps.poll.choices, pollMultiple: ps.poll?.multiple ?? false,
multiple: ps.poll.multiple ?? false, pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, pollExpiredAfter: ps.poll?.expiredAfter ?? null,
expiredAfter: ps.poll.expiredAfter ?? null, hasPoll: ps.poll != null,
} : undefined, text: ps.text,
text: ps.text ?? null, replyId: ps.replyId,
replyId: ps.replyId ?? undefined, renoteId: ps.renoteId,
renoteId: ps.renoteId ?? undefined, cw: ps.cw,
cw: ps.cw ?? null, hashtag: ps.hashtag,
...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
localOnly: ps.localOnly, localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance, reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility, visibility: ps.visibility,
visibleUserIds: ps.visibleUserIds ?? [], visibleUserIds: ps.visibleUserIds,
channelId: ps.channelId ?? undefined, channelId: ps.channelId,
scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => { }).catch((err) => {
if (err instanceof IdentifiableError) { if (err instanceof IdentifiableError) {
switch (err.id) { switch (err.id) {
@ -241,6 +250,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotReplyToInvisibleNote); throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
case '215dbc76-336c-4d2a-9605-95766ba7dab0': case '215dbc76-336c-4d2a-9605-95766ba7dab0':
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
throw new ApiError(meta.errors.tooManyScheduledNotes);
default: default:
throw err; throw err;
} }

View File

@ -41,6 +41,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
scheduled: { type: 'boolean', nullable: true },
}, },
required: [], required: [],
} as const; } as const;
@ -58,6 +59,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('drafts.userId = :meId', { meId: me.id }); .andWhere('drafts.userId = :meId', { meId: me.id });
if (ps.scheduled === true) {
query.andWhere('drafts.isActuallyScheduled = true');
} else if (ps.scheduled === false) {
query.andWhere('drafts.isActuallyScheduled = false');
}
const drafts = await query const drafts = await query
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();

View File

@ -159,6 +159,12 @@ export const meta = {
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: '215dbc76-336c-4d2a-9605-95766ba7dab0', id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
}, },
tooManyScheduledNotes: {
message: 'You cannot create scheduled notes any more.',
code: 'TOO_MANY_SCHEDULED_NOTES',
id: '02f5df79-08ae-4a33-8524-f1503c8f6212',
},
}, },
limit: { limit: {
@ -171,14 +177,14 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
draftId: { type: 'string', nullable: false, format: 'misskey:id' }, draftId: { type: 'string', nullable: false, format: 'misskey:id' },
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
visibleUserIds: { type: 'array', uniqueItems: true, items: { visibleUserIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id', type: 'string', format: 'misskey:id',
} }, } },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
hashtag: { type: 'string', nullable: true, maxLength: 200 }, hashtag: { type: 'string', nullable: true, maxLength: 200 },
localOnly: { type: 'boolean', default: false }, localOnly: { type: 'boolean' },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] },
replyId: { type: 'string', format: 'misskey:id', nullable: true }, replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true }, renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true }, channelId: { type: 'string', format: 'misskey:id', nullable: true },
@ -194,7 +200,7 @@ export const paramDef = {
fileIds: { fileIds: {
type: 'array', type: 'array',
uniqueItems: true, uniqueItems: true,
minItems: 1, minItems: 0,
maxItems: 16, maxItems: 16,
items: { type: 'string', format: 'misskey:id' }, items: { type: 'string', format: 'misskey:id' },
}, },
@ -215,6 +221,8 @@ export const paramDef = {
}, },
required: ['choices'], required: ['choices'],
}, },
scheduledAt: { type: 'integer', nullable: true },
isActuallyScheduled: { type: 'boolean' },
}, },
required: ['draftId'], required: ['draftId'],
} as const; } as const;
@ -228,22 +236,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.update(me, ps.draftId, { const draft = await this.noteDraftService.update(me, ps.draftId, {
fileIds: ps.fileIds, fileIds: ps.fileIds,
poll: ps.poll ? { pollChoices: ps.poll?.choices,
choices: ps.poll.choices, pollMultiple: ps.poll?.multiple,
multiple: ps.poll.multiple ?? false, pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, pollExpiredAfter: ps.poll?.expiredAfter,
expiredAfter: ps.poll.expiredAfter ?? null, text: ps.text,
} : undefined, replyId: ps.replyId,
text: ps.text ?? null, renoteId: ps.renoteId,
replyId: ps.replyId ?? undefined, cw: ps.cw,
renoteId: ps.renoteId ?? undefined, hashtag: ps.hashtag,
cw: ps.cw ?? null,
...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
localOnly: ps.localOnly, localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance, reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility, visibility: ps.visibility,
visibleUserIds: ps.visibleUserIds ?? [], visibleUserIds: ps.visibleUserIds,
channelId: ps.channelId ?? undefined, channelId: ps.channelId,
scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => { }).catch((err) => {
if (err instanceof IdentifiableError) { if (err instanceof IdentifiableError) {
switch (err.id) { switch (err.id) {
@ -285,6 +293,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords); throw new ApiError(meta.errors.containsProhibitedWords);
case '4de0363a-3046-481b-9b0f-feff3e211025': case '4de0363a-3046-481b-9b0f-feff3e211025':
throw new ApiError(meta.errors.containsTooManyMentions); throw new ApiError(meta.errors.containsTooManyMentions);
case 'bacdf856-5c51-4159-b88a-804fa5103be5':
throw new ApiError(meta.errors.tooManyScheduledNotes);
default: default:
throw err; throw err;
} }

View File

@ -12,6 +12,8 @@
* quote - 稿Renoteされた * quote - 稿Renoteされた
* reaction - 稿 * reaction - 稿
* pollEnded - * pollEnded -
* scheduledNotePosted - 稿
* scheduledNotePostFailed - 稿
* receiveFollowRequest - * receiveFollowRequest -
* followRequestAccepted - * followRequestAccepted -
* roleAssigned - * roleAssigned -
@ -32,6 +34,8 @@ export const notificationTypes = [
'quote', 'quote',
'reaction', 'reaction',
'pollEnded', 'pollEnded',
'scheduledNotePosted',
'scheduledNotePostFailed',
'receiveFollowRequest', 'receiveFollowRequest',
'followRequestAccepted', 'followRequestAccepted',
'roleAssigned', 'roleAssigned',

View File

@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only
@esc="cancel()" @esc="cancel()"
> >
<template #header> <template #header>
{{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }}) {{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
</template> </template>
<div class="_spacer">
<MkPagination :paginator="paginator" withControl>
<template #empty>
<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
</template>
<template #default="{ items }"> <MkStickyContainer>
<div class="_gaps_s"> <template #header>
<div <MkTabs
v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])" v-model:tab="tab"
:key="draft.id" centered
v-panel :class="$style.tabs"
:class="[$style.draft]" :tabs="[
> {
<div :class="$style.draftBody" class="_gaps_s"> key: 'drafts',
<div :class="$style.draftInfo"> title: i18n.ts.drafts,
<div :class="$style.draftMeta"> icon: 'ti ti-pencil-question',
<div v-if="draft.reply" class="_nowrap"> },
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> {
<template #user> key: 'scheduled',
<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/> title: i18n.ts.scheduled,
<MkAcct v-else :user="draft.reply.user"/> icon: 'ti ti-calendar-clock',
</template> },
</I18n> ]"
</div> />
<div v-else-if="draft.replyId" class="_nowrap"> </template>
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
<template #user> <div class="_spacer">
{{ i18n.ts.deletedNote }} <MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl>
</template> <template #empty>
</I18n> <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
</div> </template>
<div v-if="draft.renote && draft.text != null" class="_nowrap">
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> <template #default="{ items }">
<template #user> <div class="_gaps_s">
<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/> <div
<MkAcct v-else :user="draft.renote.user"/> v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
</template> :key="draft.id"
</I18n> v-panel
</div> :class="[$style.draft]"
<div v-else-if="draft.renoteId" class="_nowrap"> >
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> <div :class="$style.draftBody" class="_gaps_s">
<template #user> <MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
{{ i18n.ts.deletedNote }} <I18n :src="i18n.ts.scheduledToPostOnX" tag="span">
</template> <template #x>
</I18n> <MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
</div> </template>
<div v-if="draft.channel" class="_nowrap"> </I18n>
<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }} </MkInfo>
<div :class="$style.draftInfo">
<div :class="$style.draftMeta">
<div v-if="draft.reply" class="_nowrap">
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
<template #user>
<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
<MkAcct v-else :user="draft.reply.user"/>
</template>
</I18n>
</div>
<div v-else-if="draft.replyId" class="_nowrap">
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
<template #user>
{{ i18n.ts.deletedNote }}
</template>
</I18n>
</div>
<div v-if="draft.renote && draft.text != null" class="_nowrap">
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
<template #user>
<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
<MkAcct v-else :user="draft.renote.user"/>
</template>
</I18n>
</div>
<div v-else-if="draft.renoteId" class="_nowrap">
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
<template #user>
{{ i18n.ts.deletedNote }}
</template>
</I18n>
</div>
<div v-if="draft.channel" class="_nowrap">
<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
</div>
</div> </div>
</div> </div>
</div> <div :class="$style.draftContent">
<div :class="$style.draftContent"> <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
<Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/> </div>
</div> <div :class="$style.draftFooter">
<div :class="$style.draftFooter"> <div :class="$style.draftVisibility">
<div :class="$style.draftVisibility"> <span :title="i18n.ts._visibility[draft.visibility]">
<span :title="i18n.ts._visibility[draft.visibility]"> <i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
<i v-if="draft.visibility === 'public'" class="ti ti-world"></i> <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i> <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
<i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i> </span>
</span> <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div>
<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
</div> </div>
<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
</div> </div>
</div>
<div :class="$style.draftActions" class="_buttons"> <div :class="$style.draftActions" class="_buttons">
<MkButton <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
:class="$style.itemButton" <MkButton
small :class="$style.itemButton"
@click="restoreDraft(draft)" small
> @click="cancelSchedule(draft)"
<i class="ti ti-corner-up-left"></i> >
{{ i18n.ts._drafts.restore }} <i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }}
</MkButton> </MkButton>
<MkButton <!-- TODO
v-tooltip="i18n.ts._drafts.delete" <MkButton
danger :class="$style.itemButton"
small small
:iconOnly="true" @click="reSchedule(draft)"
:class="$style.itemButton" >
@click="deleteDraft(draft)" <i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }}
> </MkButton>
<i class="ti ti-trash"></i> -->
</MkButton> </template>
<MkButton
v-else
:class="$style.itemButton"
small
@click="restoreDraft(draft)"
>
<i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }}
</MkButton>
<MkButton
v-tooltip="i18n.ts._drafts.delete"
danger
small
:iconOnly="true"
:class="$style.itemButton"
style="margin-left: auto;"
@click="deleteDraft(draft)"
>
<i class="ti ti-trash"></i>
</MkButton>
</div>
</div> </div>
</div> </div>
</div> </template>
</template> </MkPagination>
</MkPagination> </div>
</div> </MkStickyContainer>
</MkModalWindow> </MkModalWindow>
</template> </template>
@ -125,6 +175,12 @@ import * as os from '@/os.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api'; import { misskeyApi } from '@/utility/misskey-api';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
import MkTabs from '@/components/MkTabs.vue';
import MkInfo from '@/components/MkInfo.vue';
const props = defineProps<{
scheduled?: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'restore', draft: Misskey.entities.NoteDraft): void; (ev: 'restore', draft: Misskey.entities.NoteDraft): void;
@ -132,8 +188,20 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const paginator = markRaw(new Paginator('notes/drafts/list', { const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts');
const draftsPaginator = markRaw(new Paginator('notes/drafts/list', {
limit: 10, limit: 10,
params: {
scheduled: false,
},
}));
const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', {
limit: 10,
params: {
scheduled: true,
},
})); }));
const currentDraftsCount = ref(0); const currentDraftsCount = ref(0);
@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
if (canceled) return; if (canceled) return;
os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => { os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
paginator.reload(); draftsPaginator.reload();
});
}
async function cancelSchedule(draft: Misskey.entities.NoteDraft) {
os.apiWithDialog('notes/drafts/update', {
draftId: draft.id,
isActuallyScheduled: false,
scheduledAt: null,
}).then(() => {
scheduledPaginator.reload();
}); });
} }
</script> </script>
@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
padding-top: 16px; padding-top: 16px;
border-top: solid 1px var(--MI_THEME-divider); border-top: solid 1px var(--MI_THEME-divider);
} }
.tabs {
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
</style> </style>

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.head"> <div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_mention]: notification.type === 'mention', [$style.t_mention]: notification.type === 'mention',
[$style.t_quote]: notification.type === 'quote', [$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
[$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed',
[$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_login]: notification.type === 'login', [$style.t_login]: notification.type === 'login',
@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
<i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tail"> <div :class="$style.tail">
<header :class="$style.header"> <header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
<span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span>
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span> <span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span>
@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i> <i class="ti ti-quote" :class="$style.quote"></i>
</MkA> </MkA>
<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
{{ notification.role.name }} {{ notification.role.name }}
</div> </div>
@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none; pointer-events: none;
} }
.t_scheduledNotePosted {
background: var(--eventOther);
pointer-events: none;
}
.t_scheduledNotePostFailed {
background: var(--eventOther);
pointer-events: none;
}
.t_achievementEarned { .t_achievementEarned {
background: var(--eventAchievement); background: var(--eventAchievement);
pointer-events: none; pointer-events: none;

View File

@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.headerLeft"> <div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu"> <button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/> <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button> </button>
<button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button> <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button>
</div> </div>
<div :class="$style.headerRight"> <div :class="$style.headerRight">
<template v-if="!(targetChannel != null && fixed)"> <template v-if="!(targetChannel != null && fixed)">
@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="posted"></template> <template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template> <template v-else-if="posting"><MkEllipsis/></template>
<template v-else>{{ submitText }}</template> <template v-else>{{ submitText }}</template>
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i> <i style="margin-left: 6px;" :class="submitIcon"></i>
</div> </div>
</button> </button>
</div> </div>
@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div> </div>
</div> </div>
<MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt">
<I18n :src="i18n.ts.scheduleToPostOnX" tag="span">
<template #x>
<MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
</template>
</I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button>
</MkInfo>
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<div v-show="useCw" :class="$style.cwOuter"> <div v-show="useCw" :class="$style.cwOuter">
<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
@ -199,6 +206,7 @@ if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
} }
const reactionAcceptance = ref(store.s.reactionAcceptance); const reactionAcceptance = ref(store.s.reactionAcceptance);
const scheduledAt = ref<number | null>(null);
const draghover = ref(false); const draghover = ref(false);
const quoteId = ref<string | null>(null); const quoteId = ref<string | null>(null);
const hasNotSpecifiedMentions = ref(false); const hasNotSpecifiedMentions = ref(false);
@ -262,11 +270,17 @@ const placeholder = computed((): string => {
}); });
const submitText = computed((): string => { const submitText = computed((): string => {
return renoteTargetNote.value return scheduledAt.value != null
? i18n.ts.quote ? i18n.ts.schedule
: replyTargetNote.value : renoteTargetNote.value
? i18n.ts.reply ? i18n.ts.quote
: i18n.ts.note; : replyTargetNote.value
? i18n.ts.reply
: i18n.ts.note;
});
const submitIcon = computed((): string => {
return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send';
}); });
const textLength = computed((): number => { const textLength = computed((): number => {
@ -414,6 +428,7 @@ function watchForDraft() {
watch(localOnly, () => saveDraft()); watch(localOnly, () => saveDraft());
watch(quoteId, () => saveDraft()); watch(quoteId, () => saveDraft());
watch(reactionAcceptance, () => saveDraft()); watch(reactionAcceptance, () => saveDraft());
watch(scheduledAt, () => saveDraft());
} }
function checkMissingMention() { function checkMissingMention() {
@ -605,7 +620,13 @@ function showOtherSettings() {
action: () => { action: () => {
toggleReactionAcceptance(); toggleReactionAcceptance();
}, },
}, { type: 'divider' }, { }, ...($i.policies.scheduledNoteLimit > 0 ? [{
icon: 'ti ti-calendar-time',
text: i18n.ts.schedulePost + '...',
action: () => {
schedule();
},
}] : []), { type: 'divider' }, {
type: 'switch', type: 'switch',
icon: 'ti ti-eye', icon: 'ti ti-eye',
text: i18n.ts.preview, text: i18n.ts.preview,
@ -654,6 +675,7 @@ function clear() {
files.value = []; files.value = [];
poll.value = null; poll.value = null;
quoteId.value = null; quoteId.value = null;
scheduledAt.value = null;
} }
function onKeydown(ev: KeyboardEvent) { function onKeydown(ev: KeyboardEvent) {
@ -809,6 +831,7 @@ function saveDraft() {
...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), ...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
quoteId: quoteId.value, quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
scheduledAt: scheduledAt.value,
}, },
}; };
@ -823,7 +846,9 @@ function deleteDraft() {
miLocalStorage.setItem('drafts', JSON.stringify(draftData)); miLocalStorage.setItem('drafts', JSON.stringify(draftData));
} }
async function saveServerDraft(clearLocal = false) { async function saveServerDraft(options: {
isActuallyScheduled?: boolean;
} = {}) {
return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', { return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }), ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
text: text.value, text: text.value,
@ -831,19 +856,15 @@ async function saveServerDraft(clearLocal = false) {
visibility: visibility.value, visibility: visibility.value,
localOnly: localOnly.value, localOnly: localOnly.value,
hashtag: hashtags.value, hashtag: hashtags.value,
...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}), fileIds: files.value.map(f => f.id),
poll: poll.value, poll: poll.value,
...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), visibleUserIds: visibleUsers.value.map(x => x.id),
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined, renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null,
replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined, replyId: replyTargetNote.value ? replyTargetNote.value.id : null,
channelId: targetChannel.value ? targetChannel.value.id : undefined, channelId: targetChannel.value ? targetChannel.value.id : null,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
}).then(() => { scheduledAt: scheduledAt.value,
if (clearLocal) { isActuallyScheduled: options.isActuallyScheduled ?? false,
clear();
deleteDraft();
}
}).catch((err) => {
}); });
} }
@ -878,6 +899,21 @@ async function post(ev?: MouseEvent) {
} }
} }
if (scheduledAt.value != null) {
if (uploader.items.value.some(x => x.uploaded == null)) {
await uploadFiles();
//
if (uploader.items.value.some(x => x.uploaded == null)) {
return;
}
}
await postAsScheduled();
clear();
return;
}
if (props.mock) return; if (props.mock) return;
if (visibility.value === 'public' && ( if (visibility.value === 'public' && (
@ -1049,6 +1085,14 @@ async function post(ev?: MouseEvent) {
}); });
} }
async function postAsScheduled() {
if (props.mock) return;
await saveServerDraft({
isActuallyScheduled: true,
});
}
function cancel() { function cancel() {
emit('cancel'); emit('cancel');
} }
@ -1143,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
} }
function showDraftMenu(ev: MouseEvent) { function showDraftMenu(ev: MouseEvent) {
function showDraftsDialog() { function showDraftsDialog(scheduled: boolean) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {
scheduled,
}, {
restore: async (draft: Misskey.entities.NoteDraft) => { restore: async (draft: Misskey.entities.NoteDraft) => {
text.value = draft.text ?? ''; text.value = draft.text ?? '';
useCw.value = draft.cw != null; useCw.value = draft.cw != null;
@ -1175,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) {
renoteTargetNote.value = draft.renote; renoteTargetNote.value = draft.renote;
replyTargetNote.value = draft.reply; replyTargetNote.value = draft.reply;
reactionAcceptance.value = draft.reactionAcceptance; reactionAcceptance.value = draft.reactionAcceptance;
scheduledAt.value = draft.scheduledAt ?? null;
if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel; if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
visibleUsers.value = []; visibleUsers.value = [];
@ -1215,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) {
text: i18n.ts._drafts.listDrafts, text: i18n.ts._drafts.listDrafts,
icon: 'ti ti-cloud-download', icon: 'ti ti-cloud-download',
action: () => { action: () => {
showDraftsDialog(); showDraftsDialog(false);
},
}, { type: 'divider' }, {
type: 'button',
text: i18n.ts._drafts.listScheduledNotes,
icon: 'ti ti-clock-down',
action: () => {
showDraftsDialog(true);
}, },
}], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); }], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
} }
async function schedule() {
const { canceled, result } = await os.inputDatetime({
title: i18n.ts.schedulePost,
});
if (canceled) return;
if (result.getTime() <= Date.now()) return;
scheduledAt.value = result.getTime();
}
function cancelSchedule() {
scheduledAt.value = null;
}
onMounted(() => { onMounted(() => {
if (props.autofocus) { if (props.autofocus) {
focus(); focus();
@ -1255,6 +1323,7 @@ onMounted(() => {
} }
quoteId.value = draft.data.quoteId; quoteId.value = draft.data.quoteId;
reactionAcceptance.value = draft.data.reactionAcceptance; reactionAcceptance.value = draft.data.reactionAcceptance;
scheduledAt.value = draft.data.scheduledAt ?? null;
} }
} }
@ -1519,6 +1588,10 @@ html[data-color-scheme=light] .preview {
margin: 0 20px 16px 20px; margin: 0 20px 16px 20px;
} }
.scheduledAt {
margin: 0 20px 16px 20px;
}
.cw, .cw,
.hashtags, .hashtags,
.text { .text {

View File

@ -76,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints>(
} else if (err.code === 'ROLE_PERMISSION_DENIED') { } else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError; title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription; text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) { } else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する
title = i18n.ts.youCannotCreateAnymore; title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`; text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) { } else if (err.message.startsWith('Unexpected token')) {
@ -460,7 +460,7 @@ export function inputNumber(props: {
}); });
} }
export function inputDate(props: { export function inputDatetime(props: {
title?: string; title?: string;
text?: string; text?: string;
placeholder?: string | null; placeholder?: string | null;
@ -475,13 +475,13 @@ export function inputDate(props: {
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
type: 'date', type: 'datetime-local',
placeholder: props.placeholder, placeholder: props.placeholder,
default: props.default ?? null, default: props.default ?? null,
}, },
}, { }, {
done: result => { done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
}, },
closed: () => dispose(), closed: () => dispose(),
}); });

View File

@ -802,6 +802,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
<template #suffix>
<span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduledNoteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
<template #suffix> <template #suffix>
@ -831,6 +850,7 @@ import { watch, ref, computed } from 'vue';
import { throttle } from 'throttle-debounce'; import { throttle } from 'throttle-debounce';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import RolesEditorFormula from './RolesEditorFormula.vue'; import RolesEditorFormula from './RolesEditorFormula.vue';
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue'; import MkColorInput from '@/components/MkColorInput.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
@ -842,7 +862,6 @@ import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', v: any): void; (ev: 'update:modelValue', v: any): void;

View File

@ -304,6 +304,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
<template #suffix>{{ policies.scheduledNoteLimit }}</template>
<MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
<template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template> <template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@ -3226,7 +3226,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"]; export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
// @public (undocumented) // @public (undocumented)
export function nyaize(text: string): string; export function nyaize(text: string): string;
@ -3339,7 +3339,7 @@ type QueueStats = {
type QueueStatsLog = QueueStats[]; type QueueStatsLog = QueueStats[];
// @public (undocumented) // @public (undocumented)
export const queueTypes: readonly ["system", "endedPollNotification", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"]; export const queueTypes: readonly ["system", "endedPollNotification", "postScheduledNote", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"];
// @public (undocumented) // @public (undocumented)
type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json']; type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json'];
@ -3441,7 +3441,7 @@ type RoleLite = components['schemas']['RoleLite'];
type RolePolicies = components['schemas']['RolePolicies']; type RolePolicies = components['schemas']['RolePolicies'];
// @public (undocumented) // @public (undocumented)
export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "watermarkAvailable"]; export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "scheduledNoteLimit", "watermarkAvailable"];
// @public (undocumented) // @public (undocumented)
type RolesListResponse = operations['roles___list']['responses']['200']['content']['application/json']; type RolesListResponse = operations['roles___list']['responses']['200']['content']['application/json'];

View File

@ -4159,6 +4159,24 @@ export type components = {
/** Format: misskey:id */ /** Format: misskey:id */
userListId: string; userListId: string;
}; };
scheduledNotePosted?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
scheduledNotePostFailed?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
receiveFollowRequest?: { receiveFollowRequest?: {
/** @enum {string} */ /** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@ -4406,42 +4424,31 @@ export type components = {
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
text: string | null; text: string | null;
cw?: string | null; cw: string | null;
/** Format: id */ /** Format: id */
userId: string; userId: string;
user: components['schemas']['UserLite']; user: components['schemas']['UserLite'];
/** /** Format: id */
* Format: id replyId: string | null;
* @example xxxxxxxxxx /** Format: id */
*/ renoteId: string | null;
replyId?: string | null;
/**
* Format: id
* @example xxxxxxxxxx
*/
renoteId?: string | null;
/** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */
reply?: components['schemas']['Note'] | null; reply?: components['schemas']['Note'] | null;
/** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */
renote?: components['schemas']['Note'] | null; renote?: components['schemas']['Note'] | null;
/** @enum {string} */ /** @enum {string} */
visibility: 'public' | 'home' | 'followers' | 'specified'; visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: string[]; visibleUserIds: string[];
fileIds?: string[]; fileIds: string[];
files?: components['schemas']['DriveFile'][]; files?: components['schemas']['DriveFile'][];
hashtag?: string; hashtag: string | null;
poll?: { poll: {
/** Format: date-time */ /** Format: date-time */
expiresAt?: string | null; expiresAt?: string | null;
expiredAfter?: number | null; expiredAfter?: number | null;
multiple: boolean; multiple: boolean;
choices: string[]; choices: string[];
} | null; } | null;
/** /** Format: id */
* Format: id channelId: string | null;
* @example xxxxxxxxxx
*/
channelId?: string | null;
channel?: { channel?: {
id: string; id: string;
name: string; name: string;
@ -4450,9 +4457,11 @@ export type components = {
allowRenoteToExternal: boolean; allowRenoteToExternal: boolean;
userId: string | null; userId: string | null;
} | null; } | null;
localOnly?: boolean; localOnly: boolean;
/** @enum {string|null} */ /** @enum {string|null} */
reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
scheduledAt: number | null;
isActuallyScheduled: boolean;
}; };
NoteReaction: { NoteReaction: {
/** Format: id */ /** Format: id */
@ -4561,6 +4570,22 @@ export type components = {
/** Format: id */ /** Format: id */
userId: string; userId: string;
note: components['schemas']['Note']; note: components['schemas']['Note'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNotePosted';
note: components['schemas']['Note'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNotePostFailed';
noteDraft: components['schemas']['NoteDraft'];
} | { } | {
/** Format: id */ /** Format: id */
id: string; id: string;
@ -5253,6 +5278,7 @@ export type components = {
/** @enum {string} */ /** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable'; chatAvailability: 'available' | 'readonly' | 'unavailable';
noteDraftLimit: number; noteDraftLimit: number;
scheduledNoteLimit: number;
watermarkAvailable: boolean; watermarkAvailable: boolean;
}; };
ReversiGameLite: { ReversiGameLite: {
@ -9553,7 +9579,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
/** @enum {string} */ /** @enum {string} */
state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed'; state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed';
}; };
@ -9740,7 +9766,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[]; state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[];
search?: string; search?: string;
}; };
@ -9808,7 +9834,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
}; };
}; };
}; };
@ -9871,7 +9897,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
}; };
}; };
}; };
@ -9884,7 +9910,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
qualifiedName: string; qualifiedName: string;
counts: { counts: {
[key: string]: number; [key: string]: number;
@ -9974,7 +10000,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
counts: { counts: {
[key: string]: number; [key: string]: number;
}; };
@ -10038,7 +10064,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string; jobId: string;
}; };
}; };
@ -10102,7 +10128,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string; jobId: string;
}; };
}; };
@ -10166,7 +10192,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string; jobId: string;
}; };
}; };
@ -10233,7 +10259,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string; jobId: string;
}; };
}; };
@ -11653,6 +11679,24 @@ export interface operations {
/** Format: misskey:id */ /** Format: misskey:id */
userListId: string; userListId: string;
}; };
scheduledNotePosted?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
scheduledNotePostFailed?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
receiveFollowRequest?: { receiveFollowRequest?: {
/** @enum {string} */ /** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@ -25954,8 +25998,8 @@ export interface operations {
untilDate?: number; untilDate?: number;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };
@ -26039,8 +26083,8 @@ export interface operations {
untilDate?: number; untilDate?: number;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };
@ -27314,6 +27358,24 @@ export interface operations {
/** Format: misskey:id */ /** Format: misskey:id */
userListId: string; userListId: string;
}; };
scheduledNotePosted?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
scheduledNotePostFailed?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
receiveFollowRequest?: { receiveFollowRequest?: {
/** @enum {string} */ /** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@ -29143,31 +29205,34 @@ export interface operations {
* @default public * @default public
* @enum {string} * @enum {string}
*/ */
visibility?: 'public' | 'home' | 'followers' | 'specified'; visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: string[]; visibleUserIds: string[];
cw?: string | null; cw: string | null;
hashtag?: string | null; hashtag: string | null;
/** @default false */ /** @default false */
localOnly?: boolean; localOnly: boolean;
/** /**
* @default null * @default null
* @enum {string|null} * @enum {string|null}
*/ */
reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote'; reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** Format: misskey:id */ /** Format: misskey:id */
replyId?: string | null; replyId: string | null;
/** Format: misskey:id */ /** Format: misskey:id */
renoteId?: string | null; renoteId: string | null;
/** Format: misskey:id */ /** Format: misskey:id */
channelId?: string | null; channelId: string | null;
text?: string | null; text: string | null;
fileIds?: string[]; fileIds: string[];
poll?: { poll: {
choices: string[]; choices: string[];
multiple?: boolean; multiple?: boolean;
expiresAt?: number | null; expiresAt?: number | null;
expiredAfter?: number | null; expiredAfter?: number | null;
} | null; } | null;
scheduledAt: number | null;
/** @default false */
isActuallyScheduled: boolean;
}; };
}; };
}; };
@ -29314,6 +29379,7 @@ export interface operations {
untilId?: string; untilId?: string;
sinceDate?: number; sinceDate?: number;
untilDate?: number; untilDate?: number;
scheduled?: boolean | null;
}; };
}; };
}; };
@ -29380,20 +29446,13 @@ export interface operations {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
draftId: string; draftId: string;
/** /** @enum {string} */
* @default public
* @enum {string}
*/
visibility?: 'public' | 'home' | 'followers' | 'specified'; visibility?: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: string[]; visibleUserIds?: string[];
cw?: string | null; cw?: string | null;
hashtag?: string | null; hashtag?: string | null;
/** @default false */
localOnly?: boolean; localOnly?: boolean;
/** /** @enum {string|null} */
* @default null
* @enum {string|null}
*/
reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote'; reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** Format: misskey:id */ /** Format: misskey:id */
replyId?: string | null; replyId?: string | null;
@ -29409,6 +29468,8 @@ export interface operations {
expiresAt?: number | null; expiresAt?: number | null;
expiredAfter?: number | null; expiredAfter?: number | null;
} | null; } | null;
scheduledAt?: number | null;
isActuallyScheduled?: boolean;
}; };
}; };
}; };

View File

@ -26,6 +26,8 @@ export const notificationTypes = [
'quote', 'quote',
'reaction', 'reaction',
'pollEnded', 'pollEnded',
'scheduledNotePosted',
'scheduledNotePostFailed',
'receiveFollowRequest', 'receiveFollowRequest',
'followRequestAccepted', 'followRequestAccepted',
'groupInvited', 'groupInvited',
@ -227,12 +229,14 @@ export const rolePolicies = [
'chatAvailability', 'chatAvailability',
'uploadableFileTypes', 'uploadableFileTypes',
'noteDraftLimit', 'noteDraftLimit',
'scheduledNoteLimit',
'watermarkAvailable', 'watermarkAvailable',
] as const; ] as const;
export const queueTypes = [ export const queueTypes = [
'system', 'system',
'endedPollNotification', 'endedPollNotification',
'postScheduledNote',
'deliver', 'deliver',
'inbox', 'inbox',
'db', 'db',