Merge branch 'develop' into feature/2024.10

This commit is contained in:
dakkar 2024-12-12 13:04:51 +00:00
commit 6d4ae93592
54 changed files with 1630 additions and 20 deletions

28
locales/index.d.ts vendored
View file

@ -7033,6 +7033,10 @@ export interface Locale extends ILocale {
* Can import notes * Can import notes
*/ */
"canImportNotes": string; "canImportNotes": string;
/**
* Maximum number of scheduled notes
*/
"scheduleNoteMax": string;
}; };
"_condition": { "_condition": {
/** /**
@ -8502,6 +8506,14 @@ export interface Locale extends ILocale {
* *
*/ */
"write:report-abuse": string; "write:report-abuse": string;
/**
* View your list of scheduled notes
*/
"read:notes-schedule": string;
/**
* Compose or delete scheduled notes
*/
"write:notes-schedule": string;
}; };
"_auth": { "_auth": {
/** /**
@ -9644,6 +9656,14 @@ export interface Locale extends ILocale {
* Note got edited * Note got edited
*/ */
"edited": string; "edited": string;
/**
* Posting scheduled note failed
*/
"scheduledNoteFailed": string;
/**
* Scheduled Note was posted
*/
"scheduledNotePosted": string;
}; };
"_deck": { "_deck": {
/** /**
@ -11558,6 +11578,14 @@ export interface Locale extends ILocale {
* Select a follow relationship... * Select a follow relationship...
*/ */
"selectFollowRelationship": string; "selectFollowRelationship": string;
/**
* Schedule a note
*/
"schedulePost": string;
/**
* List of scheduled notes
*/
"schedulePostList": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ScheduleNote1699437894737 {
name = 'ScheduleNote1699437894737'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP TABLE "note_schedule"`);
}
}

View file

@ -16,6 +16,7 @@ import {
RelationshipJobData, RelationshipJobData,
UserWebhookDeliverJobData, UserWebhookDeliverJobData,
SystemWebhookDeliverJobData, SystemWebhookDeliverJobData,
ScheduleNotePostJobData,
} from '../queue/types.js'; } from '../queue/types.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -28,6 +29,7 @@ export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue;
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>; export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>; export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
const $system: Provider = { const $system: Provider = {
provide: 'queue:system', provide: 'queue:system',
@ -83,6 +85,12 @@ const $systemWebhookDeliver: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $scheduleNotePost: Provider = {
provide: 'queue:scheduleNotePost',
useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)),
inject: [DI.config],
};
@Module({ @Module({
imports: [ imports: [
], ],
@ -96,6 +104,7 @@ const $systemWebhookDeliver: Provider = {
$objectStorage, $objectStorage,
$userWebhookDeliver, $userWebhookDeliver,
$systemWebhookDeliver, $systemWebhookDeliver,
$scheduleNotePost,
], ],
exports: [ exports: [
$system, $system,
@ -107,6 +116,7 @@ const $systemWebhookDeliver: Provider = {
$objectStorage, $objectStorage,
$userWebhookDeliver, $userWebhookDeliver,
$systemWebhookDeliver, $systemWebhookDeliver,
$scheduleNotePost,
], ],
}) })
export class QueueModule implements OnApplicationShutdown { export class QueueModule implements OnApplicationShutdown {
@ -120,6 +130,7 @@ export class QueueModule implements OnApplicationShutdown {
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
) {} ) {}
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@ -136,6 +147,7 @@ export class QueueModule implements OnApplicationShutdown {
this.objectStorageQueue.close(), this.objectStorageQueue.close(),
this.userWebhookDeliverQueue.close(), this.userWebhookDeliverQueue.close(),
this.systemWebhookDeliverQueue.close(), this.systemWebhookDeliverQueue.close(),
this.scheduleNotePostQueue.close(),
]); ]);
} }

View file

@ -34,6 +34,7 @@ import type {
SystemQueue, SystemQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
ScheduleNotePostQueue,
} from './QueueModule.js'; } from './QueueModule.js';
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@ -54,6 +55,7 @@ export class QueueService {
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
) { ) {
this.systemQueue.add('tickCharts', { this.systemQueue.add('tickCharts', {
}, { }, {

View file

@ -36,6 +36,7 @@ export type RolePolicies = {
ltlAvailable: boolean; ltlAvailable: boolean;
btlAvailable: boolean; btlAvailable: boolean;
canPublicNote: boolean; canPublicNote: boolean;
scheduleNoteMax: number;
mentionLimit: number; mentionLimit: number;
canInvite: boolean; canInvite: boolean;
inviteLimit: number; inviteLimit: number;
@ -72,6 +73,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true, ltlAvailable: true,
btlAvailable: false, btlAvailable: false,
canPublicNote: true, canPublicNote: true,
scheduleNoteMax: 5,
mentionLimit: 20, mentionLimit: 20,
canInvite: false, canInvite: false,
inviteLimit: 0, inviteLimit: 0,
@ -379,6 +381,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)),
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),

View file

@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]); const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]);
@Injectable() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {
@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity, exportedEntity: notification.exportedEntity,
fileId: notification.fileId, fileId: notification.fileId,
} : {}), } : {}),
...(notification.type === 'scheduledNoteFailed' ? {
reason: notification.reason,
} : {}),
...(notification.type === 'app' ? { ...(notification.type === 'app' ? {
body: notification.customBody, body: notification.customBody,
header: notification.customHeader, header: notification.customHeader,

View file

@ -86,5 +86,6 @@ export const DI = {
noteEditRepository: Symbol('noteEditRepository'), noteEditRepository: Symbol('noteEditRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'),
noteScheduleRepository: Symbol('noteScheduleRepository'),
//#endregion //#endregion
}; };

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
import { MiNote } from '@/models/Note.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
import type { MiDriveFile } from './DriveFile.js';
type MinimumUser = {
id: MiUser['id'];
host: MiUser['host'];
username: MiUser['username'];
uri: MiUser['uri'];
};
export type MiScheduleNoteType={
visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUsers: MinimumUser[];
channel?: MiChannel['id'];
poll: {
multiple: boolean;
choices: string[];
/** Date.toISOString() */
expiresAt: string | null
} | undefined;
renote?: MiNote['id'];
localOnly: boolean;
cw?: string | null;
reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
files: MiDriveFile['id'][];
text?: string | null;
reply?: MiNote['id'];
apMentions?: MinimumUser[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
}
@Entity('note_schedule')
export class MiNoteSchedule {
@PrimaryColumn(id())
public id: string;
@Column('jsonb')
public note: MiScheduleNoteType;
@Index()
@Column('varchar', {
length: 260,
})
public userId: MiUser['id'];
@Column('timestamp with time zone')
public scheduledAt: Date;
}

View file

@ -126,6 +126,16 @@ export type MiNotification = {
createdAt: string; createdAt: string;
notifierId: MiUser['id']; notifierId: MiUser['id'];
noteId: MiNote['id']; noteId: MiNote['id'];
} | {
type: 'scheduledNoteFailed';
id: string;
createdAt: string;
reason: string;
} | {
type: 'scheduledNotePosted';
id: string;
createdAt: string;
noteId: MiNote['id'];
}; };
export type MiGroupedNotification = MiNotification | { export type MiGroupedNotification = MiNotification | {

View file

@ -43,6 +43,7 @@ import {
MiNote, MiNote,
MiNoteFavorite, MiNoteFavorite,
MiNoteReaction, MiNoteReaction,
MiNoteSchedule,
MiNoteThreadMuting, MiNoteThreadMuting,
MiNoteUnread, MiNoteUnread,
MiPage, MiPage,
@ -509,6 +510,12 @@ const $reversiGamesRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $noteScheduleRepository: Provider = {
provide: DI.noteScheduleRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository<MiNoteSchedule>),
inject: [DI.db],
};
@Module({ @Module({
imports: [], imports: [],
providers: [ providers: [
@ -583,6 +590,7 @@ const $reversiGamesRepository: Provider = {
$noteEditRepository, $noteEditRepository,
$bubbleGameRecordsRepository, $bubbleGameRecordsRepository,
$reversiGamesRepository, $reversiGamesRepository,
$noteScheduleRepository,
], ],
exports: [ exports: [
$usersRepository, $usersRepository,
@ -656,6 +664,7 @@ const $reversiGamesRepository: Provider = {
$noteEditRepository, $noteEditRepository,
$bubbleGameRecordsRepository, $bubbleGameRecordsRepository,
$reversiGamesRepository, $reversiGamesRepository,
$noteScheduleRepository,
], ],
}) })
export class RepositoryModule { export class RepositoryModule {

View file

@ -81,6 +81,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { NoteEdit } from '@/models/NoteEdit.js'; import { NoteEdit } from '@/models/NoteEdit.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> { export interface MiRepository<T extends ObjectLiteral> {
@ -160,6 +161,7 @@ export {
MiNote, MiNote,
MiNoteFavorite, MiNoteFavorite,
MiNoteReaction, MiNoteReaction,
MiNoteSchedule,
MiNoteThreadMuting, MiNoteThreadMuting,
MiNoteUnread, MiNoteUnread,
MiPage, MiPage,
@ -271,3 +273,4 @@ export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMem
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>; export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>; export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>; export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
export type NoteScheduleRepository = Repository<MiNoteSchedule>;

View file

@ -379,6 +379,45 @@ export const packedNotificationSchema = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['scheduledNoteFailed'],
},
reason: {
type: 'string',
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['scheduledNotePosted'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
note: {
type: 'object',
ref: 'Note',
optional: false, nullable: false,
},
},
}, { }, {
type: 'object', type: 'object',
properties: { properties: {

View file

@ -296,6 +296,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
scheduleNoteMax: {
type: 'integer',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -79,6 +79,7 @@ import { MiUserMemo } from '@/models/UserMemo.js';
import { NoteEdit } from '@/models/NoteEdit.js'; import { NoteEdit } from '@/models/NoteEdit.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
import { Config } from '@/config.js'; import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js'; import MisskeyLogger from '@/logger.js';
@ -158,6 +159,7 @@ export const entities = [
MiNote, MiNote,
MiNoteFavorite, MiNoteFavorite,
MiNoteReaction, MiNoteReaction,
MiNoteSchedule,
MiNoteThreadMuting, MiNoteThreadMuting,
MiNoteUnread, MiNoteUnread,
MiPage, MiPage,

View file

@ -43,6 +43,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
@Module({ @Module({
imports: [ imports: [
@ -88,6 +89,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
CheckExpiredMutingsProcessorService, CheckExpiredMutingsProcessorService,
CheckModeratorsActivityProcessorService, CheckModeratorsActivityProcessorService,
QueueProcessorService, QueueProcessorService,
ScheduleNotePostProcessorService,
], ],
exports: [ exports: [
QueueProcessorService, QueueProcessorService,

View file

@ -45,6 +45,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QUEUE, baseQueueOptions } from './const.js'; import { QUEUE, baseQueueOptions } from './const.js';
import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
@ -87,6 +88,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private relationshipQueueWorker: Bull.Worker; private relationshipQueueWorker: Bull.Worker;
private objectStorageQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker;
private endedPollNotificationQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker;
private schedulerNotePostQueueWorker: Bull.Worker;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -128,6 +130,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService, private cleanProcessorService: CleanProcessorService,
private scheduleNotePostProcessorService: ScheduleNotePostProcessorService,
) { ) {
this.logger = this.queueLoggerService.logger; this.logger = this.queueLoggerService.logger;
@ -533,6 +536,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
}); });
} }
//#endregion //#endregion
//#region schedule note post
{
this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), {
...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST),
autorun: false,
});
}
//#endregion
} }
@bindThis @bindThis
@ -547,6 +559,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(), this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(), this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(), this.endedPollNotificationQueueWorker.run(),
this.schedulerNotePostQueueWorker.run(),
]); ]);
} }
@ -562,6 +575,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(), this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(), this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(), this.endedPollNotificationQueueWorker.close(),
this.schedulerNotePostQueueWorker.close(),
]); ]);
} }

View file

@ -16,6 +16,7 @@ export const QUEUE = {
OBJECT_STORAGE: 'objectStorage', OBJECT_STORAGE: 'objectStorage',
USER_WEBHOOK_DELIVER: 'userWebhookDeliver', USER_WEBHOOK_DELIVER: 'userWebhookDeliver',
SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver',
SCHEDULE_NOTE_POST: 'scheduleNotePost',
}; };
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {

View file

@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiScheduleNoteType } from '@/models/NoteSchedule.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { ScheduleNotePostJobData } from '../types.js';
@Injectable()
export class ScheduleNotePostProcessorService {
private logger: Logger;
constructor(
@Inject(DI.noteScheduleRepository)
private noteScheduleRepository: NoteScheduleRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private noteCreateService: NoteCreateService,
private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
}
@bindThis
private async isValidNoteSchedule(note: MiScheduleNoteType, id: string): Promise<boolean> {
const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined;
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
if (note.reply && !reply) {
this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
reason: 'Replied to note on your scheduled note no longer exists',
});
return false;
}
if (note.renote && !renote) {
this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
reason: 'A quoted note from one of your scheduled notes no longer exists',
});
return false;
}
if (note.channel && !channel) {
this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
this.notificationService.createNotification(id, 'scheduledNoteFailed', {
reason: 'An attached channel on your scheduled note no longer exists',
});
return false;
}
return true;
}
@bindThis
public async process(job: Bull.Job<ScheduleNotePostJobData>): Promise<void> {
this.noteScheduleRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => {
if (!data) {
this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`);
} else {
const me = await this.usersRepository.findOneBy({ id: data.userId });
const note = data.note;
const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined;
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
let files: MiDriveFile[] = [];
const fileIds = note.files;
if (fileIds.length > 0 && me) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
}
if (!data.userId || !me) {
this.logger.warn('Schedule Note Failed Reason: User Not Found');
await this.noteScheduleRepository.remove(data);
return;
}
if (!await this.isValidNoteSchedule(note, me.id)) {
await this.noteScheduleRepository.remove(data);
return;
}
if (note.files.length !== files.length) {
this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: 'Some attached files on your scheduled note no longer exist',
});
await this.noteScheduleRepository.remove(data);
return;
}
const createdNote = await this.noteCreateService.create(me, {
...note,
createdAt: new Date(),
files,
poll: note.poll ? {
choices: note.poll.choices,
multiple: note.poll.multiple,
expiresAt: note.poll.expiresAt ? new Date(note.poll.expiresAt) : null,
} : undefined,
reply,
renote,
channel,
}).catch(async (err: IdentifiableError) => {
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: err.message,
});
await this.noteScheduleRepository.remove(data);
throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`);
});
await this.noteScheduleRepository.remove(data);
this.notificationService.createNotification(me.id, 'scheduledNotePosted', {
noteId: createdNote.id,
});
}
});
}
}

View file

@ -155,3 +155,7 @@ export type UserWebhookDeliverJobData = {
export type ThinUser = { export type ThinUser = {
id: MiUser['id']; id: MiUser['id'];
}; };
export type ScheduleNotePostJobData = {
scheduleNoteId: MiNote['id'];
}

View file

@ -313,6 +313,9 @@ import * as ep___notes_renotes from './endpoints/notes/renotes.js';
import * as ep___notes_replies from './endpoints/notes/replies.js'; import * as ep___notes_replies from './endpoints/notes/replies.js';
import * as ep___notes_edit from './endpoints/notes/edit.js'; import * as ep___notes_edit from './endpoints/notes/edit.js';
import * as ep___notes_versions from './endpoints/notes/versions.js'; import * as ep___notes_versions from './endpoints/notes/versions.js';
import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js';
import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js';
import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js';
import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js';
import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_search from './endpoints/notes/search.js';
import * as ep___notes_show from './endpoints/notes/show.js'; import * as ep___notes_show from './endpoints/notes/show.js';
@ -715,6 +718,9 @@ const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete'
const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default }; const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default };
const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default };
const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default };
const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default };
const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default };
const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default };
const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default };
const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default };
const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default };
@ -1123,6 +1129,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_like, $notes_like,
$notes_renotes, $notes_renotes,
$notes_replies, $notes_replies,
$notes_schedule_create,
$notes_schedule_delete,
$notes_schedule_list,
$notes_searchByTag, $notes_searchByTag,
$notes_search, $notes_search,
$notes_show, $notes_show,
@ -1524,6 +1533,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_like, $notes_like,
$notes_renotes, $notes_renotes,
$notes_replies, $notes_replies,
$notes_schedule_create,
$notes_schedule_delete,
$notes_schedule_list,
$notes_searchByTag, $notes_searchByTag,
$notes_search, $notes_search,
$notes_show, $notes_show,

View file

@ -318,6 +318,9 @@ import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete
import * as ep___notes_like from './endpoints/notes/like.js'; import * as ep___notes_like from './endpoints/notes/like.js';
import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js';
import * as ep___notes_replies from './endpoints/notes/replies.js'; import * as ep___notes_replies from './endpoints/notes/replies.js';
import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js';
import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js';
import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js';
import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js';
import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_search from './endpoints/notes/search.js';
import * as ep___notes_show from './endpoints/notes/show.js'; import * as ep___notes_show from './endpoints/notes/show.js';
@ -720,6 +723,9 @@ const eps = [
['notes/like', ep___notes_like], ['notes/like', ep___notes_like],
['notes/renotes', ep___notes_renotes], ['notes/renotes', ep___notes_renotes],
['notes/replies', ep___notes_replies], ['notes/replies', ep___notes_replies],
['notes/schedule/create', ep___notes_schedule_create],
['notes/schedule/delete', ep___notes_schedule_delete],
['notes/schedule/list', ep___notes_schedule_list],
['notes/search-by-tag', ep___notes_searchByTag], ['notes/search-by-tag', ep___notes_searchByTag],
['notes/search', ep___notes_search], ['notes/search', ep___notes_search],
['notes/show', ep___notes_show], ['notes/show', ep___notes_show],

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -55,6 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const deliverJobCounts = await this.deliverQueue.getJobCounts(); const deliverJobCounts = await this.deliverQueue.getJobCounts();

View file

@ -0,0 +1,370 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { isPureRenote } from '@/misc/is-renote.js';
import type { MiUser } from '@/models/User.js';
import type {
UsersRepository,
NotesRepository,
BlockingsRepository,
DriveFilesRepository,
ChannelsRepository,
NoteScheduleRepository,
} from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiChannel } from '@/models/Channel.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
import { IdService } from '@/core/IdService.js';
import { MiScheduleNoteType } from '@/models/NoteSchedule.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 300,
},
kind: 'write:notes-schedule',
errors: {
scheduleNoteMax: {
message: 'Schedule note max.',
code: 'SCHEDULE_NOTE_MAX',
id: '168707c3-e7da-4031-989e-f42aa3a274b2',
},
noSuchRenoteTarget: {
message: 'No such renote target.',
code: 'NO_SUCH_RENOTE_TARGET',
id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4',
},
cannotReRenote: {
message: 'You can not Renote a pure Renote.',
code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
},
cannotRenoteDueToVisibility: {
message: 'You can not Renote due to target visibility.',
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
},
noSuchReplyTarget: {
message: 'No such reply target.',
code: 'NO_SUCH_REPLY_TARGET',
id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
},
cannotReplyToPureRenote: {
message: 'You can not reply to a pure Renote.',
code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
},
cannotCreateAlreadyExpiredPoll: {
message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
id: '04da457d-b083-4055-9082-955525eda5a5',
},
cannotCreateAlreadyExpiredSchedule: {
message: 'Schedule is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE',
id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07',
},
noSuchChannel: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
},
noSuchSchedule: {
message: 'No such schedule.',
code: 'NO_SUCH_SCHEDULE',
id: '44dee229-8da1-4a61-856d-e3a4bbc12032',
},
youHaveBeenBlocked: {
message: 'You have been blocked by this user.',
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
noSuchFile: {
message: 'Some files are not found.',
code: 'NO_SUCH_FILE',
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
},
cannotRenoteOutsideOfChannel: {
message: 'Cannot renote outside of channel.',
code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
id: '33510210-8452-094c-6227-4a6c05d99f00',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
visibleUserIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
// anyOf内にバリデーションを書いても最初の一つしかチェックされない
// See https://github.com/misskey-dev/misskey/pull/10082
text: {
type: 'string',
minLength: 1,
nullable: true,
},
fileIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
mediaIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
poll: {
type: 'object',
nullable: true,
properties: {
choices: {
type: 'array',
uniqueItems: true,
minItems: 2,
maxItems: 10,
items: { type: 'string', minLength: 1, maxLength: 50 },
},
multiple: { type: 'boolean' },
expiresAt: { type: 'integer', nullable: true },
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
required: ['choices'],
},
scheduleNote: {
type: 'object',
nullable: false,
properties: {
scheduledAt: { type: 'integer', nullable: false },
},
},
},
// (re)note with text, files and poll are optional
anyOf: [
{ required: ['text'] },
{ required: ['renoteId'] },
{ required: ['fileIds'] },
{ required: ['mediaIds'] },
{ required: ['poll'] },
],
required: ['scheduleNote'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteScheduleRepository)
private noteScheduleRepository: NoteScheduleRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private queueService: QueueService,
private roleService: RoleService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id });
const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax;
if (scheduleNoteCount >= scheduleNoteMax) {
throw new ApiError(meta.errors.scheduleNoteMax);
}
let visibleUsers: MiUser[] = [];
if (ps.visibleUserIds) {
visibleUsers = await this.usersRepository.findBy({
id: In(ps.visibleUserIds),
});
}
let files: MiDriveFile[] = [];
const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
if (fileIds != null) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchFile);
}
}
let renote: MiNote | null = null;
if (ps.renoteId != null) {
// Fetch renote to note
renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
if (renote == null) {
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (isPureRenote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
}
// Check blocking
if (renote.userId !== me.id) {
const blockExist = await this.blockingsRepository.exist({
where: {
blockerId: renote.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
if (renote.visibility === 'followers' && renote.userId !== me.id) {
// 他人のfollowers noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
}
let reply: MiNote | null = null;
if (ps.replyId != null) {
// Fetch reply
reply = await this.notesRepository.findOneBy({ id: ps.replyId });
if (reply == null) {
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isPureRenote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
}
// Check blocking
if (reply.userId !== me.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
}
if (ps.poll) {
let scheduleNote_scheduledAt = Date.now();
if (typeof ps.scheduleNote.scheduledAt === 'number') {
scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt;
}
if (typeof ps.poll.expiresAt === 'number') {
if (ps.poll.expiresAt < scheduleNote_scheduledAt) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
}
} else if (typeof ps.poll.expiredAfter === 'number') {
ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter;
}
}
if (typeof ps.scheduleNote.scheduledAt === 'number') {
if (ps.scheduleNote.scheduledAt < Date.now()) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
}
} else {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
}
const note: MiScheduleNoteType = {
files: files.map(f => f.id),
poll: ps.poll ? {
choices: ps.poll.choices,
multiple: ps.poll.multiple ?? false,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null,
} : undefined,
text: ps.text ?? undefined,
reply: reply?.id,
renote: renote?.id,
cw: ps.cw,
localOnly: false,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
visibleUsers,
apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
};
if (ps.scheduleNote.scheduledAt) {
me.token = null;
const noteId = this.idService.gen(new Date().getTime());
await this.noteScheduleRepository.insert({
id: noteId,
note: note,
userId: me.id,
scheduledAt: new Date(ps.scheduleNote.scheduledAt),
});
const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now();
await this.queueService.ScheduleNotePostQueue.add(String(delay), {
scheduleNoteId: noteId,
}, {
delay,
removeOnComplete: true,
jobId: `schedNote:${noteId}`,
});
}
return '';
});
}
}

View file

@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import type { NoteScheduleRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'write:notes-schedule',
limit: {
duration: ms('1hour'),
max: 300,
},
errors: {
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f',
},
permissionDenied: {
message: 'Permission denied.',
code: 'PERMISSION_DENIED',
id: 'c0da2fed-8f61-4c47-a41d-431992607b5c',
httpStatusCode: 403,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
},
required: ['noteId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.noteScheduleRepository)
private noteScheduleRepository: NoteScheduleRepository,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId });
if (note === null) {
throw new ApiError(meta.errors.noSuchNote);
}
if (note.userId !== me.id) {
throw new ApiError(meta.errors.permissionDenied);
}
await this.noteScheduleRepository.delete({ id: ps.noteId });
await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${ps.noteId}`);
});
}
}

View file

@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { QueryService } from '@/core/QueryService.js';
import { Packed } from '@/misc/json-schema.js';
import { noteVisibilities } from '@/types.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'read:notes-schedule',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: { type: 'string', format: 'misskey:id', optional: false, nullable: false },
note: {
type: 'object',
optional: false, nullable: false,
properties: {
createdAt: { type: 'string', optional: false, nullable: false },
text: { type: 'string', optional: true, nullable: false },
cw: { type: 'string', optional: true, nullable: true },
fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } },
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false },
visibleUsers: {
type: 'array', optional: false, nullable: false, items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
},
user: {
type: 'object',
optional: false, nullable: false,
ref: 'User',
},
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
isSchedule: { type: 'boolean', optional: false, nullable: false },
},
},
userId: { type: 'string', optional: false, nullable: false },
scheduledAt: { type: 'string', optional: false, nullable: false },
},
},
},
limit: {
duration: ms('1hour'),
max: 300,
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.noteScheduleRepository)
private noteScheduleRepository: NoteScheduleRepository,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.userId = :userId', { userId: me.id });
const scheduleNotes = await query.limit(ps.limit).getMany();
const user = await this.userEntityService.pack(me, me);
const scheduleNotesPack: {
id: string;
note: {
text?: string;
cw?: string|null;
fileIds: string[];
visibility: typeof noteVisibilities[number];
visibleUsers: Packed<'UserLite'>[];
reactionAcceptance: MiNote['reactionAcceptance'];
user: Packed<'User'>;
createdAt: string;
isSchedule: boolean;
};
userId: string;
scheduledAt: string;
}[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => {
return {
...item,
scheduledAt: item.scheduledAt.toISOString(),
note: {
...item.note,
text: item.note.text ?? '',
user: user,
visibility: item.note.visibility ?? 'public',
reactionAcceptance: item.note.reactionAcceptance ?? null,
visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [],
fileIds: item.note.files ? item.note.files : [],
files: await this.driveFileEntityService.packManyByIds(item.note.files),
createdAt: item.scheduledAt.toISOString(),
isSchedule: true,
id: item.id,
},
};
}));
return scheduleNotesPack;
});
}
}

View file

@ -34,6 +34,7 @@ import type {
SystemQueue, SystemQueue,
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
ScheduleNotePostQueue,
} from '@/core/QueueModule.js'; } from '@/core/QueueModule.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@ -143,6 +144,7 @@ export class ClientServerService {
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
} }
@ -274,6 +276,7 @@ export class ClientServerService {
this.objectStorageQueue, this.objectStorageQueue,
this.userWebhookDeliverQueue, this.userWebhookDeliverQueue,
this.systemWebhookDeliverQueue, this.systemWebhookDeliverQueue,
this.scheduleNotePostQueue,
].map(q => new BullMQAdapter(q)), ].map(q => new BullMQAdapter(q)),
serverAdapter: bullBoardServerAdapter, serverAdapter: bullBoardServerAdapter,
}); });

View file

@ -37,6 +37,8 @@ export const notificationTypes = [
'achievementEarned', 'achievementEarned',
'exportCompleted', 'exportCompleted',
'login', 'login',
'scheduledNoteFailed',
'scheduledNotePosted',
'app', 'app',
'test', 'test',
] as const; ] as const;

View file

@ -27,6 +27,8 @@
/> />
<meta property="og:site_name" content="[DEV BUILD] Sharkey" /> <meta property="og:site_name" content="[DEV BUILD] Sharkey" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel='stylesheet' href='/assets/phosphor-icons/bold/style.css'>
<link rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css'>
</head> </head>
<body> <body>

View file

@ -132,6 +132,8 @@ export const notificationTypes = [
'test', 'test',
'app', 'app',
'edited', 'edited',
'scheduledNoteFailed',
'scheduledNotePosted',
] as const; ] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
@ -141,6 +143,7 @@ export const ROLE_POLICIES = [
'btlAvailable', 'btlAvailable',
'canPublicNote', 'canPublicNote',
'canImportNotes', 'canImportNotes',
'scheduleNoteMax',
'mentionLimit', 'mentionLimit',
'canInvite', 'canInvite',
'inviteLimit', 'inviteLimit',

View file

@ -195,6 +195,12 @@ onMounted(() => {
textBox.textContent = pswp.currSlide?.data.comment; textBox.textContent = pswp.currSlide?.data.comment;
}); });
// `passive: true` is for Safari compatibility, apparently
const stopEvent = name => textBox.addEventListener(name, event => event.stopPropagation(), { passive: true });
stopEvent('wheel');
stopEvent('pointerdown');
stopEvent('pointercancel');
}, },
}); });
}); });

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>: </MkA>:
<Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/> <Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/>
</div> </div>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <MkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
<!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->

View file

@ -46,7 +46,10 @@ import { popupMenu } from '@/os.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = defineProps<{ const props = defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note & {
isSchedule?: boolean
};
scheduled?: boolean;
}>(); }>();
const menuVersionsButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>();

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="$style.root"> <div v-if="!isDeleted" :class="$style.root">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/> <MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main"> <div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
@ -15,6 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="note.cw == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
<div v-if="note.isSchedule" style="margin-top: 10px;">
<MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.deleteAndEdit }}</MkButton>
<MkButton :class="$style.button" inline danger @click.stop.prevent="deleteScheduleNote()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -24,18 +28,58 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue'; import MkCwButton from '@/components/MkCwButton.vue';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note & {
isSchedule? : boolean,
scheduledNoteId?: string
};
expandAllCws?: boolean; expandAllCws?: boolean;
hideFiles?: boolean; hideFiles?: boolean;
}>(); }>();
let showContent = ref(defaultStore.state.uncollapseCW); let showContent = ref(defaultStore.state.uncollapseCW);
const isDeleted = ref(false);
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
}>();
async function deleteScheduleNote() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
okText: i18n.ts.delete,
cancelText: i18n.ts.cancel,
});
if (canceled) return;
await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id })
.then(() => {
isDeleted.value = true;
});
}
async function editScheduleNote() {
await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id })
.then(() => {
isDeleted.value = true;
});
await os.post({
initialNote: props.note,
renote: props.note.renote,
reply: props.note.reply,
channel: props.note.channel,
});
emit('editScheduleNote');
}
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws; if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
@ -50,6 +94,11 @@ watch(() => props.expandAllCws, (expandAllCws) => {
font-size: 0.95em; font-size: 0.95em;
} }
.button{
margin-right: var(--margin);
margin-bottom: var(--margin);
}
.avatar { .avatar {
flex-shrink: 0; flex-shrink: 0;
display: block; display: block;

View file

@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.head"> <div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="['pollEnded', 'note', 'edited', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@ -29,6 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_login]: notification.type === 'login', [$style.t_login]: notification.type === 'login',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
[$style.t_pollEnded]: notification.type === 'edited', [$style.t_pollEnded]: notification.type === 'edited',
[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
[$style.t_pollEnded]: notification.type === 'scheduledNotePosted',
}]" }]"
> <!-- we re-use t_pollEnded for "edited" instead of making an identical style --> > <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i> <i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
@ -47,6 +49,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ti ti-badges"></i> <i v-else class="ti ti-badges"></i>
</template> </template>
<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i> <i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-calendar-event"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon <MkReactionIcon
v-else-if="notification.type === 'reaction'" v-else-if="notification.type === 'reaction'"
@ -72,6 +76,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span> <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span> <span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span> <span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header> </header>
<div> <div>
@ -111,6 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
{{ i18n.ts.showFile }} {{ i18n.ts.showFile }}
</MkA> </MkA>
<div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text">
{{ notification.reason }}
</div>
<template v-else-if="notification.type === 'follow'"> <template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template> </template>
@ -158,6 +167,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/> <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA> </MkA>
<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA>
</div> </div>
</div> </div>
</div> </div>

View file

@ -77,6 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkScheduleEditor v-if="scheduleNote" v-model="scheduleNote" @destroyed="scheduleNote = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;"> <div v-if="showingOptions" style="padding: 8px 16px;">
</div> </div>
@ -90,6 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
<button v-tooltip="i18n.ts.otherSettings" :class="['_button', $style.footerButton]" @click="showOtherMenu"><i class="ti ti-dots"></i></button>
</div> </div>
<div :class="$style.footerRight"> <div :class="$style.footerRight">
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
@ -110,6 +112,7 @@ import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
import { host, url } from '@@/js/config.js'; import { host, url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue'; import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@ -134,6 +137,7 @@ import { claimAchievement } from '@/scripts/achievements.js';
import { emojiPicker } from '@/scripts/emoji-picker.js'; import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
import type { PostFormProps } from '@/types/post-form.js'; import type { PostFormProps } from '@/types/post-form.js';
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
const $i = signinRequired(); const $i = signinRequired();
@ -195,6 +199,9 @@ const imeText = ref('');
const showingOptions = ref(false); const showingOptions = ref(false);
const textAreaReadOnly = ref(false); const textAreaReadOnly = ref(false);
const justEndedComposition = ref(false); const justEndedComposition = ref(false);
const scheduleNote = ref<{
scheduledAt: number | null;
} | null>(null);
const draftKey = computed((): string => { const draftKey = computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : ''; let key = props.channel ? `channel:${props.channel.id}` : '';
@ -367,6 +374,7 @@ function watchForDraft() {
watch(localOnly, () => saveDraft()); watch(localOnly, () => saveDraft());
watch(quoteId, () => saveDraft()); watch(quoteId, () => saveDraft());
watch(reactionAcceptance, () => saveDraft()); watch(reactionAcceptance, () => saveDraft());
watch(scheduleNote, () => saveDraft());
} }
function MFMWindow() { function MFMWindow() {
@ -575,6 +583,7 @@ function clear() {
files.value = []; files.value = [];
poll.value = null; poll.value = null;
quoteId.value = null; quoteId.value = null;
scheduleNote.value = null;
} }
function onKeydown(ev: KeyboardEvent) { function onKeydown(ev: KeyboardEvent) {
@ -732,6 +741,7 @@ function saveDraft() {
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
quoteId: quoteId.value, quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
scheduleNote: scheduleNote.value,
}, },
}; };
@ -839,6 +849,7 @@ async function post(ev?: MouseEvent) {
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
editId: props.editId ? props.editId : undefined, editId: props.editId ? props.editId : undefined,
scheduleNote: scheduleNote.value ?? undefined,
}; };
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
@ -875,7 +886,7 @@ async function post(ev?: MouseEvent) {
} }
posting.value = true; posting.value = true;
misskeyApi(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => {
if (props.freezeAfterPosted) { if (props.freezeAfterPosted) {
posted.value = true; posted.value = true;
} else { } else {
@ -1026,6 +1037,42 @@ function openAccountMenu(ev: MouseEvent) {
}, ev); }, ev);
} }
function toggleScheduleNote() {
if (scheduleNote.value) {
scheduleNote.value = null;
} else {
scheduleNote.value = {
scheduledAt: null,
};
}
}
function showOtherMenu(ev: MouseEvent) {
const menuItems: MenuItem[] = [];
if ($i.policies.scheduleNoteMax > 0) {
menuItems.push({
type: 'button',
text: i18n.ts.schedulePost,
icon: 'ti ti-calendar-time',
action: toggleScheduleNote,
}, {
type: 'button',
text: i18n.ts.schedulePostList,
icon: 'ti ti-calendar-event',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, {
closed: () => {
dispose();
},
});
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
onMounted(() => { onMounted(() => {
if (props.autofocus) { if (props.autofocus) {
focus(); focus();
@ -1095,6 +1142,11 @@ onMounted(() => {
} }
quoteId.value = init.renote ? init.renote.id : null; quoteId.value = init.renote ? init.renote.id : null;
reactionAcceptance.value = init.reactionAcceptance; reactionAcceptance.value = init.reactionAcceptance;
if (init.isSchedule) {
scheduleNote.value = {
scheduledAt: new Date(init.createdAt).getTime(),
};
}
} }
nextTick(() => watchForDraft()); nextTick(() => watchForDraft());

View file

@ -0,0 +1,65 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div style="padding: 8px 16px;">
<section>
<MkInput v-model="atDate" small type="date" class="input">
<template #label>{{ i18n.ts._poll.deadlineDate }}</template>
</MkInput>
<MkInput v-model="atTime" small type="time" class="input">
<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
</MkInput>
</section>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import MkInput from '@/components/MkInput.vue';
import { formatDateTimeString } from '@/scripts/format-time-string.js';
import { addTime } from '@/scripts/time.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
modelValue: {
scheduledAt: number | null;
};
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: {
scheduledAt: number | null;
}): void;
}>();
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00');
if (props.modelValue.scheduledAt) {
const date = new Date(props.modelValue.scheduledAt);
atDate.value = formatDateTimeString(date, 'yyyy-MM-dd');
atTime.value = formatDateTimeString(date, 'HH:mm');
}
function get() {
const calcAt = () => {
return new Date(`${ atDate.value } ${ atTime.value }`).getTime();
};
return { scheduledAt: calcAt() };
}
watch([
atDate,
atTime,
], () => emit('update:modelValue', get()), {
deep: true,
});
onMounted(() => {
emit('update:modelValue', get());
});
</script>

View file

@ -0,0 +1,62 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialogEl"
:withOkButton="false"
@click="cancel()"
@close="cancel()"
>
<template #header>{{ i18n.ts.schedulePostList }}</template>
<MkSpacer :marginMin="14" :marginMax="16">
<MkPagination ref="paginationEl" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #default="{ items }">
<div class="_gaps">
<MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" @editScheduleNote="listUpdate"/>
</div>
</template>
</MkPagination>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const emit = defineEmits<{
(ev: 'cancel'): void;
}>();
const dialogEl = ref();
const cancel = () => {
emit('cancel');
dialogEl.value.close();
};
const paginationEl = ref();
const pagination: Paging = {
endpoint: 'notes/schedule/list',
limit: 10,
offsetMode: true,
};
function listUpdate() {
paginationEl.value.reload();
}
</script>

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'" :tabindex="isDeleted ? '-1' : '0'"
> >
<SkNoteSub v-if="appearNote.reply && !renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <SkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="appearNote.reply && inReplyToCollapsed && !renoteCollapsed" :class="$style.collapsedInReplyTo"> <div v-if="appearNote.reply && inReplyToCollapsed && !renoteCollapsed" :class="$style.collapsedInReplyTo">
<div :class="$style.collapsedInReplyToLine"></div> <div :class="$style.collapsedInReplyToLine"></div>
<MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/> <MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>

View file

@ -25,9 +25,11 @@
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org https://api.friendlycaptcha.com https://raw.esm.sh; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org https://api.friendlycaptcha.com https://raw.esm.sh;
frame-src *;" frame-src *;"
/> />
<meta property="og:site_name" content="[DEV BUILD] Misskey" /> <meta property="og:site_name" content="[DEV BUILD] Sharkey" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color-orig" content="#86b300"> <meta name="theme-color-orig" content="#86b300">
<link rel='stylesheet' href='/assets/phosphor-icons/bold/style.css'>
<link rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css'>
</head> </head>
<body> <body>

View file

@ -740,3 +740,4 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> {
}); });
}); });
}*/ }*/

View file

@ -200,6 +200,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMax, 'scheduleNoteMax'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template>
<template #suffix>
<span v-if="role.policies.scheduleNoteMax.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduleNoteMax.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteMax)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduleNoteMax.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduleNoteMax.value" :disabled="role.policies.scheduleNoteMax.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduleNoteMax.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix> <template #suffix>

View file

@ -70,6 +70,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMax, 'scheduleNoteMax'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template>
<template #suffix>{{ policies.scheduleNoteMax }}</template>
<MkInput v-model="policies.scheduleNoteMax" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template> <template #suffix>{{ policies.mentionLimit }}</template>

View file

@ -264,7 +264,7 @@ const memoDraft = ref(props.user.memo);
const isEditingMemo = ref(false); const isEditingMemo = ref(false);
const moderationNote = ref(props.user.moderationNote); const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false); const editModerationNote = ref(false);
const noteview = ref<string | null>('pinned'); const noteview = ref<string | null>(props.user.pinnedNotes.length ? 'pinned' : null);
const listenbrainzdata = ref(false); const listenbrainzdata = ref(false);
if (props.user.listenbrainz) { if (props.user.listenbrainz) {

View file

@ -17,6 +17,8 @@ export interface PostFormProps {
initialFiles?: Misskey.entities.DriveFile[]; initialFiles?: Misskey.entities.DriveFile[];
initialLocalOnly?: boolean; initialLocalOnly?: boolean;
initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialVisibleUsers?: Misskey.entities.UserDetailed[];
initialNote?: Misskey.entities.Note; initialNote?: Misskey.entities.Note & {
isSchedule?: boolean,
};
instant?: boolean; instant?: boolean;
} }

View file

@ -176,6 +176,7 @@ export function pluginReplaceIcons() {
'ti ti-cake': 'ph-cake ph-bold ph-lg', 'ti ti-cake': 'ph-cake ph-bold ph-lg',
'ti ti-calendar': 'ph-calendar ph-bold ph-lg', 'ti ti-calendar': 'ph-calendar ph-bold ph-lg',
'ti ti-calendar-time': 'ph-calendar ph-bold ph-lg', 'ti ti-calendar-time': 'ph-calendar ph-bold ph-lg',
'ti ti-calendar-event': 'ph-calendar-star ph-bold ph-lg',
'ti ti-camera': 'ph-camera ph-bold ph-lg', 'ti ti-camera': 'ph-camera ph-bold ph-lg',
'ti ti-carousel-horizontal': 'ph-split-horizontal ph-bold ph-lg', 'ti ti-carousel-horizontal': 'ph-split-horizontal ph-bold ph-lg',
'ti ti-carousel-vertical': 'ph-split-vertical ph-bold ph-lg', 'ti ti-carousel-vertical': 'ph-split-vertical ph-bold ph-lg',

View file

@ -1712,6 +1712,10 @@ declare namespace entities {
NotesRenotesResponse, NotesRenotesResponse,
NotesRepliesRequest, NotesRepliesRequest,
NotesRepliesResponse, NotesRepliesResponse,
NotesScheduleCreateRequest,
NotesScheduleDeleteRequest,
NotesScheduleListRequest,
NotesScheduleListResponse,
NotesSearchByTagRequest, NotesSearchByTagRequest,
NotesSearchByTagResponse, NotesSearchByTagResponse,
NotesSearchRequest, NotesSearchRequest,
@ -2845,6 +2849,18 @@ type NotesRequest = operations['notes']['requestBody']['content']['application/j
// @public (undocumented) // @public (undocumented)
type NotesResponse = operations['notes']['responses']['200']['content']['application/json']; type NotesResponse = operations['notes']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json']; type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
@ -2912,7 +2928,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited"]; export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed", "scheduledNotePosted"];
// @public (undocumented) // @public (undocumented)
export function nyaize(text: string): string; export function nyaize(text: string): string;
@ -2975,7 +2991,7 @@ type PartialRolePolicyOverride = Partial<{
}>; }>;
// @public (undocumented) // @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
// @public (undocumented) // @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json']; type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -3407,6 +3407,39 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
request<E extends 'notes/schedule/create', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
request<E extends 'notes/schedule/delete', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
*/
request<E extends 'notes/schedule/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* No description provided. * No description provided.
* *

View file

@ -453,6 +453,10 @@ import type {
NotesRenotesResponse, NotesRenotesResponse,
NotesRepliesRequest, NotesRepliesRequest,
NotesRepliesResponse, NotesRepliesResponse,
NotesScheduleCreateRequest,
NotesScheduleDeleteRequest,
NotesScheduleListRequest,
NotesScheduleListResponse,
NotesSearchByTagRequest, NotesSearchByTagRequest,
NotesSearchByTagResponse, NotesSearchByTagResponse,
NotesSearchRequest, NotesSearchRequest,
@ -906,6 +910,9 @@ export type Endpoints = {
'notes/like': { req: NotesLikeRequest; res: EmptyResponse }; 'notes/like': { req: NotesLikeRequest; res: EmptyResponse };
'notes/renotes': { req: NotesRenotesRequest; res: NotesRenotesResponse }; 'notes/renotes': { req: NotesRenotesRequest; res: NotesRenotesResponse };
'notes/replies': { req: NotesRepliesRequest; res: NotesRepliesResponse }; 'notes/replies': { req: NotesRepliesRequest; res: NotesRepliesResponse };
'notes/schedule/create': { req: NotesScheduleCreateRequest; res: EmptyResponse };
'notes/schedule/delete': { req: NotesScheduleDeleteRequest; res: EmptyResponse };
'notes/schedule/list': { req: NotesScheduleListRequest; res: NotesScheduleListResponse };
'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse }; 'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse };
'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse }; 'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse };
'notes/show': { req: NotesShowRequest; res: NotesShowResponse }; 'notes/show': { req: NotesShowRequest; res: NotesShowResponse };

View file

@ -456,6 +456,10 @@ export type NotesRenotesRequest = operations['notes___renotes']['requestBody']['
export type NotesRenotesResponse = operations['notes___renotes']['responses']['200']['content']['application/json']; export type NotesRenotesResponse = operations['notes___renotes']['responses']['200']['content']['application/json'];
export type NotesRepliesRequest = operations['notes___replies']['requestBody']['content']['application/json']; export type NotesRepliesRequest = operations['notes___replies']['requestBody']['content']['application/json'];
export type NotesRepliesResponse = operations['notes___replies']['responses']['200']['content']['application/json']; export type NotesRepliesResponse = operations['notes___replies']['responses']['200']['content']['application/json'];
export type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json'];
export type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json'];
export type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json'];
export type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json'];
export type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json']; export type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json']; export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json'];
export type NotesSearchRequest = operations['notes___search']['requestBody']['content']['application/json']; export type NotesSearchRequest = operations['notes___search']['requestBody']['content']['application/json'];

View file

@ -2953,6 +2953,33 @@ export type paths = {
*/ */
post: operations['notes___replies']; post: operations['notes___replies'];
}; };
'/notes/schedule/create': {
/**
* notes/schedule/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
post: operations['notes___schedule___create'];
};
'/notes/schedule/delete': {
/**
* notes/schedule/delete
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
post: operations['notes___schedule___delete'];
};
'/notes/schedule/list': {
/**
* notes/schedule/list
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
*/
post: operations['notes___schedule___list'];
};
'/notes/search-by-tag': { '/notes/search-by-tag': {
/** /**
* notes/search-by-tag * notes/search-by-tag
@ -4521,6 +4548,25 @@ export type components = {
/** Format: id */ /** Format: id */
userId: string; userId: string;
note: components['schemas']['Note']; note: components['schemas']['Note'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNoteFailed';
reason: string;
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNotePosted';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
note: components['schemas']['Note'];
} | { } | {
/** Format: id */ /** Format: id */
id: string; id: string;
@ -5067,6 +5113,7 @@ export type components = {
canImportFollowing: boolean; canImportFollowing: boolean;
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
scheduleNoteMax: number;
}; };
ReversiGameLite: { ReversiGameLite: {
/** Format: id */ /** Format: id */
@ -20115,8 +20162,8 @@ export type operations = {
untilId?: string; untilId?: string;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };
@ -20183,8 +20230,8 @@ export type operations = {
untilId?: string; untilId?: string;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };
@ -24586,6 +24633,239 @@ export type operations = {
}; };
}; };
}; };
/**
* notes/schedule/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
notes___schedule___create: {
requestBody: {
content: {
'application/json': {
/**
* @default public
* @enum {string}
*/
visibility?: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: string[];
cw?: string | null;
/**
* @default null
* @enum {string|null}
*/
reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** @default false */
noExtractMentions?: boolean;
/** @default false */
noExtractHashtags?: boolean;
/** @default false */
noExtractEmojis?: boolean;
/** Format: misskey:id */
replyId?: string | null;
/** Format: misskey:id */
renoteId?: string | null;
text?: string | null;
fileIds?: string[];
mediaIds?: string[];
poll?: ({
choices: string[];
multiple?: boolean;
expiresAt?: number | null;
expiredAfter?: number | null;
}) | null;
scheduleNote: {
scheduledAt?: number;
};
};
};
};
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 Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/schedule/delete
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
*/
notes___schedule___delete: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
noteId: 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 Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/schedule/list
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
*/
notes___schedule___list: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
/** @default 10 */
limit?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': ({
/** Format: misskey:id */
id: string;
note: {
createdAt: string;
text?: string;
cw?: string | null;
fileIds: string[];
/** @enum {string} */
visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUsers: components['schemas']['UserLite'][];
user: components['schemas']['User'];
/**
* @default null
* @enum {string|null}
*/
reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
isSchedule: boolean;
};
userId: string;
scheduledAt: string;
})[];
};
};
/** @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 Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/** /**
* notes/search-by-tag * notes/search-by-tag
* @description No description provided. * @description No description provided.

View file

@ -16,7 +16,7 @@ import type {
UserLite, UserLite,
} from './autogen/models.js'; } from './autogen/models.js';
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited'] as const; export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed', 'scheduledNotePosted'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
@ -42,6 +42,8 @@ export const permissions = [
'read:mutes', 'read:mutes',
'write:mutes', 'write:mutes',
'write:notes', 'write:notes',
'read:notes-schedule',
'write:notes-schedule',
'read:notifications', 'read:notifications',
'write:notifications', 'write:notifications',
'read:reactions', 'read:reactions',

View file

@ -264,6 +264,21 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
data, data,
}]; }];
case 'scheduledNoteFailed':
return [i18n.ts._notification.scheduledNoteFailed, {
body: data.body.reason,
badge: iconUrl('bell'),
data,
}];
case 'scheduledNotePosted':
return [i18n.ts._notification.scheduledNotePosted, {
body: data.body.note.text ?? '',
icon: data.body.user.avatarUrl ?? undefined,
badge: iconUrl('bell'),
data,
}];
default: default:
return null; return null;
} }

View file

@ -225,6 +225,7 @@ _role:
btlAvailable: "Can view the bubble timeline" btlAvailable: "Can view the bubble timeline"
canImportNotes: "Can import notes" canImportNotes: "Can import notes"
canUpdateBioMedia: "Allow users to edit their avatar or banner" canUpdateBioMedia: "Allow users to edit their avatar or banner"
scheduleNoteMax: "Maximum number of scheduled notes"
_condition: _condition:
isLocked: "Private account" isLocked: "Private account"
isExplorable: "Account is discoverable" isExplorable: "Account is discoverable"
@ -276,6 +277,8 @@ _notification:
youRenoted: "Boost from {name}" youRenoted: "Boost from {name}"
renotedBySomeUsers: "Boosted by {n} users" renotedBySomeUsers: "Boosted by {n} users"
edited: "Note got edited" edited: "Note got edited"
scheduledNoteFailed: "Posting scheduled note failed"
scheduledNotePosted: "Scheduled Note was posted"
_types: _types:
renote: "Boosts" renote: "Boosts"
edited: "Edits" edited: "Edits"
@ -414,3 +417,10 @@ _deck:
following: "Following" following: "Following"
selectFollowRelationship: "Select a follow relationship..." selectFollowRelationship: "Select a follow relationship..."
schedulePost: "Schedule a note"
schedulePostList: "List of scheduled notes"
_permissions:
"read:notes-schedule": "View your list of scheduled notes"
"write:notes-schedule": "Compose or delete scheduled notes"

View file

@ -210,6 +210,7 @@ _role:
btlAvailable: "バブルタイムラインの閲覧" btlAvailable: "バブルタイムラインの閲覧"
canImportNotes: "ノートのインポートが可能" canImportNotes: "ノートのインポートが可能"
canUpdateBioMedia: "アイコンとバナーの更新を許可" canUpdateBioMedia: "アイコンとバナーの更新を許可"
scheduleNoteMax: "予約投稿の最大数"
_condition: _condition:
isLocked: "鍵アカウントユーザー" isLocked: "鍵アカウントユーザー"
isExplorable: "「アカウントを見つけやすくする」が有効なユーザー" isExplorable: "「アカウントを見つけやすくする」が有効なユーザー"
@ -384,3 +385,8 @@ _externalNavigationWarning:
title: "外部サイトに移動します" title: "外部サイトに移動します"
description: "{host}を離れて外部サイトに移動します" description: "{host}を離れて外部サイトに移動します"
trustThisDomain: "このデバイスで今後このドメインを信頼する" trustThisDomain: "このデバイスで今後このドメインを信頼する"
schedulePost: "予約投稿"
schedulePostList: "予約投稿一覧"
_permissions:
"read:notes-schedule": "予約投稿を見る"
"write:notes-schedule": "予約投稿を作成・削除する"