%qEf@Ov(`XrIpOlOi3FgQ(`F$I)usw5ZX%W8p!h5e|i(zo4p19y(
zEl4WLI%KLKcxS*3ntRt}KgV4yv^wAW{vmiAU-Ad4h9f);E$)LWcg&;o
z>GBW6jD>;$>!)53XxCtfWvb~cRG*$O%=widPV*zMt_)LKU>zmxy_siw3-&dH{2C3h
z3@ta{#L)~X>*rIR9xvN;fzz4@Kq)o&J+)7d9Ao1KMqEK-1&3To{8w`?rUaZxqDe
zm*5>01?lpu!mdK|%$w}x@qou*@;63gEP-V)HuVQ*x9?|UIMa1b>o5uJlJ-pJs%|D~
zu6h8&n-bN{AUQB||IvO2w9Fw?8RC=F;mK0A)Ve!LNtylxmX4EJKSo|(tuqglGzyAg
zG|`Z<=1YwIb;>7R`%S
z3eN9|dJrU6{>gMcc6ogAi#K6ALTa*LFXhJgNykI!pU)XR9l+VXLFjc@w|i*+XHC>23$0|ob-e146Qz0f
zS58{U;)rI~>Jd5iy%E@P*4vokw8qW6B+)pk$G}x}LL#t7hzE{0o=>RQu-a+TCrRS$
zR8|~ll6uHidK=sk>xBRNy{*Gg7OBGa8oYhCgnn$lbr))zg1O9&Gwc>>+QxM$D5`@N
zH8BT*G0p8yuv)E0&mAo9!W9}5&F;8WtlTb?c3+)fqu#V^3U^*1#L?=*0%A;pr~jI?
z6|C^oB~9$?9DLX4Abpo)`tg1#HMRSf_Cf}>4|+E{G^dWf*0kvJgzyrXvN_#1$+Hi-
zluUeCZ9?Q`7($r3JMlaEV~b(PAZ5cR=Cb%KDNoOfj@_{?(qxa`fi&FX0~_xhsNj1r
ztUXI-j4x3rrNYcp3d$O~>cWe*FCQAm_k<@|wztRz=Sv43G1Mq0ZsbTFhwEGa9y8`0
zq!{?rcE5}6-$LqGMI;qb)WuchBeTOQrUf`QFU(w_SF5)|
k0gvB5yx_L_`&hltOsT!`;l_y#or`}Nl^p0_>C0gM1EQ^7b^rhX
literal 0
HcmV?d00001
diff --git a/packages/backend/assets/tabler-badges/calendar-exclamation.png b/packages/backend/assets/tabler-badges/calendar-exclamation.png
new file mode 100644
index 0000000000000000000000000000000000000000..7944fac88f3ede3e5995dcb3c1141feda536e5f8
GIT binary patch
literal 1933
zcmYk7c~sKr8pnS>7Dd2aM-el%GSeckT&5KFg>(wajhncanoE{op2kFtFuRndWr`+n
zW=31wGc__n%e85A^pu8UVw09uN_JHemy6Y$d!KWj_xpLj=Xt;9`R~moAM{p78X*Ay
zP$&6#99C-hMk7>|=j-A#4oXo=_MxT#07`eGL4aFks5Bdo1(S}GNLBzsc~Jp$Kqvqz
zX`_um_*V^7GWI)%D%tY;3`EJgkpIoc<-oqqA6NPt<)iG4oPSigO1>%pQ#yDX0v^2j
zjX`As!2j+O@OY5_EpdGv03iHtiQ&pO(EnrQ6}Vj5^s91XCdtFqKdtN|?OC3lryldE
z7OSWGL+Du0E3!lwjBQxiw5&YHR=H?}o7$F%qCCutTFa$cKbR_7b-
z%<(`xtyvhHOBCI%u9+^Mo(ZDSL~m}C+^jASs?AvVA{45aZuweK`N5_d&)bYNwC=7Q
z9BZ>0pwuwDb6t203PQ3N(5_Lwr!`JqoK{FIj62*|i=18=XFp``e?nW{8EqCj8l<|P
z0Y&EUFl}3jzw{>s#~o>?Mb4(=VQSo{iqz_*yVtxz3$xEL>h^3$+w+Bho#o|L3%J56
zSnG|26MGttuV_@Qnk&vp9GQdThg2h&o2}xd7XAie3)V^So}_ku7c#uU>qSfsIuDC8
z!nyw0jKe0mGQb21H;Vo&(zmR8?bN(yBurb)%)oBWD3lOFXoYt2n{XF@3fY&Gk~3JP
z`g-SLQ11=>`H!3CZ7*HG4lfU}hta&pYm)28mz8hdypNk-l^Rdl8??xrZPF2*Q^^Nc
zJCd<|KQ3PB+;@c?eMIr0+wRL^!JyOD@nclFik7THbu4eaYXh{a>KLnu4rS8*<HK7TiPDa;AlM_ytU
zT?j4}z!K->b^ec;0U$noe^U(!{D4|!6TPf~uERy@VZZ%e?!$_jkHf6gZ?y%Zz3>%n
zC6>*O8D}gq@2Y9sY(Qz?lprY~l@LKY(>lNbl{_|x-S=Q;lX%k=9+sNv1M8vXhh<7>
zWE0jZgl>b-JZCf`DKf7Vv_Iqva8G1y?eED659?QBD6<%o`o1cCw3A+zG;QDvSw17I
zT&cd^WQ%q(+zvfGg3QQim`eF5@?9^Sd~U=={6DE%2QAcgfp5I-
z@X*8Irz2SJHA0NX>{i4=iyg5D^8M>|C-g6+eA29rA7yu?-HS04n28z1A@UYT^h&0@
zcGt_)NzVn&gBrLr{?uJL_i<=|HR4lx)?#3vQ`AL)8vjvAnkIqaN(3e8U4B3BUxh`Q
zu-s8votPAYpZf!p>={d<|I20zHsu5HfoAroa&PE|UB^#DAn8l34@Lc5xa2
z4LZiC2jVbrEV-%~RX?!*Zy{3C+gjX6l;57f9Kc$+jdZFP#GXHuhJLxpCX|sR3A*+}
z-YoLI(iGP5GNPgh(NHv^+I&LibJqDOCri5*<>t(p
zi~Niu4`DPwe#<6pYG<3QxM`VZJygSx$6E{
z5J9#8`lap&So0c!6I!f0vWkuZyvkZy%TGcxW6kt_x~DB-G+Z0!Mp%gMu2^N|+R^&y
z51N|1?bR7sd)BxU_EO%a&JJNeJs=TX2nlT-yyLQX)xm+8DBsh<>^e~Dp#Dg6h&Npz
zKn%a|$q=brU0M7!-lf3#%@4Zo9f!8T#YEA8%xB@=<0erVZ?}
zPV`4;v*Q2F5MMZJV&Oh$qj&Drfzm&1TEcf5u0cEnCkGU$KrY86zmT)7*paivAjA8j
jl^!8_FUH+wIddKCd$sdsE2`UR<4YoW9`vYj3(x*9G&Vb$
literal 0
HcmV?d00001
diff --git a/packages/backend/assets/tabler-badges/calendar-time.png b/packages/backend/assets/tabler-badges/calendar-time.png
new file mode 100644
index 0000000000000000000000000000000000000000..0443baee2614989c8571a40a24a5f50a00fc5879
GIT binary patch
literal 2801
zcmYjTc{tSFAN`JDm@%W&NDLDOkt{J8WoeR~?E5mb49%1!#Mmd4XhHVf$o|@+C|i~m
zNrOUUX&7smL<#Ym-uL}Izu$eHbIffE1GAa(_VOtQzQq9Qyxla$Gcu*=kjh
znA2LLcOPr@WrqEnnCtC5s)48
zbislk-+r>3F2@m^+Hs0=u4lu5ZEQG=sv4O2;x{s6vUDS?t*}3+9N*?cRZ@YYrQ108
zjD>SbVC%d$(YHzm!jdbfT88R<#~umwMuy8$Gtg$2#>|&*M$Oo@i!oin!bPh|mGab#
z#fIGTShevflc>isWT$NeTD*kbg#NFEjGUnWT?7JdJnvjT6V^V?X
za)Ndh!_fz&FZqs^UBkb%t(yu;Tt1N4ATRC3+WG{UJVA+k8Ej{>UpZ&LgU8*k(^+&^
zD}Xua?xU#N`z?Jd`Gt7gs}anhVt%YyMVi*k;RBy;ON|o}U6u-nK;%BT+v!cmf?MC>
zOJ>wj#Mq*w&!Fg);t}K9mP&+^B9?2D#06;_zCae~#(6SS7uGf~`S&7Jfpi75HB91m-M
z-;AQaOhBEL%$c9zlsAfyBeYV-wGXWo2WzzwFOI1hWTsXrbJ^dZGRO;-yIyfy
zJh#zHkM9}tZ#GkR^r#9DNzd7nfQgCfW>gGxCmcUuT1ZKCp6yoK&6a$B=;-U+9HXoS
z4m51~Mj}0UYZCJQhLZWZug~N4Z@)vMUUB>i!Y02B=YUQBSU0DF!f|q9cn93l#4A
zz8#Mae7{rVJ9>38sHaiZ6$(~Dh8-`o*h)D8oa^U)BusOa{bONk2wN#CRE4MT7(cnI
z)H?i^HNigo3-iXE`zBp=&PFoA{bzcO#3N{sDdw1#oS6VnS99(7r7>aZu;muQRKVTC
znsZyHI2=|jCEyOcTHszZq1tV@Jl)IhiGybdUEO|eH0UV2DDSiTUQR1Ak+W&l!+-Rm2;+tH>yoz_!Pc1sv&`OnG&niv)TqGH
z?YR;Qe+BZh(7>Qjui8sCIBF%uOV1nE*fAv?HHKKB&Chg!e(g3jJBgFFt#^A77DVv;
zY!sM9P~B87Izq<0^M=?7HQVRw2ys^?cdy;*uU
z=(aKn=QmX|?nbXz9?%F8)Q!t@OX)YRWw7pz1*m?F
zw-u^#?9LP6WW-4rN$Ch7sKs-hFgYG{kLQNhPDW}?F
zo%I^uFh24Uu>kTLvMKAxa(9wcWlO`z9S}QZ}?d!GY1^BYeIg;*{2N1FB?T7_HXhI77QXIYzqn4hpkY
z1+#`|OOXgo5r3BxNl%~*v-;_0Lj88z*}gN}iXqYZ=T&6PKf&8EVxq~O-$Oo3IoP6q
zk9z=4vna_HO=GQ?jzD9dd%op~<|zoDCn@?7a}U;!uEiIy?lD^}*5)rt0{6iWEKBkf~d%^NY!lBDHYHMoy>H?CrH
zh~k#r@Q^#{vHHO)tLFa7C-CYy_oCd6m{Vl#Y#f;k5`%aV!~(Be?Dm7@YK|>wYmJt9
z$DF-s_;Y#ZTtCC6T-y(V1;2mj1T`NWM&`ycL<8%!F!>dEvR;f
zu+?=WtQehC{}rk$VpcEkl-^k#9^f4*!EzpsPQnIU_iE|!i-0}5;SvUq-%a>TpgOh>
z2e@2jlBC>hEOA+FmW}eesRjH8*Pa7bdy^qtD-J+qRlkL
zRZy%`BhDjzgD{bUV}#CmSTA*J>-q|>EZ^BHXWB-Yp>~Y=;R_&a8cgwgPPU%A
z0{kRig;2ltD``6~sH0?}+C^>^bt=94{FnuK$L)}NPlkxo@YRWJ{Z6&(YrT8l^R-1!`FDdngy_gjl&8gN8TQ-6t|T7hne-=PPW(i(bbCB0{P
zx#$rywjXqkcPWw;;{*2p&LdJG@ds+V5<`$u`J%fkik`hysvLQ!u<@xj>`>BRw9JBZ
z?AxjKec5~0GS)n=Y)E9c98Nn_A4!p)3yL$<9Uc_9?&4_lTltfNJE0y2X^Ot#Jf0
zcG4t8#xKT{trW|cq)cjxla7|{cy72R5hI@ {
+ }, data: NoteCreateOption, silent = false): Promise {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
@@ -425,18 +411,30 @@ export class NoteCreateService implements OnApplicationShutdown {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', `Notes including mentions are limited to ${policies.mentionLimit} users.`);
}
- const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
+ if (!data.scheduledAt) {
+ const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
- setImmediate('post created', { signal: this.#shutdownController.signal }).then(
- () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
- () => { /* aborted, ignore this */ },
- );
+ setImmediate('post created', { signal: this.#shutdownController.signal }).then(
+ () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
+ () => { /* aborted, ignore this */ },
+ );
+
+ return note;
+ } else {
+ if (!policies.canScheduleNote) {
+ throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.');
+ }
+
+ const draft = await this.insertScheduledNote(user, data);
- return note;
+ await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!);
+
+ return draft;
+ }
}
@bindThis
- private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
+ private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
const insert = new MiNote({
id: this.idService.gen(data.createdAt?.getTime()),
createdAt: data.createdAt!,
@@ -534,13 +532,40 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
+ @bindThis
+ private async insertScheduledNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption) {
+ const insert = new MiScheduledNote({
+ id: this.idService.gen(data.createdAt?.getTime()),
+ createdAt: data.createdAt!,
+ scheduledAt: data.scheduledAt!,
+ userId: user.id,
+ draft: data,
+ });
+
+ // 予約投稿を作成
+ try {
+ await this.scheduledNotesRepository.insert(insert);
+
+ return insert;
+ } catch (e) {
+ // duplicate key error
+ if (isDuplicateKeyValueError(e)) {
+ throw new IdentifiableError('5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc', 'There is already a scheduled note with the same time.');
+ }
+
+ this.logger.error(`Failed to create scheduled note: ${e}`, { error: e });
+
+ throw e;
+ }
+ }
+
@bindThis
private async postNoteCreated(note: MiNote, user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
- }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
+ }, data: NoteCreateOption, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
const meta = await this.metaService.fetch();
this.notesChart.update(note, true);
@@ -792,12 +817,12 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- private isRenote(note: Option): note is Option & { renote: MiNote } {
+ private isRenote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } {
return note.renote != null;
}
@bindThis
- private isQuote(note: Option): note is Option & { renote: MiNote } & (
+ private isQuote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } & (
{ text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] }
) {
// NOTE: SYNC WITH misc/is-renote.ts
@@ -873,7 +898,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote) {
+ private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote) {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index a41c9c96e4ce..fb5501c42cbe 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
+import type { MiScheduledNote } from '@/models/ScheduledNote.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
import type { Config } from '@/config.js';
@@ -34,6 +35,11 @@ export class QueueService {
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {
+ this.ensureRepeatJobs();
+ }
+
+ @bindThis
+ private ensureRepeatJobs() {
this.systemQueue.add('tickCharts', {
}, {
repeat: { pattern: '55 * * * *' },
@@ -69,6 +75,12 @@ export class QueueService {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true,
});
+
+ this.systemQueue.add('checkMissingScheduledNote', {
+ }, {
+ repeat: { pattern: '*/5 * * * *' },
+ removeOnComplete: true,
+ });
}
@bindThis
@@ -382,6 +394,18 @@ export class QueueService {
});
}
+ @bindThis
+ public createScheduledNoteJob(draftId: MiScheduledNote['id'], scheduledAt: Date) {
+ return this.systemQueue.add('scheduledNote', {
+ draftId,
+ }, {
+ jobId: `scheduledNote:${draftId}`,
+ delay: Math.max(scheduledAt.getTime() - Date.now(), 0) + Math.floor(Math.random() * 500 + 250),
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
@bindThis
public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean, withReplies?: boolean }[]) {
const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel));
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 1c205dca0bcf..1b8091cc24c7 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -36,6 +36,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
+ canScheduleNote: boolean;
canInitiateConversation: boolean;
canCreateContent: boolean;
canUpdateContent: boolean;
@@ -77,6 +78,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
gtlAvailable: true,
ltlAvailable: true,
canPublicNote: true,
+ canScheduleNote: true,
canInitiateConversation: true,
canCreateContent: true,
canUpdateContent: true,
@@ -389,6 +391,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
+ canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)),
canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)),
canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)),
canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)),
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 1e6ff5a5a448..9effcfac9f1d 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -352,7 +352,7 @@ export class ApNoteService {
poll,
uri: note.id,
url: url,
- }, silent);
+ }, silent) as MiNote;
} catch (err: any) {
if (err.name !== 'duplicated') {
throw err;
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index a580ce44d92d..bd8f9a1cf7bd 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -20,14 +20,16 @@ import { RoleEntityService } from './RoleEntityService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
+import type { ScheduledNoteEntityService } from './ScheduledNoteEntityService.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()
export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
private noteEntityService: NoteEntityService;
private roleEntityService: RoleEntityService;
+ private scheduledNoteEntityService: ScheduledNoteEntityService;
constructor(
private moduleRef: ModuleRef,
@@ -52,6 +54,7 @@ export class NotificationEntityService implements OnModuleInit {
this.userEntityService = this.moduleRef.get('UserEntityService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.roleEntityService = this.moduleRef.get('RoleEntityService');
+ this.scheduledNoteEntityService = this.moduleRef.get('ScheduledNoteEntityService');
}
/**
@@ -84,6 +87,11 @@ export class NotificationEntityService implements OnModuleInit {
// if the note has been deleted, don't show this notification
if (needsNote && !noteIfNeed) return null;
+ const needsDraft = 'draftId' in notification;
+ const draftIfNeed = needsDraft ? this.scheduledNoteEntityService.pack(notification.draftId, { id: meId }) : undefined;
+ // if the draft has been deleted, don't show this notification
+ if (needsDraft && !draftIfNeed) return null;
+
const needsUser = 'notifierId' in notification;
const userIfNeed = needsUser ? (
hint?.packedUsers != null
@@ -116,6 +124,7 @@ export class NotificationEntityService implements OnModuleInit {
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
note: noteIfNeed,
+ draft: draftIfNeed,
reactions,
});
} else if (notification.type === 'renote:grouped') {
@@ -139,6 +148,7 @@ export class NotificationEntityService implements OnModuleInit {
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
note: noteIfNeed,
+ draft: draftIfNeed,
users,
});
}
@@ -158,6 +168,7 @@ export class NotificationEntityService implements OnModuleInit {
userId: 'notifierId' in notification ? notification.notifierId : undefined,
...(userIfNeed != null ? { user: userIfNeed } : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
+ ...(draftIfNeed != null ? { draft: draftIfNeed } : {}),
...(notification.type === 'reaction' ? {
reaction: notification.reaction,
} : {}),
diff --git a/packages/backend/src/core/entities/ScheduledNoteEntityService.ts b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts
new file mode 100644
index 000000000000..07f1c2bcafea
--- /dev/null
+++ b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts
@@ -0,0 +1,76 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { ScheduledNotesRepository } from '@/models/_.js';
+import type { MiUser } from '@/models/User.js';
+import type { MiScheduledNote } from '@/models/ScheduledNote.js';
+import { bindThis } from '@/decorators.js';
+import { Packed } from '@/misc/json-schema.js';
+import { DriveFileEntityService } from './DriveFileEntityService.js';
+
+@Injectable()
+export class ScheduledNoteEntityService {
+ constructor(
+ @Inject(DI.scheduledNotesRepository)
+ private scheduledNotesRepository: ScheduledNotesRepository,
+
+ private driveFileEntityService: DriveFileEntityService,
+ ) {
+ }
+
+ @bindThis
+ public async pack(
+ src: MiScheduledNote['id'] | MiScheduledNote,
+ me: { id: MiUser['id'] },
+ ) : Promise> {
+ const item = typeof src === 'object' ? src : await this.scheduledNotesRepository.findOneByOrFail({ id: src, userId: me.id });
+
+ return {
+ id: item.id,
+ updatedAt: item.createdAt.toISOString(),
+ scheduledAt: item.scheduledAt?.toISOString() ?? null,
+ reason: item.reason ?? undefined,
+ channel: item.draft.channel ? {
+ id: item.draft.channel.id,
+ name: item.draft.channel.name,
+ } : undefined,
+ renote: item.draft.renote ? {
+ id: item.draft.renote.id,
+ text: (item.draft.renote.cw ?? item.draft.renote.text)?.substring(0, 100) ?? null,
+ user: {
+ id: item.draft.renote.userId,
+ username: item.draft.renote.user!.username,
+ host: item.draft.renote.user!.host,
+ },
+ } : undefined,
+ reply: item.draft.reply ? {
+ id: item.draft.reply.id,
+ text: (item.draft.reply.cw ?? item.draft.reply.text)?.substring(0, 100) ?? null,
+ user: {
+ id: item.draft.reply.userId,
+ username: item.draft.reply.user!.username,
+ host: item.draft.reply.user!.host,
+ },
+ } : undefined,
+ data: {
+ text: item.draft.text ?? null,
+ useCw: !!item.draft.cw,
+ cw: item.draft.cw ?? null,
+ visibility: item.draft.visibility as 'public' | 'followers' | 'home' | 'specified',
+ localOnly: item.draft.localOnly ?? false,
+ files: item.draft.files ? await this.driveFileEntityService.packMany(item.draft.files, me) : [],
+ poll: item.draft.poll ? { ...item.draft.poll, expiresAt: item.draft.poll.expiresAt?.getTime() ?? null, expiredAfter: null } : null,
+ visibleUserIds: item.draft.visibility === 'specified' ? item.draft.visibleUsers?.map(x => x.id) : undefined,
+ },
+ };
+ }
+
+ @bindThis
+ public async packMany(
+ drafts: (MiScheduledNote['id'] | MiScheduledNote)[],
+ me: { id: MiUser['id'] },
+ ) : Promise[]> {
+ return (await Promise.allSettled(drafts.map(x => this.pack(x, me))))
+ .filter(result => result.status === 'fulfilled')
+ .map(result => (result as PromiseFulfilledResult>).value);
+ }
+}
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index a70cce451b16..8b7b10b92593 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -21,6 +21,7 @@ export const DI = {
announcementReadsRepository: Symbol('announcementReadsRepository'),
appsRepository: Symbol('appsRepository'),
avatarDecorationsRepository: Symbol('avatarDecorationsRepository'),
+ scheduledNotesRepository: Symbol('scheduledNotesRepository'),
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
noteReactionsRepository: Symbol('noteReactionsRepository'),
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 5f169c37dfce..10b144b16136 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -31,6 +31,7 @@ import { packedMutingSchema } from '@/models/json-schema/muting.js';
import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
import { packedNoteSchema } from '@/models/json-schema/note.js';
+import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
import { packedNotificationSchema } from '@/models/json-schema/notification.js';
import { packedPageLikeSchema, packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js';
import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
@@ -77,6 +78,7 @@ export const refs = {
Announcement: packedAnnouncementSchema,
App: packedAppSchema,
Note: packedNoteSchema,
+ NoteDraft: packedNoteDraftSchema,
NoteReaction: packedNoteReactionSchema,
NoteFavorite: packedNoteFavoriteSchema,
Notification: packedNotificationSchema,
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index df88b996364c..4747b51b5fc7 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -5,6 +5,7 @@
import { MiUser } from './User.js';
import { MiNote } from './Note.js';
+import { MiScheduledNote } from './ScheduledNote.js';
import { MiAccessToken } from './AccessToken.js';
import { MiRole } from './Role.js';
@@ -77,6 +78,21 @@ export type MiNotification = {
id: string;
createdAt: string;
achievement: string;
+} | {
+ type: 'noteScheduled';
+ id: string;
+ createdAt: string;
+ draftId: MiScheduledNote['id'];
+} | {
+ type: 'scheduledNotePosted';
+ id: string;
+ createdAt: string;
+ noteId: MiNote['id'];
+} | {
+ type: 'scheduledNoteError';
+ id: string;
+ createdAt: string;
+ draftId: MiScheduledNote['id'];
} | {
type: 'app';
id: string;
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 83eee55ff890..ced1ff812310 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -39,6 +39,7 @@ import {
MiModerationLog,
MiMuting,
MiNote,
+ MiScheduledNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
@@ -117,6 +118,12 @@ const $avatarDecorationsRepository: Provider = {
inject: [DI.db],
};
+const $scheduledNotesRepository: Provider = {
+ provide: DI.scheduledNotesRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiScheduledNote),
+ inject: [DI.db],
+};
+
const $noteFavoritesRepository: Provider = {
provide: DI.noteFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite),
@@ -517,6 +524,7 @@ const $abuseReportResolversRepository: Provider = {
$announcementReadsRepository,
$appsRepository,
$avatarDecorationsRepository,
+ $scheduledNotesRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
@@ -590,6 +598,7 @@ const $abuseReportResolversRepository: Provider = {
$announcementReadsRepository,
$appsRepository,
$avatarDecorationsRepository,
+ $scheduledNotesRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts
new file mode 100644
index 000000000000..d573e251497b
--- /dev/null
+++ b/packages/backend/src/models/ScheduledNote.ts
@@ -0,0 +1,54 @@
+import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
+import type { NoteCreateOption } from '@/types.js';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('note_scheduled')
+@Index(['userId', 'scheduledAt'], { unique: true })
+export class MiScheduledNote {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ comment: 'The created date of the Note.',
+ default: () => 'CURRENT_TIMESTAMP',
+ })
+ public createdAt: Date;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ comment: 'The scheduled date of the Note.',
+ nullable: true
+ })
+ public scheduledAt: Date | null;
+
+ @Column('varchar', {
+ length: 256, nullable: true,
+ })
+ public reason: string | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ comment: 'The ID of author.',
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Column('jsonb')
+ public draft: NoteCreateOption;
+
+ constructor(data: Partial) {
+ if (data == null) return;
+
+ for (const [k, v] of Object.entries(data)) {
+ (this as any)[k] = v;
+ }
+ }
+}
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index 800af98b1f30..7bf282a1829f 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -34,6 +34,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
+import { MiScheduledNote } from '@/models/ScheduledNote.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
@@ -108,6 +109,7 @@ export {
MiMuting,
MiRenoteMuting,
MiNote,
+ MiScheduledNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
@@ -181,6 +183,7 @@ export type ModerationLogsRepository = Repository;
export type MutingsRepository = Repository;
export type RenoteMutingsRepository = Repository;
export type NotesRepository = Repository;
+export type ScheduledNotesRepository = Repository;
export type NoteFavoritesRepository = Repository;
export type NoteReactionsRepository = Repository;
export type NoteThreadMutingsRepository = Repository;
diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts
new file mode 100644
index 000000000000..7b81838fe087
--- /dev/null
+++ b/packages/backend/src/models/json-schema/note-draft.ts
@@ -0,0 +1,179 @@
+export const packedNoteDraftSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id'
+ },
+ updatedAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time'
+ },
+ scheduledAt: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'date-time',
+ },
+ reason: {
+ type: 'string',
+ optional: true, nullable: false
+ },
+ channel: {
+ type: 'object',
+ optional: true, nullable: true,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id'
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false
+ },
+ },
+ },
+ renote: {
+ type: 'object',
+ optional: true, nullable: true,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id',
+ },
+ text: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ user: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id',
+ },
+ username: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ host: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ },
+ },
+ },
+ },
+ reply: {
+ type: 'object',
+ optional: true, nullable: true,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id',
+ },
+ text: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ user: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id'
+ },
+ username: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ host: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ },
+ },
+ },
+ },
+ data: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ text: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ useCw: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ cw: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ visibility: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['public', 'home', 'followers', 'specified'],
+ },
+ localOnly: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ files: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'DriveFile',
+ },
+ },
+ poll: {
+ type: 'object',
+ optional: false, nullable: true,
+ properties: {
+ choices: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
+ multiple: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ expiresAt: {
+ type: 'integer',
+ optional: false, nullable: true,
+ },
+ expiredAfter: {
+ type: 'integer',
+ optional: false, nullable: true,
+ minimum: 1
+ },
+ },
+ },
+ visibleUserIds: {
+ type: 'array',
+ optional: true, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'misskey:id',
+ },
+ },
+ },
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index b4c4442758cd..e682408977d1 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -296,6 +296,51 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['noteScheduled'],
+ },
+ draft: {
+ type: 'object',
+ ref: 'NoteDraft',
+ 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: ['scheduledNoteError'],
+ },
+ draft: {
+ type: 'object',
+ ref: 'NoteDraft',
+ optional: false, nullable: false,
+ }
+ }
}, {
type: 'object',
properties: {
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 166a085f5546..6cbea17089ca 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -180,6 +180,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ canScheduleNote: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
canInitiateConversation: {
type: 'boolean',
optional: false, nullable: false,
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 58a40ae3c8b4..b272f7d6523e 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -44,6 +44,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
+import { MiScheduledNote } from '@/models/ScheduledNote.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
@@ -162,6 +163,7 @@ export const entities = [
MiRenoteMuting,
MiBlocking,
MiNote,
+ MiScheduledNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index f3588544fc7d..d7f0a56e7286 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
+import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
@@ -36,6 +37,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro
import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js';
+import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js';
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
@@ -52,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ResyncChartsProcessorService,
CleanChartsProcessorService,
CheckExpiredMutingsProcessorService,
+ CheckMissingScheduledNoteProcessorService,
CleanProcessorService,
DeleteDriveFilesProcessorService,
ExportCustomEmojisProcessorService,
@@ -75,6 +78,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
CleanRemoteFilesProcessorService,
RelationshipProcessorService,
ReportAbuseProcessorService,
+ ScheduledNoteProcessorService,
WebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
DeliverProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 1e68f750abcd..dc1429944a76 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -35,10 +35,12 @@ import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesP
import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js';
-import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
+import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js';
+import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
+import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
@@ -113,11 +115,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService,
private relationshipProcessorService: RelationshipProcessorService,
private reportAbuseProcessorService: ReportAbuseProcessorService,
- private tickChartsProcessorService: TickChartsProcessorService,
private resyncChartsProcessorService: ResyncChartsProcessorService,
+ private scheduledNoteProcessorService: ScheduledNoteProcessorService,
+ private tickChartsProcessorService: TickChartsProcessorService,
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
+ private checkMissingScheduledNoteProcessorService: CheckMissingScheduledNoteProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
@@ -141,11 +145,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
switch (job.name) {
+ case 'scheduledNote': return this.scheduledNoteProcessorService.process(job);
case 'tickCharts': return this.tickChartsProcessorService.process();
case 'resyncCharts': return this.resyncChartsProcessorService.process();
case 'cleanCharts': return this.cleanChartsProcessorService.process();
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
+ case 'checkMissingScheduledNote': return this.checkMissingScheduledNoteProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`);
}
diff --git a/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts
new file mode 100644
index 000000000000..8c441a9cc4a7
--- /dev/null
+++ b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts
@@ -0,0 +1,61 @@
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { DI } from '@/di-symbols.js';
+import type { ScheduledNotesRepository } from '@/models/_.js';
+import type Logger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
+import { acquireDistributedLock } from '@/misc/distributed-lock.js';
+import { QueueService } from '@/core/QueueService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+
+@Injectable()
+export class CheckMissingScheduledNoteProcessorService {
+ private logger: Logger;
+
+ constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
+ @Inject(DI.scheduledNotesRepository)
+ private scheduledNotesRepository: ScheduledNotesRepository,
+
+ private queueService: QueueService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled');
+ }
+
+ @bindThis
+ public async process(): Promise {
+ this.logger.info(`checking missing scheduled note tasks`);
+
+ try {
+ await acquireDistributedLock(this.redisForTimelines, `note:scheduled:check`, 3 * 60 * 1000, 1, 1000);
+ } catch (e) {
+ this.logger.warn(`check is already being processed`);
+ return;
+ }
+
+ const query = this.scheduledNotesRepository.createQueryBuilder('draft')
+ .where('draft.scheduledAt < now() - interval \'5 minutes\'').orderBy('draft.createdAt', 'ASC');
+
+ let lastId = '0';
+ while (true) {
+ const drafts = await query.andWhere('draft.id > :lastId', { lastId }).limit(100).getMany();
+
+ if (drafts.length === 0) {
+ break;
+ }
+
+ for (const draft of drafts.filter(draft => draft.scheduledAt !== null)) {
+ const jobState = await this.queueService.systemQueue.getJobState(`scheduledNote:${draft.id}`);
+ if (jobState !== 'unknown') continue;
+
+ this.logger.warn(`found missing scheduled note task: ${draft.id}`);
+ await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!);
+ }
+
+ lastId = drafts[drafts.length - 1].id;
+ }
+ }
+}
diff --git a/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts
new file mode 100644
index 000000000000..3ca28941eb85
--- /dev/null
+++ b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts
@@ -0,0 +1,101 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { IsNull } from 'typeorm';
+import * as Bull from 'bullmq';
+import * as Redis from 'ioredis';
+import { DI } from '@/di-symbols.js';
+import { MiNote, type ScheduledNotesRepository } from '@/models/_.js';
+import type Logger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { acquireDistributedLock } from '@/misc/distributed-lock.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type { ScheduledNoteJobData } from '../types.js';
+
+@Injectable()
+export class ScheduledNoteProcessorService {
+ private logger: Logger;
+
+ constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
+ @Inject(DI.scheduledNotesRepository)
+ private scheduledNotesRepository: ScheduledNotesRepository,
+
+ private notificationService: NotificationService,
+ private noteCreateService: NoteCreateService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled');
+ }
+
+ @bindThis
+ public async process(job: Bull.Job): Promise {
+ this.logger.info(`processing ${job.data.draftId}`);
+
+ try {
+ await acquireDistributedLock(this.redisForTimelines, `note:scheduled:${job.data.draftId}`, 30 * 1000, 1, 100);
+ } catch (e) {
+ this.logger.warn(`draft=${job.data.draftId} is already being processed`);
+ return 'ok';
+ }
+
+ const draft = await this.scheduledNotesRepository.findOne({
+ where: { id: job.data.draftId, reason: IsNull() },
+ relations: ['user'],
+ });
+
+ if (draft == null) {
+ this.logger.warn(`draft not found: ${job.data.draftId}`);
+ return 'ok';
+ }
+
+ if (!draft.user || draft.user.isSuspended) {
+ this.logger.warn(`user is suspended: ${draft.userId}`);
+ await this.scheduledNotesRepository.delete({ id: draft.id });
+ return 'ok';
+ }
+
+ try {
+ const note = (await this.noteCreateService.create(draft.user, {
+ ...draft.draft,
+ createdAt: new Date(),
+ scheduledAt: null,
+ })) as MiNote;
+
+ await this.scheduledNotesRepository.delete({ id: draft.id });
+
+ this.notificationService.createNotification(draft.userId, "scheduledNotePosted", {
+ noteId: note.id,
+ });
+
+ return 'ok';
+ } catch (e) {
+ if (e instanceof IdentifiableError) {
+ if ([
+ 'e11b3a16-f543-4885-8eb1-66cad131dbfd',
+ '689ee33f-f97c-479a-ac49-1b9f8140af99',
+ '9f466dab-c856-48cd-9e65-ff90ff750580',
+ '85ab9bd7-3a41-4530-959d-f07073900109',
+ 'd450b8a9-48e4-4dab-ae36-f4db763fda7c',
+ ].includes(e.id)) {
+ this.logger.warn(`creating note from draft=${draft.id} failed: ${e.message}`);
+
+ await this.scheduledNotesRepository.update({ id: draft.id }, {
+ scheduledAt: null,
+ reason: e.message,
+ });
+
+ this.notificationService.createNotification(draft.userId, "scheduledNoteError", {
+ draftId: draft.id,
+ });
+
+ return e.message;
+ }
+ }
+ throw e;
+ }
+ }
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 49aedec58478..ca2662155a8c 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -6,6 +6,7 @@
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
+import type { MiScheduledNote } from '@/models/ScheduledNote.js';
import type { MiUser } from '@/models/User.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiWebhook } from '@/models/Webhook.js';
@@ -116,6 +117,10 @@ export type EndedPollNotificationJobData = {
noteId: MiNote['id'];
};
+export type ScheduledNoteJobData = {
+ draftId: MiScheduledNote['id'];
+};
+
export type WebhookDeliverJobData = {
type: string;
content: unknown;
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 924eac7a1fb4..33e93a81487f 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
+import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js';
+import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@@ -682,6 +684,8 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes
const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default };
const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default };
const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default };
+const $notes_scheduled_cancel: Provider = { provide: 'ep:notes/scheduled/cancel', useClass: ep___notes_scheduled_cancel.default };
+const $notes_scheduled_list: Provider = { provide: 'ep:notes/scheduled/list', useClass: ep___notes_scheduled_list.default };
const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default };
const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default };
const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default };
@@ -1081,6 +1085,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_conversation,
$notes_create,
$notes_delete,
+ $notes_scheduled_cancel,
+ $notes_scheduled_list,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,
@@ -1474,6 +1480,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_conversation,
$notes_create,
$notes_delete,
+ $notes_scheduled_cancel,
+ $notes_scheduled_list,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 987228be66b1..a5f51328c035 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
+import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js';
+import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@@ -680,6 +682,8 @@ const eps = [
['notes/conversation', ep___notes_conversation],
['notes/create', ep___notes_create],
['notes/delete', ep___notes_delete],
+ ['notes/scheduled/cancel', ep___notes_scheduled_cancel],
+ ['notes/scheduled/list', ep___notes_scheduled_list],
['notes/favorites/create', ep___notes_favorites_create],
['notes/favorites/delete', ep___notes_favorites_delete],
['notes/featured', ep___notes_featured],
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 963f3bbfdbe2..c90c9daf9675 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -17,6 +17,7 @@ import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
@@ -148,6 +149,31 @@ export const meta = {
id: '66819f28-9525-389d-4b0a-4974363fbbbf',
},
+ cannotScheduleToPast: {
+ message: 'Cannot schedule to the past.',
+ code: 'CANNOT_SCHEDULE_TO_PAST',
+ id: 'e577d185-8179-4a17-b47f-6093985558e6',
+ },
+
+ cannotScheduleToFarFuture: {
+ message: 'Cannot schedule to the far future.',
+ code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE',
+ id: 'ea102856-e8da-4ae9-a98a-0326821bd177',
+ },
+
+ cannotScheduleSameTime: {
+ message: 'Cannot schedule multiple notes at the same time.',
+ code: 'CANNOT_SCHEDULE_SAME_TIME',
+ id: '187a8fab-fd83-4ae6-a46c-0f6f07784634',
+ },
+
+ rolePermissionDenied: {
+ message: 'You are not assigned to a required role.',
+ code: 'ROLE_PERMISSION_DENIED',
+ kind: 'permission',
+ id: '12f1d5d2-f7ec-4d7c-b608-e873f4b20327',
+ status: 403,
+ },
},
} as const;
@@ -207,6 +233,7 @@ export const paramDef = {
},
required: ['choices'],
},
+ scheduledAt: { type: 'integer', nullable: true },
noCreatedNote: { type: 'boolean', default: false },
},
// (re)note with text, files and poll are optional
@@ -263,6 +290,7 @@ export default class extends Endpoint { // eslint-
private loggerService: LoggerService,
private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService,
+ private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me, _token, _file, _cleanup, ip, headers) => {
const logger = this.loggerService.getLogger('api:notes:create');
@@ -318,7 +346,7 @@ export default class extends Endpoint { // eslint-
let renote: MiNote | null = null;
if (ps.renoteId != null) {
// Fetch renote to note
- renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
+ renote = await this.notesRepository.findOne({ where: { id: ps.renoteId }, relations: ['user'] });
if (renote == null) {
logger.error('No such renote target.', { renoteId: ps.renoteId });
@@ -371,7 +399,7 @@ export default class extends Endpoint { // eslint-
let reply: MiNote | null = null;
if (ps.replyId != null) {
// Fetch reply
- reply = await this.notesRepository.findOneBy({ id: ps.replyId });
+ reply = await this.notesRepository.findOne({ where: { id: ps.replyId }, relations: ['user'] });
if (reply == null) {
logger.error('No such reply target.', { replyId: ps.replyId });
@@ -384,11 +412,8 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
- } else if ( me.isBot ) {
- const replayuser = await this.usersRepository.findOneBy({ id: reply.userId });
- if (replayuser?.isBot) {
- throw new ApiError(meta.errors.replyingToAnotherBot);
- }
+ } else if (me.isBot && reply.user!.isBot) {
+ throw new ApiError(meta.errors.replyingToAnotherBot);
}
// Check blocking
@@ -427,10 +452,28 @@ export default class extends Endpoint { // eslint-
}
}
+ let scheduledAt: Date | null = null;
+ if (ps.scheduledAt) {
+ const now = new Date();
+ scheduledAt = new Date(ps.scheduledAt);
+ scheduledAt.setMilliseconds(0);
+
+ if (scheduledAt < now) {
+ logger.error('Cannot schedule to the past.', { scheduledAt });
+ throw new ApiError(meta.errors.cannotScheduleToPast);
+ }
+
+ if (scheduledAt.getTime() - now.getTime() > ms('1year')) {
+ logger.error('Cannot schedule to the far future.', { scheduledAt });
+ throw new ApiError(meta.errors.cannotScheduleToFarFuture);
+ }
+ }
+
// 投稿を作成
try {
const note = await this.noteCreateService.create(me, {
createdAt: new Date(),
+ scheduledAt: ps.scheduledAt ? scheduledAt : null,
files: files,
poll: ps.poll ? {
choices: ps.poll.choices,
@@ -454,10 +497,18 @@ export default class extends Endpoint { // eslint-
// 1分間、リクエストの処理結果を記録
await this.redisForTimelines.set(`note:idempotent:${me.id}:${hash}`, note.id, 'EX', 60);
- logger.info('Successfully created a note.', { noteId: note.id });
- if (ps.noCreatedNote) return;
+ if (!scheduledAt) {
+ logger.info('Successfully created a note.', { noteId: note.id });
+ } else {
+ this.notificationService.createNotification(me.id, "noteScheduled", {
+ draftId: note.id,
+ });
+ logger.info('Successfully scheduled a note.', { draftId: note.id });
+ }
+
+ if (ps.noCreatedNote || scheduledAt) return;
else return {
- createdNote: await this.noteEntityService.pack(note, me),
+ createdNote: await this.noteEntityService.pack(note as MiNote, me),
};
} catch (err) {
// エラーが発生した場合、まだ処理中として記録されている場合はリクエストの処理結果を削除
@@ -468,6 +519,8 @@ export default class extends Endpoint { // eslint-
if (err instanceof IdentifiableError) {
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions);
+ if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied);
+ if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime);
}
throw err;
diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts
new file mode 100644
index 000000000000..b3627521e296
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts
@@ -0,0 +1,57 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import type { ScheduledNotesRepository } from '@/models/_.js';
+import { QueueService } from '@/core/QueueService.js';
+import { ApiError } from '@/server/api/error.js';
+import ms from 'ms';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canCreateContent',
+
+ prohibitMoved: true,
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+
+ kind: 'write:notes',
+
+ errors: {
+ noSuchDraft: {
+ message: 'No such draft',
+ code: 'NO_SUCH_DRAFT',
+ id: '91c2ad21-fb45-4f2a-ba4c-ea749b262947',
+ }
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ draftId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['draftId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.scheduledNotesRepository)
+ private scheduledNotesRepository: ScheduledNotesRepository,
+
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const draft = await this.scheduledNotesRepository.findOneBy({ id: ps.draftId, userId: me.id });
+ if (!draft) throw new ApiError(meta.errors.noSuchDraft);
+
+ await this.queueService.systemQueue.remove(`scheduledNote:${draft.id}`);
+ await this.scheduledNotesRepository.delete({ id: draft.id });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts
new file mode 100644
index 000000000000..c27a7b9a78e0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts
@@ -0,0 +1,49 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import type { ScheduledNotesRepository } from '@/models/_.js';
+import { ScheduledNoteEntityService } from '@/core/entities/ScheduledNoteEntityService.js';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canScheduleNote',
+
+ kind: 'write:notes',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'NoteDraft',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ offset: { type: 'integer', default: 0 },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.scheduledNotesRepository)
+ private scheduledNotesRepository: ScheduledNotesRepository,
+
+ private scheduledNoteEntityService: ScheduledNoteEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.scheduledNotesRepository.createQueryBuilder('draft').where('draft.userId = :userId', { userId: me.id });
+ const drafts = await query.orderBy('draft.scheduledAt', 'ASC', 'NULLS FIRST').offset(ps.offset).limit(ps.limit).getMany();
+
+ return await this.scheduledNoteEntityService.packMany(drafts, me);
+ });
+ }
+}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index e125b074ffc1..945eb27b54c9 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -3,6 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import type { MiDriveFile } from '@/models/DriveFile.js';
+import type { IPoll } from '@/models/Poll.js';
+import type { MiChannel } from '@/models/Channel.js';
+import type { MiApp } from '@/models/App.js';
+import type { MiUser } from '@/models/User.js';
+import type { MiNote } from '@/models/Note.js';
+
/**
* note - 通知オンにしているユーザーが投稿した
* follow - フォローされた
@@ -16,6 +23,9 @@
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
* roleAssigned - ロールが付与された
* achievementEarned - 実績を獲得
+ * noteScheduled - 予約投稿が予約された
+ * scheduledNotePosted - 予約投稿が投稿された
+ * scheduledNoteError - 予約投稿がエラーになった
* app - アプリ通知
* test - テスト通知(サーバー側)
*/
@@ -32,6 +42,9 @@ export const notificationTypes = [
'followRequestAccepted',
'roleAssigned',
'achievementEarned',
+ 'noteScheduled',
+ 'scheduledNotePosted',
+ 'scheduledNoteError',
'app',
'test',
] as const;
@@ -338,6 +351,36 @@ export type ModerationLogPayloads = {
}
};
+export type MinimumUser = {
+ id: MiUser['id'];
+ host: MiUser['host'];
+ username: MiUser['username'];
+ uri: MiUser['uri'];
+};
+
+export type NoteCreateOption = {
+ createdAt?: Date | null;
+ scheduledAt?: Date | null;
+ name?: string | null;
+ text?: string | null;
+ reply?: MiNote | null;
+ renote?: MiNote | null;
+ files?: MiDriveFile[] | null;
+ poll?: IPoll | null;
+ localOnly?: boolean | null;
+ reactionAcceptance?: MiNote['reactionAcceptance'];
+ cw?: string | null;
+ visibility?: string;
+ visibleUsers?: MinimumUser[] | null;
+ channel?: MiChannel | null;
+ apMentions?: MinimumUser[] | null;
+ apHashtags?: string[] | null;
+ apEmojis?: string[] | null;
+ uri?: string | null;
+ url?: string | null;
+ app?: MiApp | null;
+};
+
export type Serialized = {
[K in keyof T]:
T[K] extends Date
diff --git a/packages/frontend/src/components/MkDraftsDialog.vue b/packages/frontend/src/components/MkDraftsDialog.vue
index 755bb2cf12be..289707cd2e4e 100644
--- a/packages/frontend/src/components/MkDraftsDialog.vue
+++ b/packages/frontend/src/components/MkDraftsDialog.vue
@@ -10,14 +10,18 @@
{{ i18n.ts.drafts }}
-
+
+
+
+
+
{{ i18n.ts.nothing }}
-
+
+
+
+
+
![]()
+
{{ i18n.ts.nothing }}
+
+
+
+
+
+
+
+
+ {{ draft.channel.name }}
+
+
+ {{ draft.renote.text }}
+
+
+ {{ draft.reply.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.error }}: {{ draft.reason }}
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 1450a3022db1..f40dc1b2b5b3 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -25,6 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
+ [$style.t_noteScheduled]: notification.type === 'noteScheduled',
+ [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
+ [$style.t_scheduledNoteError]: notification.type === 'scheduledNoteError',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
}]"
>
@@ -37,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+
@@ -52,16 +58,19 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts._notification.pollEnded }}
- {{ i18n.ts._notification.newNote }}:
- {{ i18n.ts._notification.roleAssigned }}
- {{ i18n.ts._notification.achievementEarned }}
- {{ i18n.ts._notification.testNotification }}
+ {{ i18n.ts._notification.pollEnded }}
+ {{ i18n.ts._notification.newNote }}:
+ {{ i18n.ts._notification.roleAssigned }}
+ {{ i18n.ts._notification.achievementEarned }}
+ {{ i18n.ts._notification.noteScheduled }}
+ {{ i18n.ts._notification.scheduledNotePosted }}
+ {{ i18n.ts._notification.scheduledNoteError }}
+ {{ i18n.ts._notification.testNotification }}
- {{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}
- {{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}
- {{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}
- {{ notification.header }}
+ {{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}
+ {{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}
+ {{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}
+ {{ notification.header }}
@@ -98,6 +107,23 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
+
+
+
+
+
+
+
+
+ {{ notification.draft.reason }}
+
+
{{ i18n.ts.youGotNewFollower }}
@@ -300,6 +326,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none;
}
+.t_noteScheduled, .t_scheduledNotePosted, .t_scheduledNoteError {
+ padding: 3px;
+ background: var(--eventOther);
+ pointer-events: none;
+}
+
.t_roleAssigned {
padding: 3px;
background: var(--eventOther);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 9911dc0ae962..ddf4a157def0 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -81,6 +81,11 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ maxTextLength - textLength }}
+
+
+
{{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }}
+
+
@@ -94,6 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -115,7 +121,6 @@ import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js';
-import type { NoteDraftItem } from '@/types/note-draft-item.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@@ -138,8 +143,8 @@ import { uploadFile } from '@/scripts/upload.js';
import { deepClone } from '@/scripts/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js';
+import { dateTimeFormat } from '@/scripts/intl-const.js';
import { claimAchievement } from '@/scripts/achievements.js';
-import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
const $i = signinRequired();
@@ -211,6 +216,7 @@ if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
+const scheduledTime = ref
(null);
const autocompleteTextareaInput = ref(null);
const autocompleteCwInput = ref(null);
const autocompleteHashtagsInput = ref(null);
@@ -259,11 +265,15 @@ const placeholder = computed((): string => {
});
const submitText = computed((): string => {
- return renote.value
- ? i18n.ts.quote
- : reply.value
- ? i18n.ts.reply
- : i18n.ts.note;
+ if (scheduledTime.value) {
+ return i18n.ts.schedule;
+ } else if (renote.value) {
+ return i18n.ts.quote;
+ } else if (reply.value) {
+ return i18n.ts.reply;
+ } else {
+ return i18n.ts.note;
+ }
});
const textLength = computed((): number => {
@@ -389,6 +399,7 @@ function watchForDraft() {
watch(files, () => saveDraft(), { deep: true });
watch(visibility, () => saveDraft());
watch(localOnly, () => saveDraft());
+ watch(scheduledTime, () => saveDraft());
}
function checkMissingMention() {
@@ -583,10 +594,25 @@ function removeVisibleUser(user) {
visibleUsers.value = erase(user, visibleUsers.value);
}
+async function setScheduledTime() {
+ const { canceled, result: date } = await os.inputDateTime({
+ title: i18n.ts.setScheduledTime,
+ });
+ if (canceled) return;
+
+ scheduledTime.value = date;
+}
+
function clear() {
text.value = '';
+ useCw.value = false;
+ cw.value = null;
+ visibility.value = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
+ localOnly.value = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
files.value = [];
poll.value = null;
+ visibleUsers.value = [];
+ scheduledTime.value = null;
quoteId.value = null;
}
@@ -694,10 +720,16 @@ function onDrop(ev: DragEvent): void {
function saveDraft() {
if (props.instant || props.mock) return;
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
+ let scheduledAt = scheduledTime.value ?? null;
+ if (scheduledAt && (isNaN(scheduledAt.getTime()) || scheduledAt.getTime() < Date.now())) {
+ scheduledAt = null;
+ }
+
+ const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
draftData[draftKey.value] = {
updatedAt: new Date().toISOString(),
+ scheduledAt: scheduledAt?.toISOString() ?? null,
channel: channel.value ? {
id: channel.value.id,
name: channel.value.name,
@@ -737,7 +769,7 @@ function saveDraft() {
}
function deleteDraft() {
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
+ const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
delete draftData[draftKey.value];
@@ -777,7 +809,7 @@ async function openDrafts() {
}
function loadDraft(exactMatch = false) {
- const drafts = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
+ const drafts = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
const scope = exactMatch ? draftKey.value : draftKey.value.replace(`note:${draftId.value}`, 'note:');
const draft = Object.entries(drafts).filter(([k]) => k.startsWith(scope))
.map(r => ({ key: r[0], value: { ...r[1], updatedAt: new Date(r[1].updatedAt).getTime() } }))
@@ -788,7 +820,11 @@ function loadDraft(exactMatch = false) {
draftId.value = draft.key.replace(scope, '');
}
- text.value = draft.value.data.text;
+ scheduledTime.value = draft.value.scheduledAt ? new Date(draft.value.scheduledAt) : null;
+ if (scheduledTime.value && (isNaN(scheduledTime.value.getTime()) || scheduledTime.value.getTime() < Date.now())) {
+ scheduledTime.value = null;
+ }
+ text.value = draft.value.data.text ?? '';
useCw.value = draft.value.data.useCw;
cw.value = draft.value.data.cw;
visibility.value = draft.value.data.visibility;
@@ -872,6 +908,7 @@ async function post(ev?: MouseEvent) {
visibility: visibility.value,
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
reactionAcceptance: reactionAcceptance.value,
+ scheduledAt: scheduledTime.value?.getTime() ?? undefined,
noCreatedNote: true,
};
@@ -1079,6 +1116,7 @@ onMounted(() => {
visibility.value = init.visibility;
localOnly.value = init.localOnly ?? false;
quoteId.value = init.renote ? init.renote.id : null;
+ scheduledTime.value = null;
}
nextTick(() => watchForDraft());
@@ -1352,6 +1390,13 @@ defineExpose({
}
}
+.scheduledTime {
+ display: flex;
+ padding: 8px 24px;
+ gap: 4px;
+ background: var(--infoBg);
+}
+
.footer {
display: flex;
padding: 0 16px 16px 16px;
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 32b0bb353bc9..1483b9d0c094 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -67,6 +67,9 @@ export const notificationTypes = [
'followRequestAccepted',
'roleAssigned',
'achievementEarned',
+ 'noteScheduled',
+ 'scheduledNotePosted',
+ 'scheduledNoteError',
'app',
] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
@@ -75,6 +78,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
+ 'canScheduleNote',
'canInitiateConversation',
'canCreateContent',
'canUpdateContent',
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 5560335d838e..9a5243926808 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -427,28 +427,36 @@ export function inputNumber(props: {
});
}
-export function inputDate(props: {
+export function inputDateTime(props: {
title?: string | null;
text?: string | null;
placeholder?: string | null;
- default?: string | null;
+ default?: Date | null;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: Date;
}> {
+ const defaultValue = props.default ?? new Date();
+ defaultValue.setMinutes(defaultValue.getMinutes() - defaultValue.getTimezoneOffset());
+
return new Promise(resolve => {
popup(MkDialog, {
title: props.title ?? undefined,
text: props.text ?? undefined,
input: {
- type: 'date',
+ type: 'datetime-local',
placeholder: props.placeholder,
- default: props.default ?? null,
+ default: defaultValue.toISOString().slice(0, -5),
},
}, {
done: result => {
- resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
+ const date = result ? new Date(result.result) : undefined;
+ if (date && !isNaN(date.getTime())) {
+ resolve({ result: date, canceled: false });
+ } else {
+ resolve({ result: undefined, canceled: true });
+ }
},
}, 'closed');
});
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 2665bfb906c3..d4d5b50500ca 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -165,6 +165,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts._role._options.canScheduleNote }}
+
+ {{ i18n.ts._role.useBaseValue }}
+ {{ role.policies.canScheduleNote.value ? i18n.ts.yes : i18n.ts.no }}
+
+
+
+
+ {{ i18n.ts._role.useBaseValue }}
+
+
+ {{ i18n.ts.enable }}
+
+
+ {{ i18n.ts._role.priority }}
+
+
+
+
{{ i18n.ts._role._options.canInitiateConversation }}
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 62782ef75100..132ecbd31b32 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -48,6 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts._role._options.canScheduleNote }}
+ {{ policies.canScheduleNote ? i18n.ts.yes : i18n.ts.no }}
+
+ {{ i18n.ts.enable }}
+
+
+
{{ i18n.ts._role._options.canInitiateConversation }}
{{ policies.canInitiateConversation ? i18n.ts.yes : i18n.ts.no }}
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index 273250d1d000..b9e804c3f641 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -57,7 +57,7 @@ function top() {
}
async function timetravel() {
- const { canceled, result: date } = await os.inputDate({
+ const { canceled, result: date } = await os.inputDateTime({
title: i18n.ts.date,
});
if (canceled) return;
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 75419ce06d65..311493aa8440 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -221,7 +221,7 @@ function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue:
}
async function timetravel(): Promise {
- const { canceled, result: date } = await os.inputDate({
+ const { canceled, result: date } = await os.inputDateTime({
title: i18n.ts.date,
});
if (canceled) return;
diff --git a/packages/frontend/src/types/note-draft-item.ts b/packages/frontend/src/types/note-draft-item.ts
deleted file mode 100644
index a7ec5962e589..000000000000
--- a/packages/frontend/src/types/note-draft-item.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as Misskey from 'misskey-js';
-import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
-
-export type NoteDraftItem = {
- updatedAt: string;
- channel?: {
- id: string;
- name: string;
- };
- renote?: {
- id: string;
- text: string | null;
- user: {
- id: string;
- username: string;
- host: string | null;
- };
- };
- reply?: {
- id: string;
- text: string | null;
- user: {
- id: string;
- username: string;
- host: string | null;
- };
- };
- data: {
- text: string;
- useCw: boolean;
- cw: string | null;
- visibility: 'public' | 'followers' | 'home' | 'specified';
- localOnly: boolean;
- files: Misskey.entities.DriveFile[];
- poll: PollEditorModelValue | null;
- visibleUserIds?: string[];
- };
-};
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 4c03556e6cd1..27d102bd79d3 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1640,6 +1640,9 @@ declare namespace entities {
NotesCreateRequest,
NotesCreateResponse,
NotesDeleteRequest,
+ NotesScheduledCancelRequest,
+ NotesScheduledListRequest,
+ NotesScheduledListResponse,
NotesFavoritesCreateRequest,
NotesFavoritesDeleteRequest,
NotesFeaturedRequest,
@@ -1825,6 +1828,7 @@ declare namespace entities {
Announcement,
App,
Note,
+ NoteDraft,
NoteReaction,
NoteFavorite,
Notification_2 as Notification,
@@ -2573,6 +2577,9 @@ type MyAppsResponse = operations['my___apps']['responses']['200']['content']['ap
// @public (undocumented)
type Note = components['schemas']['Note'];
+// @public (undocumented)
+type NoteDraft = components['schemas']['NoteDraft'];
+
// @public (undocumented)
type NoteFavorite = components['schemas']['NoteFavorite'];
@@ -2681,6 +2688,15 @@ type NotesRequest = operations['notes']['requestBody']['content']['application/j
// @public (undocumented)
type NotesResponse = operations['notes']['responses']['200']['content']['application/json'];
+// @public (undocumented)
+type NotesScheduledCancelRequest = operations['notes___scheduled___cancel']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesScheduledListRequest = operations['notes___scheduled___list']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesScheduledListResponse = operations['notes___scheduled___list']['responses']['200']['content']['application/json'];
+
// @public (undocumented)
type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
@@ -2742,7 +2758,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented)
-export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "noteScheduled", "scheduledNotePosted", "scheduledNoteError"];
// @public (undocumented)
type Page = components['schemas']['Page'];
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 0b8ee24732ca..712755926c0c 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3129,6 +3129,28 @@ declare module '../api.js' {
credential?: string | null,
): Promise>;
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ request(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ request(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise>;
+
/**
* No description provided.
*
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 246d2c99b6c4..ab44ed7d48fb 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -418,6 +418,9 @@ import type {
NotesCreateRequest,
NotesCreateResponse,
NotesDeleteRequest,
+ NotesScheduledCancelRequest,
+ NotesScheduledListRequest,
+ NotesScheduledListResponse,
NotesFavoritesCreateRequest,
NotesFavoritesDeleteRequest,
NotesFeaturedRequest,
@@ -872,6 +875,8 @@ export type Endpoints = {
'notes/conversation': { req: NotesConversationRequest; res: NotesConversationResponse };
'notes/create': { req: NotesCreateRequest; res: NotesCreateResponse };
'notes/delete': { req: NotesDeleteRequest; res: EmptyResponse };
+ 'notes/scheduled/cancel': { req: NotesScheduledCancelRequest; res: EmptyResponse };
+ 'notes/scheduled/list': { req: NotesScheduledListRequest; res: NotesScheduledListResponse };
'notes/favorites/create': { req: NotesFavoritesCreateRequest; res: EmptyResponse };
'notes/favorites/delete': { req: NotesFavoritesDeleteRequest; res: EmptyResponse };
'notes/featured': { req: NotesFeaturedRequest; res: NotesFeaturedResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index b26acb1fa54a..b74926c876d1 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -421,6 +421,9 @@ export type NotesConversationResponse = operations['notes___conversation']['resp
export type NotesCreateRequest = operations['notes___create']['requestBody']['content']['application/json'];
export type NotesCreateResponse = operations['notes___create']['responses']['200']['content']['application/json'];
export type NotesDeleteRequest = operations['notes___delete']['requestBody']['content']['application/json'];
+export type NotesScheduledCancelRequest = operations['notes___scheduled___cancel']['requestBody']['content']['application/json'];
+export type NotesScheduledListRequest = operations['notes___scheduled___list']['requestBody']['content']['application/json'];
+export type NotesScheduledListResponse = operations['notes___scheduled___list']['responses']['200']['content']['application/json'];
export type NotesFavoritesCreateRequest = operations['notes___favorites___create']['requestBody']['content']['application/json'];
export type NotesFavoritesDeleteRequest = operations['notes___favorites___delete']['requestBody']['content']['application/json'];
export type NotesFeaturedRequest = operations['notes___featured']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index e60fe35aaef2..332eb28bfa86 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -14,6 +14,7 @@ export type Ad = components['schemas']['Ad'];
export type Announcement = components['schemas']['Announcement'];
export type App = components['schemas']['App'];
export type Note = components['schemas']['Note'];
+export type NoteDraft = components['schemas']['NoteDraft'];
export type NoteReaction = components['schemas']['NoteReaction'];
export type NoteFavorite = components['schemas']['NoteFavorite'];
export type Notification = components['schemas']['Notification'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 63e97c0f64ea..b0d2d76627ae 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -2709,6 +2709,24 @@ export type paths = {
*/
post: operations['notes___delete'];
};
+ '/notes/scheduled/cancel': {
+ /**
+ * notes/scheduled/cancel
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ post: operations['notes___scheduled___cancel'];
+ };
+ '/notes/scheduled/list': {
+ /**
+ * notes/scheduled/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ post: operations['notes___scheduled___list'];
+ };
'/notes/favorites/create': {
/**
* notes/favorites/create
@@ -4272,6 +4290,58 @@ export type components = {
clippedCount?: number;
myReaction?: string | null;
};
+ NoteDraft: {
+ /** Format: misskey:id */
+ id: string;
+ /** Format: date-time */
+ updatedAt: string;
+ /** Format: date-time */
+ scheduledAt: string | null;
+ reason?: string;
+ channel?: {
+ /** Format: misskey:id */
+ id: string;
+ name: string;
+ } | null;
+ renote?: ({
+ /** Format: misskey:id */
+ id: string;
+ text: string | null;
+ user: {
+ /** Format: misskey:id */
+ id: string;
+ username: string;
+ host: string | null;
+ };
+ }) | null;
+ reply?: ({
+ /** Format: misskey:id */
+ id: string;
+ text: string | null;
+ user: {
+ /** Format: misskey:id */
+ id: string;
+ username: string;
+ host: string | null;
+ };
+ }) | null;
+ data: {
+ text: string | null;
+ useCw: boolean;
+ cw: string | null;
+ /** @enum {string} */
+ visibility: 'public' | 'home' | 'followers' | 'specified';
+ localOnly: boolean;
+ files: components['schemas']['DriveFile'][];
+ poll: ({
+ choices: string[];
+ multiple: boolean;
+ expiresAt: number | null;
+ expiredAfter: number | null;
+ }) | null;
+ visibleUserIds?: string[];
+ };
+ };
NoteReaction: {
/**
* Format: id
@@ -4419,6 +4489,30 @@ export type components = {
/** @enum {string} */
type: 'achievementEarned';
achievement: string;
+ } | {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** @enum {string} */
+ type: 'noteScheduled';
+ draft: components['schemas']['NoteDraft'];
+ } | {
+ /** 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: 'scheduledNoteError';
+ draft: components['schemas']['NoteDraft'];
} | {
/** Format: id */
id: string;
@@ -4983,6 +5077,7 @@ export type components = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
+ canScheduleNote: boolean;
canInitiateConversation: boolean;
canCreateContent: boolean;
canUpdateContent: boolean;
@@ -20366,8 +20461,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'noteScheduled' | 'scheduledNotePosted' | 'scheduledNoteError' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'noteScheduled' | 'scheduledNotePosted' | 'scheduledNoteError' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -20434,8 +20529,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'noteScheduled' | 'scheduledNotePosted' | 'scheduledNoteError' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'noteScheduled' | 'scheduledNotePosted' | 'scheduledNoteError' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -23333,6 +23428,7 @@ export type operations = {
expiresAt?: number | null;
expiredAfter?: number | null;
}) | null;
+ scheduledAt?: number | null;
/** @default false */
noCreatedNote?: boolean;
};
@@ -23447,6 +23543,120 @@ export type operations = {
};
};
};
+ /**
+ * notes/scheduled/cancel
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ notes___scheduled___cancel: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ draftId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (without any results) */
+ 204: {
+ content: never;
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description To many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * notes/scheduled/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes*
+ */
+ notes___scheduled___list: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 10 */
+ limit?: number;
+ /** @default 0 */
+ offset?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['NoteDraft'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
/**
* notes/favorites/create
* @description No description provided.
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index d915224a97e8..5fcad5f46294 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -1,4 +1,4 @@
-export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned'] as const;
+export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'noteScheduled', 'scheduledNotePosted', 'scheduledNoteError'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
index 7d28d8a694ba..1edd528f28e1 100644
--- a/packages/sw/src/scripts/create-notification.ts
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -39,7 +39,7 @@ export async function createNotification {
+async function composeNotification(data: PushNotificationDataMap[keyof PushNotificationDataMap]): Promise<[string, NotificationOptions & { actions?: Record[], renotify?: boolean }] | null> {
const i18n = await (swLang.i18n ?? swLang.fetchLocale());
const { t } = i18n;
switch (data.type) {
@@ -60,7 +60,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
const userDetail = await cli.request('users/show', { userId: data.body.userId }, account.token);
return [t('_notification.youWereFollowed'), {
body: getUserName(data.body.user),
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('user-plus'),
data,
actions: userDetail.isFollowing ? [] : [
@@ -75,7 +75,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'mention':
return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('at'),
data,
actions: [
@@ -89,7 +89,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'reply':
return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('arrow-back-up'),
data,
actions: [
@@ -103,7 +103,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'renote':
return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('repeat'),
data,
actions: [
@@ -117,7 +117,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'quote':
return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('quote'),
data,
actions: [
@@ -137,7 +137,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'note':
return [t('_notification.newNote') + ': ' + getUserName(data.body.user), {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
data,
}];
@@ -164,7 +164,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
const tag = `reaction:${data.body.note.id}`;
return [`${reaction} ${getUserName(data.body.user)}`, {
body: data.body.note.text ?? '',
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
tag,
badge,
data,
@@ -180,7 +180,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'receiveFollowRequest':
return [t('_notification.youReceivedFollowRequest'), {
body: getUserName(data.body.user),
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('user-plus'),
data,
actions: [
@@ -198,11 +198,18 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'followRequestAccepted':
return [t('_notification.yourFollowRequestAccepted'), {
body: getUserName(data.body.user),
- icon: data.body.user.avatarUrl,
+ icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('circle-check'),
data,
}];
+ case 'pollEnded':
+ return [t('_notification.pollEnded'), {
+ body: data.body.note.text ?? '',
+ badge: iconUrl('chart-arrows'),
+ data,
+ }];
+
case 'achievementEarned':
return [t('_notification.achievementEarned'), {
body: t(`_achievements._types._${data.body.achievement}.title`),
@@ -211,10 +218,32 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
tag: `achievement:${data.body.achievement}`,
}];
- case 'pollEnded':
- return [t('_notification.pollEnded'), {
+ case 'noteScheduled':
+ return [t('_notification.noteScheduled'), {
+ body: data.body.draft.data.text ?? '',
+ badge: iconUrl('calendar-clock'),
+ data,
+ }];
+
+ case 'scheduledNotePosted':
+ return [t('_notification.scheduledNotePosted'), {
body: data.body.note.text ?? '',
- badge: iconUrl('chart-arrows'),
+ badge: iconUrl('calendar-check'),
+ data,
+ }];
+
+ case 'scheduledNoteError':
+ return [t('_notification.scheduledNoteError'), {
+ body: data.body.draft.reason ?? '',
+ badge: iconUrl('calendar-exclamation'),
+ data,
+ }];
+
+ case 'roleAssigned':
+ return [t('_notification.roleAssigned'), {
+ body: data.body.role.name,
+ icon: data.body.role.iconUrl ?? undefined,
+ badge: iconUrl('badges'),
data,
}];
@@ -238,7 +267,7 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
case 'unreadAntennaNote':
return [t('_notification.unreadAntennaNote', { name: data.body.antenna.name }), {
body: `${getUserName(data.body.note.user)}: ${data.body.note.text ?? ''}`,
- icon: data.body.note.user.avatarUrl,
+ icon: data.body.note.user.avatarUrl ?? undefined,
badge: iconUrl('antenna'),
tag: `antenna:${data.body.antenna.id}`,
data,
diff --git a/packages/sw/src/types.ts b/packages/sw/src/types.ts
index fac3e707d811..5b38743f2c88 100644
--- a/packages/sw/src/types.ts
+++ b/packages/sw/src/types.ts
@@ -50,4 +50,9 @@ export type BadgeNames =
| 'quote'
| 'repeat'
| 'user-plus'
- | 'users';
+ | 'users'
+ | 'badges'
+ | 'calendar-clock'
+ | 'calendar-check'
+ | 'calendar-exclamation'
+ ;
From 6993e5b7fd1f522c6f06007f08e7991571a0dfc5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=82=E3=82=8F=E3=82=8F=E3=82=8F=E3=81=A8=E3=83=BC?=
=?UTF-8?q?=E3=81=AB=E3=82=85?=
<17376330+u1-liquid@users.noreply.github.com>
Date: Thu, 16 Jan 2025 23:05:02 +0900
Subject: [PATCH 09/10] =?UTF-8?q?perf(frontend/css):=20MkNote=20=E5=8F=8A?=
=?UTF-8?q?=E3=81=B3=20MkNotification=20=E3=81=AB=20content-visibility=20?=
=?UTF-8?q?=E3=82=92=E9=81=A9=E7=94=A8=20(MisskeyIO#892)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/frontend/src/components/MkNote.vue | 9 ++-------
packages/frontend/src/components/MkNotification.vue | 3 +++
2 files changed, 5 insertions(+), 7 deletions(-)
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 54e7e161faff..6a67a98a1d0c 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -569,13 +569,8 @@ function emitUpdReaction(emoji: string, delta: number) {
overflow: clip;
contain: content;
- // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
- // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
- // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
- // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
- // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
- //content-visibility: auto;
- //contain-intrinsic-size: 0 128px;
+ content-visibility: auto;
+ contain-intrinsic-size: auto none auto 128px;
&:focus-visible {
outline: none;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index f40dc1b2b5b3..5bf5188c2778 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -218,6 +218,9 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
overflow-wrap: break-word;
display: flex;
contain: content;
+
+ content-visibility: auto;
+ contain-intrinsic-size: auto none auto 100px;
}
.head {
From 86209cf4b33d6d1efca81377d11a79258be43d83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=82=E3=82=8F=E3=82=8F=E3=82=8F=E3=81=A8=E3=83=BC?=
=?UTF-8?q?=E3=81=AB=E3=82=85?=
<17376330+u1-liquid@users.noreply.github.com>
Date: Thu, 16 Jan 2025 23:29:14 +0900
Subject: [PATCH 10/10] Bump up version to 2024.5.0-io.7 (MisskeyIO#893)
---
package.json | 2 +-
packages/misskey-js/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index f67ad749fcdf..94796d2e388d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "misskey",
- "version": "2024.5.0-io.6a",
+ "version": "2024.5.0-io.7",
"codename": "nasubi",
"repository": {
"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 12f583477f14..697d0c9ff8a0 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
- "version": "2024.5.0-io.6a",
+ "version": "2024.5.0-io.7",
"description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts",
"exports": {