mirror of
https://github.com/kokonect-link/cherrypick
synced 2024-11-27 14:28:53 +09:00
feat: 노트 게시를 예약할 수 있음 (yojo-art/cherrypick#483, [Type4ny-Project/Type4ny@271c872c](271c872c97
))
This commit is contained in:
parent
2302a30bc6
commit
e902193fcb
@ -35,6 +35,7 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE
|
||||
- Feat: 사용자 메뉴에서 서버를 뮤트할 수 있음 (kokonect-link/cherrypick#502)
|
||||
- 이전 빌드에 추가된 기능은 관리자 전용이며, 이 빌드에서 추가된 기능은 일반 사용자용 기능입니다.
|
||||
- Feat: 새 노트 알림을 묶어서 표시 (yojo-art/cherrypick#328)
|
||||
- Feat: 노트 게시를 예약할 수 있음 (yojo-art/cherrypick#483, [Type4ny-Project/Type4ny@271c872c](https://github.com/Type4ny-Project/Type4ny/commit/271c872c97f215ef5d8e0be62251dd422a52e5b1))
|
||||
|
||||
### Client
|
||||
- Enhance: (Friendly) 모바일 환경에서 계정 목록을 표시할 때 내 프로필을 표시함
|
||||
|
@ -1,5 +1,7 @@
|
||||
---
|
||||
_lang_: "English"
|
||||
schedulePost: "Posting a scheduled note"
|
||||
schedulePostList: "List of scheduled notes"
|
||||
welcomeBackToast: "Display a welcome message when you log in after a certain period of time"
|
||||
invalidTextLengthError: "Too many characters entered"
|
||||
invalidTextLengthDescription: "The number of characters is limited to {limitValue} characters. The current number of characters entered is {value} characters."
|
||||
@ -13,7 +15,7 @@ widgets: "Widgets"
|
||||
postNote: "Post note"
|
||||
bottomNavbar: "Bottom navigation bar"
|
||||
bottomNavbarDescription: "This setting is only available in a mobile environment."
|
||||
scheduledNoteDelete: "Schedule note deletion"
|
||||
scheduledNoteDelete: "Schedule deletion of note"
|
||||
getQRCode: "Get QR code"
|
||||
customSplashText: "Custom splash text"
|
||||
customSplashTextDescription: "This text will be displayed on the loading page."
|
||||
@ -1960,6 +1962,7 @@ _role:
|
||||
ltlAvailable: "Can view the local timeline"
|
||||
canPublicNote: "Can send public notes"
|
||||
canEditNote: "Note editing"
|
||||
scheduleNoteMax: "Maximum number of scheduled notes"
|
||||
mentionMax: "Maximum number of mentions in a note"
|
||||
canInvite: "Can create instance invite codes"
|
||||
inviteLimit: "Invite limit"
|
||||
@ -2387,6 +2390,8 @@ _permissions:
|
||||
"read:mutes": "View your list of muted users"
|
||||
"write:mutes": "Edit your list of muted users"
|
||||
"write:notes": "Compose or delete notes"
|
||||
"read:notes-schedule": "View your list of scheduled notes"
|
||||
"write:notes-schedule": "Compose or delete scheduled notes"
|
||||
"read:notifications": "View your notifications"
|
||||
"write:notifications": "Manage your notifications"
|
||||
"read:reactions": "View your reactions"
|
||||
|
20
locales/index.d.ts
vendored
20
locales/index.d.ts
vendored
@ -13,6 +13,14 @@ export interface Locale extends ILocale {
|
||||
* 日本語
|
||||
*/
|
||||
"_lang_": string;
|
||||
/**
|
||||
* 予約投稿
|
||||
*/
|
||||
"schedulePost": string;
|
||||
/**
|
||||
* 予約投稿一覧
|
||||
*/
|
||||
"schedulePostList": string;
|
||||
/**
|
||||
* 一定時間が経過した後に接続したときに歓迎メッセージを表示
|
||||
*/
|
||||
@ -7723,6 +7731,10 @@ export interface Locale extends ILocale {
|
||||
* ノートの編集
|
||||
*/
|
||||
"canEditNote": string;
|
||||
/**
|
||||
* 予約投稿の最大数
|
||||
*/
|
||||
"scheduleNoteMax": string;
|
||||
/**
|
||||
* ノート内の最大メンション数
|
||||
*/
|
||||
@ -9386,6 +9398,14 @@ export interface Locale extends ILocale {
|
||||
* ノートを作成・削除する
|
||||
*/
|
||||
"write:notes": string;
|
||||
/**
|
||||
* 予約投稿を見る
|
||||
*/
|
||||
"read:notes-schedule": string;
|
||||
/**
|
||||
* 予約投稿を作成・削除する
|
||||
*/
|
||||
"write:notes-schedule": string;
|
||||
/**
|
||||
* 通知を見る
|
||||
*/
|
||||
|
@ -1,5 +1,7 @@
|
||||
_lang_: "日本語"
|
||||
|
||||
schedulePost: "予約投稿"
|
||||
schedulePostList: "予約投稿一覧"
|
||||
welcomeBackToast: "一定時間が経過した後に接続したときに歓迎メッセージを表示"
|
||||
invalidTextLengthError: "入力された文字数が多すぎます"
|
||||
invalidTextLengthDescription: "文字数が{limitValue}文字に制限されています。現在入力された文字数は{value}文字です。"
|
||||
@ -1999,6 +2001,7 @@ _role:
|
||||
ltlAvailable: "ローカルタイムラインの閲覧"
|
||||
canPublicNote: "パブリック投稿の許可"
|
||||
canEditNote: "ノートの編集"
|
||||
scheduleNoteMax: "予約投稿の最大数"
|
||||
mentionMax: "ノート内の最大メンション数"
|
||||
canInvite: "サーバー招待コードの発行"
|
||||
inviteLimit: "招待コードの作成可能数"
|
||||
@ -2462,6 +2465,8 @@ _permissions:
|
||||
"read:mutes": "ミュートを見る"
|
||||
"write:mutes": "ミュートを操作する"
|
||||
"write:notes": "ノートを作成・削除する"
|
||||
"read:notes-schedule": "予約投稿を見る"
|
||||
"write:notes-schedule": "予約投稿を作成・削除する"
|
||||
"read:notifications": "通知を見る"
|
||||
"write:notifications": "通知を操作する"
|
||||
"read:reactions": "リアクションを見る"
|
||||
|
@ -1,5 +1,7 @@
|
||||
---
|
||||
_lang_: "한국어"
|
||||
schedulePost: "노트 게시 예약"
|
||||
schedulePostList: "게시가 예약된 노트 목록"
|
||||
welcomeBackToast: "일정 시간이 지난 후 접속했을 때 환영 메시지 표시"
|
||||
invalidTextLengthError: "입력된 문자수가 너무 많아요"
|
||||
invalidTextLengthDescription: "문자수가 {limitValue}자로 제한되어 있어요. 현재 입력된 문자수는 {value}자예요."
|
||||
@ -1978,6 +1980,7 @@ _role:
|
||||
ltlAvailable: "로컬 타임라인 보이기"
|
||||
canPublicNote: "공개 노트 허용"
|
||||
canEditNote: "노트 편집 허용"
|
||||
scheduleNoteMax: "게시를 예약한 노트의 최대 수"
|
||||
mentionMax: "노트에서 언급할 수 있는 멘션 수"
|
||||
canInvite: "서버 초대 코드 발행"
|
||||
inviteLimit: "초대 한도"
|
||||
@ -2410,6 +2413,8 @@ _permissions:
|
||||
"read:mutes": "뮤트 여부를 확인합니다"
|
||||
"write:mutes": "뮤트를 하거나 해제합니다"
|
||||
"write:notes": "노트를 작성하거나 삭제합니다"
|
||||
"read:notes-schedule": "게시를 예약한 노트를 봅니다"
|
||||
"write:notes-schedule": "노트 게시를 예약하거나 삭제합니다"
|
||||
"read:notifications": "알림을 확인합니다"
|
||||
"write:notifications": "알림을 모두 읽음 처리합니다"
|
||||
"read:reactions": "리액션을 확인합니다"
|
||||
|
17
packages/backend/migration/1699437894737-scheduleNote.js
Normal file
17
packages/backend/migration/1699437894737-scheduleNote.js
Normal 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"`);
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@ import {
|
||||
UserWebhookDeliverJobData,
|
||||
SystemWebhookDeliverJobData,
|
||||
ScheduledNoteDeleteJobData,
|
||||
ScheduleNotePostJobData,
|
||||
} from '../queue/types.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
@ -31,6 +32,7 @@ export type ObjectStorageQueue = Bull.Queue;
|
||||
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||
export type ScheduledNoteDeleteQueue = Bull.Queue<ScheduledNoteDeleteJobData>;
|
||||
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
|
||||
|
||||
const $system: Provider = {
|
||||
provide: 'queue:system',
|
||||
@ -92,6 +94,12 @@ const $scheduledNoteDelete: Provider = {
|
||||
inject: [DI.config, DI.redisForJobQueue],
|
||||
};
|
||||
|
||||
const $scheduleNotePost: Provider = {
|
||||
provide: 'queue:scheduleNotePost',
|
||||
useFactory: (config: Config, redisForJobQueue: Redis.Redis) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST, redisForJobQueue)),
|
||||
inject: [DI.config, DI.redisForJobQueue],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
@ -106,6 +114,7 @@ const $scheduledNoteDelete: Provider = {
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduledNoteDelete,
|
||||
$scheduleNotePost,
|
||||
],
|
||||
exports: [
|
||||
$system,
|
||||
@ -118,6 +127,7 @@ const $scheduledNoteDelete: Provider = {
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduledNoteDelete,
|
||||
$scheduleNotePost,
|
||||
],
|
||||
})
|
||||
export class QueueModule implements OnApplicationShutdown {
|
||||
@ -132,6 +142,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
@ -149,6 +160,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||
this.userWebhookDeliverQueue.close(),
|
||||
this.systemWebhookDeliverQueue.close(),
|
||||
this.scheduledNoteDeleteQueue.close(),
|
||||
this.scheduleNotePostQueue.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,7 @@ import type {
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
ScheduledNoteDeleteQueue,
|
||||
ScheduleNotePostQueue,
|
||||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
@ -54,6 +55,7 @@ export class QueueService {
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
|
@ -36,6 +36,7 @@ export type RolePolicies = {
|
||||
ltlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
canEditNote: boolean;
|
||||
scheduleNoteMax: number;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
@ -72,6 +73,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||
ltlAvailable: true,
|
||||
canPublicNote: true,
|
||||
canEditNote: true,
|
||||
scheduleNoteMax: 5,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
@ -379,6 +381,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
||||
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
|
||||
canEditNote: calc('canEditNote', vs => vs.some(v => v === true)),
|
||||
scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)),
|
||||
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
|
@ -149,7 +149,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
return packedUser;
|
||||
}
|
||||
|
||||
return this.userEntityService.pack(notifier, {id: meId});
|
||||
return this.userEntityService.pack(notifier, { id: meId });
|
||||
}))).filter(x => x != null);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (users.length === 0) {
|
||||
|
@ -92,5 +92,6 @@ export const DI = {
|
||||
userMemosRepository: Symbol('userMemosRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
noteScheduleRepository: Symbol('noteScheduleRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
70
packages/backend/src/models/NoteSchedule.ts
Normal file
70
packages/backend/src/models/NoteSchedule.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { EventSchema } from './Event.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
type MinimumUser = {
|
||||
id: MiUser['id'];
|
||||
host: MiUser['host'];
|
||||
username: MiUser['username'];
|
||||
uri: MiUser['uri'];
|
||||
};
|
||||
|
||||
export type MiScheduleNoteType={
|
||||
/** Date.toISOString() */
|
||||
createdAt: string;
|
||||
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'];
|
||||
event?: {
|
||||
/** Date.toISOString() */
|
||||
start: string;
|
||||
/** Date.toISOString() */
|
||||
end: string | null;
|
||||
title: string;
|
||||
metadata: EventSchema;
|
||||
} | null;
|
||||
disableRightClick: boolean,
|
||||
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;
|
||||
}
|
@ -44,6 +44,7 @@ import {
|
||||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
@ -537,6 +538,12 @@ const $abuseReportResolversRepository: Provider = {
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteScheduleRepository: Provider = {
|
||||
provide: DI.noteScheduleRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository<MiNoteSchedule>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
@ -615,6 +622,7 @@ const $abuseReportResolversRepository: Provider = {
|
||||
$abuseReportResolversRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$noteScheduleRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
@ -692,6 +700,7 @@ const $abuseReportResolversRepository: Provider = {
|
||||
$abuseReportResolversRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$noteScheduleRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
@ -85,6 +85,7 @@ import { MiFlashLike } from '@/models/FlashLike.js';
|
||||
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
|
||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
@ -166,6 +167,7 @@ export {
|
||||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
@ -283,3 +285,4 @@ export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlas
|
||||
export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>;
|
||||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type NoteScheduleRepository = Repository<MiNoteSchedule>;
|
||||
|
@ -300,6 +300,10 @@ export const packedRolePoliciesSchema = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
scheduleNoteMax: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -84,6 +84,7 @@ import { MiFlashLike } from '@/models/FlashLike.js';
|
||||
import { MiUserMemo } from '@/models/UserMemo.js';
|
||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiNoteSchedule } from '@/models/NoteSchedule.js';
|
||||
|
||||
import { Config } from '@/config.js';
|
||||
import MisskeyLogger from '@/logger.js';
|
||||
@ -165,6 +166,7 @@ export const entities = [
|
||||
MiNote,
|
||||
MiNoteFavorite,
|
||||
MiNoteReaction,
|
||||
MiNoteSchedule,
|
||||
MiNoteThreadMuting,
|
||||
MiNoteUnread,
|
||||
MiPage,
|
||||
|
@ -42,6 +42,7 @@ import { AggregateRetentionProcessorService } from './processors/AggregateRetent
|
||||
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
|
||||
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
|
||||
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
|
||||
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -85,6 +86,7 @@ import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteD
|
||||
AggregateRetentionProcessorService,
|
||||
QueueProcessorService,
|
||||
ScheduledNoteDeleteProcessorService,
|
||||
ScheduleNotePostProcessorService,
|
||||
],
|
||||
exports: [
|
||||
QueueProcessorService,
|
||||
|
@ -45,6 +45,7 @@ import { BakeBufferedReactionsProcessorService } from './processors/BakeBuffered
|
||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
|
||||
import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QUEUE, baseQueueOptions } from './const.js';
|
||||
|
||||
@ -87,6 +88,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private objectStorageQueueWorker: Bull.Worker;
|
||||
private endedPollNotificationQueueWorker: Bull.Worker;
|
||||
private scheduledNoteDeleteQueueWorker: Bull.Worker;
|
||||
private schedulerNotePostQueueWorker: Bull.Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@ -130,6 +132,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
|
||||
private cleanProcessorService: CleanProcessorService,
|
||||
private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService,
|
||||
private scheduleNotePostProcessorService: ScheduleNotePostProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
|
||||
@ -529,6 +532,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
});
|
||||
}
|
||||
//#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, this.redisForJobQueue),
|
||||
autorun: false,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -544,6 +556,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
this.objectStorageQueueWorker.run(),
|
||||
this.endedPollNotificationQueueWorker.run(),
|
||||
this.scheduledNoteDeleteQueueWorker.run(),
|
||||
this.schedulerNotePostQueueWorker.run(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -560,6 +573,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
this.objectStorageQueueWorker.close(),
|
||||
this.endedPollNotificationQueueWorker.close(),
|
||||
this.scheduledNoteDeleteQueueWorker.close(),
|
||||
this.schedulerNotePostQueueWorker.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ export const QUEUE = {
|
||||
USER_WEBHOOK_DELIVER: 'userWebhookDeliver',
|
||||
SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver',
|
||||
SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete',
|
||||
SCHEDULE_NOTE_POST: 'scheduleNotePost',
|
||||
};
|
||||
|
||||
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE], redisConnection: Redis.Redis): Bull.QueueOptions {
|
||||
|
@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 { 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,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
//idの形式でキューに積んであったのをDBから取り寄せる
|
||||
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 ?? null;
|
||||
if (fileIds != null && 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 ||
|
||||
(note.reply && !reply) ||
|
||||
(note.renote && !renote) ||
|
||||
(note.channel && !channel) ||
|
||||
(note.files.length !== files.length)
|
||||
) {
|
||||
//キューに積んだときは有った物が消滅してたら予約投稿をキャンセルする
|
||||
this.logger.warn('cancel schedule note');
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
return;
|
||||
}
|
||||
await this.noteCreateService.create(me, {
|
||||
...note,
|
||||
createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す
|
||||
files,
|
||||
poll: note.poll ? {
|
||||
choices: note.poll.choices,
|
||||
multiple: note.poll.multiple,
|
||||
expiresAt: note.poll.expiresAt ? new Date(note.poll.expiresAt) : null,
|
||||
} : undefined,
|
||||
event: note.event ? {
|
||||
start: new Date(note.event.start),
|
||||
end: note.event.end ? new Date(note.event.end) : null,
|
||||
title: note.event.title,
|
||||
metadata: note.event.metadata,
|
||||
} : undefined,
|
||||
reply,
|
||||
renote,
|
||||
channel,
|
||||
});
|
||||
await this.noteScheduleRepository.remove(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -137,3 +137,7 @@ export type ThinUser = {
|
||||
export type ScheduledNoteDeleteJobData = {
|
||||
noteId: MiNote['id'];
|
||||
};
|
||||
|
||||
export type ScheduleNotePostJobData = {
|
||||
scheduleNoteId: MiNote['id'];
|
||||
}
|
||||
|
@ -312,6 +312,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create
|
||||
import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.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_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_search from './endpoints/notes/search.js';
|
||||
import * as ep___notes_show from './endpoints/notes/show.js';
|
||||
@ -728,6 +731,9 @@ const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create'
|
||||
const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.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_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_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default };
|
||||
const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default };
|
||||
@ -1149,6 +1155,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_reactions_delete,
|
||||
$notes_renotes,
|
||||
$notes_replies,
|
||||
$notes_schedule_create,
|
||||
$notes_schedule_delete,
|
||||
$notes_schedule_list,
|
||||
$notes_searchByTag,
|
||||
$notes_search,
|
||||
$notes_show,
|
||||
@ -1562,6 +1571,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_reactions_delete,
|
||||
$notes_renotes,
|
||||
$notes_replies,
|
||||
$notes_schedule_create,
|
||||
$notes_schedule_delete,
|
||||
$notes_schedule_list,
|
||||
$notes_searchByTag,
|
||||
$notes_search,
|
||||
$notes_show,
|
||||
|
@ -317,6 +317,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create
|
||||
import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.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_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_search from './endpoints/notes/search.js';
|
||||
import * as ep___notes_show from './endpoints/notes/show.js';
|
||||
@ -731,6 +734,9 @@ const eps = [
|
||||
['notes/reactions/delete', ep___notes_reactions_delete],
|
||||
['notes/renotes', ep___notes_renotes],
|
||||
['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', ep___notes_search],
|
||||
['notes/show', ep___notes_show],
|
||||
|
@ -5,7 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 = {
|
||||
tags: ['admin'],
|
||||
@ -55,6 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const deliverJobCounts = await this.deliverQueue.getJobCounts();
|
||||
|
@ -0,0 +1,393 @@
|
||||
/*
|
||||
* 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 'cherrypick-js/note.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 { MAX_NOTE_TEXT_LENGTH } from '@/const.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 },
|
||||
disableRightClick: { type: 'boolean', default: false },
|
||||
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,
|
||||
maxLength: MAX_NOTE_TEXT_LENGTH,
|
||||
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'],
|
||||
},
|
||||
event: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1, maxLength: 128, nullable: false },
|
||||
start: { type: 'integer', nullable: false },
|
||||
end: { type: 'integer', nullable: true },
|
||||
metadata: { type: 'object' },
|
||||
},
|
||||
},
|
||||
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.exist({
|
||||
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 = {
|
||||
createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(),
|
||||
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,
|
||||
event: ps.event ? {
|
||||
start: new Date(ps.event.start!).toISOString(),
|
||||
end: ps.event.end ? new Date(ps.event.end).toISOString() : null,
|
||||
title: ps.event.title!,
|
||||
metadata: ps.event.metadata ?? {},
|
||||
} : undefined,
|
||||
disableRightClick: ps.disableRightClick,
|
||||
};
|
||||
|
||||
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: noteId,
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}
|
@ -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(ps.noteId);
|
||||
});
|
||||
}
|
||||
}
|
128
packages/backend/src/server/api/endpoints/notes/schedule/list.ts
Normal file
128
packages/backend/src/server/api/endpoints/notes/schedule/list.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 { 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 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 : [],
|
||||
createdAt: item.scheduledAt.toISOString(),
|
||||
isSchedule: true,
|
||||
id: item.id,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
return scheduleNotesPack;
|
||||
});
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ import type {
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
ScheduledNoteDeleteQueue,
|
||||
ScheduleNotePostQueue,
|
||||
} from '@/core/QueueModule.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@ -126,6 +127,7 @@ export class ClientServerService {
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
@ -255,6 +257,7 @@ export class ClientServerService {
|
||||
this.userWebhookDeliverQueue,
|
||||
this.systemWebhookDeliverQueue,
|
||||
this.scheduledNoteDeleteQueue,
|
||||
this.scheduleNotePostQueue,
|
||||
].map(q => new BullMQAdapter(q)),
|
||||
serverAdapter: bullBoardServerAdapter,
|
||||
});
|
||||
|
@ -1717,6 +1717,10 @@ declare namespace entities {
|
||||
NotesRenotesResponse,
|
||||
NotesRepliesRequest,
|
||||
NotesRepliesResponse,
|
||||
NotesScheduleCreateRequest,
|
||||
NotesScheduleDeleteRequest,
|
||||
NotesScheduleListRequest,
|
||||
NotesScheduleListResponse,
|
||||
NotesSearchByTagRequest,
|
||||
NotesSearchByTagResponse,
|
||||
NotesSearchRequest,
|
||||
@ -2862,6 +2866,18 @@ type NotesRequest = operations['notes']['requestBody']['content']['application/j
|
||||
// @public (undocumented)
|
||||
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)
|
||||
type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
|
||||
|
||||
@ -2989,7 +3005,7 @@ type PartialRolePolicyOverride = Partial<{
|
||||
}>;
|
||||
|
||||
// @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: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: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)
|
||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||
|
@ -3409,6 +3409,39 @@ declare module '../api.js' {
|
||||
credential?: string | null,
|
||||
): 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.
|
||||
*
|
||||
|
@ -457,6 +457,10 @@ import type {
|
||||
NotesRenotesResponse,
|
||||
NotesRepliesRequest,
|
||||
NotesRepliesResponse,
|
||||
NotesScheduleCreateRequest,
|
||||
NotesScheduleDeleteRequest,
|
||||
NotesScheduleListRequest,
|
||||
NotesScheduleListResponse,
|
||||
NotesSearchByTagRequest,
|
||||
NotesSearchByTagResponse,
|
||||
NotesSearchRequest,
|
||||
@ -926,6 +930,9 @@ export type Endpoints = {
|
||||
'notes/reactions/delete': { req: NotesReactionsDeleteRequest; res: EmptyResponse };
|
||||
'notes/renotes': { req: NotesRenotesRequest; res: NotesRenotesResponse };
|
||||
'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': { req: NotesSearchRequest; res: NotesSearchResponse };
|
||||
'notes/show': { req: NotesShowRequest; res: NotesShowResponse };
|
||||
|
@ -460,6 +460,10 @@ export type NotesRenotesRequest = operations['notes___renotes']['requestBody']['
|
||||
export type NotesRenotesResponse = operations['notes___renotes']['responses']['200']['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 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 NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json'];
|
||||
export type NotesSearchRequest = operations['notes___search']['requestBody']['content']['application/json'];
|
||||
|
@ -2948,6 +2948,33 @@ export type paths = {
|
||||
*/
|
||||
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
|
||||
@ -5195,6 +5222,7 @@ export type components = {
|
||||
canImportMuting: boolean;
|
||||
canImportUserLists: boolean;
|
||||
canEditNote: boolean;
|
||||
scheduleNoteMax: number;
|
||||
};
|
||||
ReversiGameLite: {
|
||||
/** Format: id */
|
||||
@ -23771,6 +23799,247 @@ 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 */
|
||||
disableRightClick?: boolean;
|
||||
/** @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;
|
||||
event?: ({
|
||||
title?: string;
|
||||
start?: number;
|
||||
end?: number | null;
|
||||
metadata?: Record<string, never>;
|
||||
}) | 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 To 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 To 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 To many requests */
|
||||
429: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* notes/search-by-tag
|
||||
* @description No description provided.
|
||||
|
@ -42,6 +42,8 @@ export const permissions = [
|
||||
'read:mutes',
|
||||
'write:mutes',
|
||||
'write:notes',
|
||||
'read:notes-schedule',
|
||||
'write:notes-schedule',
|
||||
'read:notifications',
|
||||
'write:notifications',
|
||||
'read:reactions',
|
||||
|
@ -79,6 +79,7 @@ export const ROLE_POLICIES = [
|
||||
'ltlAvailable',
|
||||
'canPublicNote',
|
||||
'canEditNote',
|
||||
'scheduleNoteMax',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
|
@ -62,7 +62,10 @@ import { useRouter } from '@/router/supplier.js';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
note: Misskey.entities.Note & {
|
||||
isSchedule?: boolean
|
||||
};
|
||||
scheduled?: boolean;
|
||||
}>();
|
||||
|
||||
const mock = inject<boolean>('mock', false);
|
||||
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" :style="{ cursor: expandOnNoteClick && enableNoteClick ? 'pointer' : '' }" @click.stop="noteClick" @dblclick.stop="noteDblClick">
|
||||
<div v-show="!isDeleted" :class="$style.root" :tabindex="!isDeleted ? '-1' : undefined" :style="{ cursor: expandOnNoteClick && enableNoteClick ? 'pointer' : '' }" @click.stop="noteClick" @dblclick.stop="noteDblClick">
|
||||
<div style="display: flex; padding-bottom: 10px;">
|
||||
<MkAvatar v-if="!defaultStore.state.hideAvatarsInNote" :class="[$style.avatar, { [$style.showEl]: (showEl && ['hideHeaderOnly', 'hideHeaderFloatBtn', 'hide'].includes(<string>defaultStore.state.displayHeaderNavBarWhenScroll)) && mainRouter.currentRoute.value.name === 'index', [$style.showElTab]: (showEl && ['hideHeaderOnly', 'hideHeaderFloatBtn', 'hide'].includes(<string>defaultStore.state.displayHeaderNavBarWhenScroll)) && mainRouter.currentRoute.value.name !== 'index' }]" :user="note.user" link preview noteClick/>
|
||||
<div :class="$style.main">
|
||||
@ -19,6 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</p>
|
||||
<div v-show="note.cw == null || showContent">
|
||||
<MkSubNoteContent :class="$style.text" :note="note" :showSubNoteFooterButton="false"/>
|
||||
<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>
|
||||
@ -27,29 +31,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'cherrypick-js';
|
||||
import * as os from '@/os.js';
|
||||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkEvent from '@/components/MkEvent.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
note: Misskey.entities.Note & {
|
||||
isSchedule? : boolean,
|
||||
scheduledNoteId?: string
|
||||
};
|
||||
enableNoteClick?: boolean,
|
||||
}>(), {
|
||||
enableNoteClick: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'editScheduleNote'): void;
|
||||
}>();
|
||||
|
||||
const showEl = ref(false);
|
||||
|
||||
const showContent = ref(false);
|
||||
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
|
||||
const router = useRouter();
|
||||
|
||||
const isDeleted = ref(false);
|
||||
|
||||
if (defaultStore.state.alwaysShowCw) showContent.value = true;
|
||||
|
||||
onMounted(() => {
|
||||
@ -59,14 +76,49 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function noteClick(ev: MouseEvent) {
|
||||
if (props.note.isSchedule) return;
|
||||
if (!expandOnNoteClick || window.getSelection()?.toString() !== '' || defaultStore.state.expandOnNoteClickBehavior === 'doubleClick') ev.stopPropagation();
|
||||
else router.push(notePage(props.note));
|
||||
}
|
||||
|
||||
function noteDblClick(ev: MouseEvent) {
|
||||
if (props.note.isSchedule) return;
|
||||
if (!expandOnNoteClick || window.getSelection()?.toString() !== '' || defaultStore.state.expandOnNoteClickBehavior === 'click') ev.stopPropagation();
|
||||
else router.push(notePage(props.note));
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
await misskeyApi('notes/schedule/delete', { noteId: props.note.id })
|
||||
.then(() => {
|
||||
isDeleted.value = true;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
await os.post({
|
||||
initialNote: props.note,
|
||||
renote: props.note.renote,
|
||||
reply: props.note.reply,
|
||||
channel: props.note.channel,
|
||||
});
|
||||
emit('editScheduleNote');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@ -74,9 +126,15 @@ function noteDblClick(ev: MouseEvent) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.95em;
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.button{
|
||||
margin-right: var(--margin);
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
|
@ -77,6 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkScheduledNoteDelete v-if="scheduledNoteDelete" v-model="scheduledNoteDelete" @destroyed="scheduledNoteDelete = null"/>
|
||||
<MkScheduleEditor v-if="scheduleNote" v-model="scheduleNote" @destroyed="scheduleNote = null"/>
|
||||
<MkNotePreview v-if="showPreview && textLength > 0" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
</div>
|
||||
@ -111,6 +112,7 @@ import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { toASCII } from 'punycode/';
|
||||
import { host, url } from '@@/js/config.js';
|
||||
import { erase, unique } from '@@/js/array.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||
@ -137,6 +139,8 @@ import { vibrate } from '@/scripts/vibrate.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
|
||||
import MkScheduledNoteDelete, { type DeleteScheduleEditorModelValue } from '@/components/MkScheduledNoteDelete.vue';
|
||||
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
|
||||
import { listScheduleNotePost } from '@/os.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@ -154,7 +158,9 @@ const props = withDefaults(defineProps<{
|
||||
initialFiles?: Misskey.entities.DriveFile[];
|
||||
initialLocalOnly?: boolean;
|
||||
initialVisibleUsers?: Misskey.entities.UserDetailed[];
|
||||
initialNote?: Misskey.entities.Note;
|
||||
initialNote?: Misskey.entities.Note & {
|
||||
isSchedule?: boolean,
|
||||
};
|
||||
instant?: boolean;
|
||||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
@ -220,6 +226,9 @@ const showingOptions = ref(false);
|
||||
const disableRightClick = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
|
||||
const scheduleNote = ref<{
|
||||
scheduledAt: number | null;
|
||||
} | null>(null);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@ -399,6 +408,7 @@ function watchForDraft() {
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
watch(scheduledNoteDelete, () => saveDraft());
|
||||
watch(scheduleNote, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
@ -613,6 +623,7 @@ function clear() {
|
||||
poll.value = null;
|
||||
event.value = null;
|
||||
quoteId.value = null;
|
||||
scheduleNote.value = null;
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
@ -767,6 +778,7 @@ function saveDraft() {
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
scheduledNoteDelete: scheduledNoteDelete.value,
|
||||
scheduleNote: scheduleNote.value,
|
||||
},
|
||||
};
|
||||
|
||||
@ -884,6 +896,7 @@ async function post(ev?: MouseEvent) {
|
||||
disableRightClick: disableRightClick.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
scheduledDelete: scheduledNoteDelete.value,
|
||||
scheduleNote: scheduleNote.value ?? undefined,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
|
||||
@ -920,7 +933,7 @@ async function post(ev?: MouseEvent) {
|
||||
}
|
||||
|
||||
posting.value = true;
|
||||
misskeyApi(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
|
||||
misskeyApi(props.updateMode ? 'notes/update' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => {
|
||||
if (props.freezeAfterPosted) {
|
||||
posted.value = true;
|
||||
} else {
|
||||
@ -947,6 +960,8 @@ async function post(ev?: MouseEvent) {
|
||||
claimAchievement('notes1');
|
||||
}
|
||||
|
||||
poll.value = null;
|
||||
|
||||
const text = postData.text ?? '';
|
||||
const lowerCase = text.toLowerCase();
|
||||
if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('cherrypick')) {
|
||||
@ -1108,13 +1123,40 @@ function toggleScheduledNoteDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScheduleNote() {
|
||||
if (scheduleNote.value) scheduleNote.value = null;
|
||||
else {
|
||||
scheduleNote.value = {
|
||||
scheduledAt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function showOtherMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
type: 'button',
|
||||
text: i18n.ts.event,
|
||||
icon: 'ti ti-calendar',
|
||||
action: toggleEvent,
|
||||
});
|
||||
|
||||
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: listScheduleNotePost,
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
type: 'button',
|
||||
text: i18n.ts.scheduledNoteDelete,
|
||||
icon: 'ti ti-clock-hour-9',
|
||||
@ -1124,7 +1166,9 @@ function showOtherMenu(ev: MouseEvent) {
|
||||
text: i18n.ts.disableRightClick,
|
||||
icon: 'ti ti-mouse-off',
|
||||
ref: disableRightClick,
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
});
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -1208,6 +1252,11 @@ onMounted(() => {
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
if (init.isSchedule) {
|
||||
scheduleNote.value = {
|
||||
scheduledAt: new Date(init.createdAt).getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => watchForDraft());
|
||||
|
@ -88,6 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XPostFormAttaches v-if="showForm" v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<MkPollEditor v-if="poll && showForm" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkScheduledNoteDelete v-if="scheduledNoteDelete" v-model="scheduledNoteDelete" @destroyed="scheduledNoteDelete = null"/>
|
||||
<MkScheduleEditor v-if="scheduleNote" v-model="scheduleNote" @destroyed="scheduleNote = null"/>
|
||||
<MkNotePreview v-if="showPreview && showForm && textLength > 0" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
|
||||
<div v-if="showingOptions && showForm" style="padding: 8px 16px;">
|
||||
</div>
|
||||
@ -128,6 +129,7 @@ import { toASCII } from 'punycode/';
|
||||
import autosize from 'autosize';
|
||||
import { host, url } from '@@/js/config.js';
|
||||
import { erase, unique } from '@@/js/array.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
|
||||
@ -154,6 +156,8 @@ import XSigninDialog from '@/components/MkSigninDialog.vue';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
|
||||
import MkScheduledNoteDelete, { type DeleteScheduleEditorModelValue } from '@/components/MkScheduledNoteDelete.vue';
|
||||
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
|
||||
import { listScheduleNotePost } from '@/os.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@ -171,7 +175,9 @@ const props = withDefaults(defineProps<{
|
||||
initialFiles?: Misskey.entities.DriveFile[];
|
||||
initialLocalOnly?: boolean;
|
||||
initialVisibleUsers?: Misskey.entities.UserDetailed[];
|
||||
initialNote?: Misskey.entities.Note;
|
||||
initialNote?: Misskey.entities.Note & {
|
||||
isSchedule?: boolean,
|
||||
};
|
||||
instant?: boolean;
|
||||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
@ -239,6 +245,9 @@ const showingOptions = ref(false);
|
||||
const disableRightClick = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
|
||||
const scheduleNote = ref<{
|
||||
scheduledAt: number | null;
|
||||
} | null>(null);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@ -392,6 +401,7 @@ function watchForDraft() {
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
watch(scheduledNoteDelete, () => saveDraft());
|
||||
watch(scheduleNote, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
@ -606,6 +616,7 @@ function clear() {
|
||||
poll.value = null;
|
||||
event.value = null;
|
||||
quoteId.value = null;
|
||||
scheduleNote.value = null;
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
@ -760,6 +771,7 @@ function saveDraft() {
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
scheduledNoteDelete: scheduledNoteDelete.value,
|
||||
scheduleNote: scheduleNote.value,
|
||||
},
|
||||
};
|
||||
|
||||
@ -877,6 +889,7 @@ async function post(ev?: MouseEvent) {
|
||||
disableRightClick: disableRightClick.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
scheduledDelete: scheduledNoteDelete.value,
|
||||
scheduleNote: scheduleNote.value ?? undefined,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
|
||||
@ -913,7 +926,7 @@ async function post(ev?: MouseEvent) {
|
||||
}
|
||||
|
||||
posting.value = true;
|
||||
misskeyApi(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
|
||||
misskeyApi(props.updateMode ? 'notes/update' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => {
|
||||
if (props.freezeAfterPosted) {
|
||||
posted.value = true;
|
||||
} else {
|
||||
@ -940,6 +953,8 @@ async function post(ev?: MouseEvent) {
|
||||
claimAchievement('notes1');
|
||||
}
|
||||
|
||||
poll.value = null;
|
||||
|
||||
const text = postData.text ?? '';
|
||||
const lowerCase = text.toLowerCase();
|
||||
if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('cherrypick')) {
|
||||
@ -1137,13 +1152,40 @@ function toggleScheduledNoteDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScheduleNote() {
|
||||
if (scheduleNote.value) scheduleNote.value = null;
|
||||
else {
|
||||
scheduleNote.value = {
|
||||
scheduledAt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function showOtherMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
type: 'button',
|
||||
text: i18n.ts.event,
|
||||
icon: 'ti ti-calendar',
|
||||
action: toggleEvent,
|
||||
});
|
||||
|
||||
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: listScheduleNotePost,
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
type: 'button',
|
||||
text: i18n.ts.scheduledNoteDelete,
|
||||
icon: 'ti ti-clock-hour-9',
|
||||
@ -1153,7 +1195,9 @@ function showOtherMenu(ev: MouseEvent) {
|
||||
text: i18n.ts.disableRightClick,
|
||||
icon: 'ti ti-mouse-off',
|
||||
ref: disableRightClick,
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
});
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -1239,6 +1283,11 @@ onMounted(() => {
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
if (init.isSchedule) {
|
||||
scheduleNote.value = {
|
||||
scheduledAt: new Date(init.createdAt).getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => watchForDraft());
|
||||
|
69
packages/frontend/src/components/MkScheduleEditor.vue
Normal file
69
packages/frontend/src/components/MkScheduleEditor.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<!--
|
||||
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>
|
@ -0,0 +1,60 @@
|
||||
<!--
|
||||
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,
|
||||
};
|
||||
|
||||
function listUpdate() {
|
||||
paginationEl.value.reload();
|
||||
}
|
||||
</script>
|
@ -756,3 +756,9 @@ export async function displayQRCode(qrCode: string) {
|
||||
});
|
||||
}))?.();
|
||||
}
|
||||
|
||||
export async function listScheduleNotePost() {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, {}, 'closed');
|
||||
});
|
||||
}
|
||||
|
@ -180,6 +180,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</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'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>
|
||||
|
@ -59,6 +59,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSwitch>
|
||||
</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'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>{{ policies.mentionLimit }}</template>
|
||||
|
Loading…
Reference in New Issue
Block a user