mirror of
https://github.com/kokonect-link/cherrypick
synced 2024-11-27 14:28:53 +09:00
feat: 설정한 시간이 지나면 노트를 자동으로 삭제할 수 있음 (1673beta/cherrypick#70)
This commit is contained in:
parent
17ed323f63
commit
e7abb27cf3
@ -56,6 +56,7 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE
|
||||
- 갤러리를 QR 코드로 공유 (1673beta/cherrypick#51)
|
||||
- 페이지를 QR 코드로 공유 (1673beta/cherrypick#53)
|
||||
- Play를 QR 코드로 공유
|
||||
- Feat: 설정한 시간이 지나면 노트를 자동으로 삭제할 수 있음 (1673beta/cherrypick#70)
|
||||
|
||||
### Client
|
||||
- Enhance: CherryPick 업데이트 페이지를 제어판 목록에 추가함
|
||||
|
@ -1,5 +1,6 @@
|
||||
---
|
||||
_lang_: "English"
|
||||
scheduledNoteDelete: "Schedule note deletion"
|
||||
getQRCode: "Get QR code"
|
||||
customSplashText: "Custom splash text"
|
||||
customSplashTextDescription: "This text will be displayed on the loading page."
|
||||
@ -3029,3 +3030,10 @@ _dice:
|
||||
rollDice: "Roll the dice"
|
||||
diceCount: "Number of dice"
|
||||
diceFaces: "Number of dice faces"
|
||||
_scheduledNoteDelete:
|
||||
expiration: "End of note deletion"
|
||||
at: "End at..."
|
||||
after: "End after..."
|
||||
deadlineDate: "End date"
|
||||
deadlineTime: "Time"
|
||||
duration: "Duration"
|
||||
|
30
locales/index.d.ts
vendored
30
locales/index.d.ts
vendored
@ -13,6 +13,10 @@ export interface Locale extends ILocale {
|
||||
* 日本語
|
||||
*/
|
||||
"_lang_": string;
|
||||
/**
|
||||
* ノートの削除を予約
|
||||
*/
|
||||
"scheduledNoteDelete": string;
|
||||
/**
|
||||
* QRコードを取得
|
||||
*/
|
||||
@ -11842,6 +11846,32 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"diceFaces": string;
|
||||
};
|
||||
"_scheduledNoteDelete": {
|
||||
/**
|
||||
* 期限
|
||||
*/
|
||||
"expiration": string;
|
||||
/**
|
||||
* 日時指定
|
||||
*/
|
||||
"at": string;
|
||||
/**
|
||||
* 経過指定
|
||||
*/
|
||||
"after": string;
|
||||
/**
|
||||
* 期日
|
||||
*/
|
||||
"deadlineDate": string;
|
||||
/**
|
||||
* 時間
|
||||
*/
|
||||
"deadlineTime": string;
|
||||
/**
|
||||
* 期間
|
||||
*/
|
||||
"duration": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
@ -1,5 +1,6 @@
|
||||
_lang_: "日本語"
|
||||
|
||||
scheduledNoteDelete: "ノートの削除を予約"
|
||||
getQRCode: "QRコードを取得"
|
||||
customSplashText: "カスタムスプラッシュテキスト"
|
||||
customSplashTextDescription: "ロード画面に表示されるテキストを設定します。改行で区切って複数設定できます。"
|
||||
@ -3155,3 +3156,11 @@ _dice:
|
||||
rollDice: "サイコロを振る"
|
||||
diceCount: "サイコロの数"
|
||||
diceFaces: "サイコロの面数"
|
||||
|
||||
_scheduledNoteDelete:
|
||||
expiration: "期限"
|
||||
at: "日時指定"
|
||||
after: "経過指定"
|
||||
deadlineDate: "期日"
|
||||
deadlineTime: "時間"
|
||||
duration: "期間"
|
||||
|
@ -1,5 +1,6 @@
|
||||
---
|
||||
_lang_: "한국어"
|
||||
scheduledNoteDelete: "노트 삭제 예약"
|
||||
getQRCode: "QR 코드 생성"
|
||||
customSplashText: "사용자 정의 스플래시 텍스트"
|
||||
customSplashTextDescription: "스플래시 화면에 표시되는 텍스트를 설정해요. 줄바꿈으로 구분해 설정할 수 있어요."
|
||||
@ -3060,3 +3061,10 @@ _dice:
|
||||
rollDice: "주사위 던지기"
|
||||
diceCount: "주사위 개수"
|
||||
diceFaces: "주사위 면의 수"
|
||||
_scheduledNoteDelete:
|
||||
expiration: "삭제 기한"
|
||||
at: "일시 지정"
|
||||
after: "기간 지정"
|
||||
deadlineDate: "기한"
|
||||
deadlineTime: "시간"
|
||||
duration: "기간"
|
||||
|
11
packages/backend/migration/1720161864577-AddDeleteAt.js
Normal file
11
packages/backend/migration/1720161864577-AddDeleteAt.js
Normal file
@ -0,0 +1,11 @@
|
||||
export class AddDeleteAt1720161864577 {
|
||||
name = 'AddDeleteAt1720161864577'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "deleteAt" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "deleteAt"`)
|
||||
}
|
||||
}
|
@ -147,6 +147,7 @@ type Option = {
|
||||
uri?: string | null;
|
||||
url?: string | null;
|
||||
app?: MiApp | null;
|
||||
deleteAt?: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@ -445,6 +446,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
userHost: user.host,
|
||||
deleteAt: data.deleteAt,
|
||||
});
|
||||
|
||||
if (data.uri != null) insert.uri = data.uri;
|
||||
@ -745,6 +747,16 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.deleteAt) {
|
||||
const delay = data.deleteAt.getTime() - Date.now();
|
||||
this.queueService.scheduledNoteDeleteQueue.add(note.id, {
|
||||
noteId: note.id,
|
||||
}, {
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register to search database
|
||||
this.index(note);
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
RelationshipJobData,
|
||||
UserWebhookDeliverJobData,
|
||||
SystemWebhookDeliverJobData,
|
||||
ScheduledNoteDeleteJobData,
|
||||
} from '../queue/types.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
@ -29,6 +30,7 @@ export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
|
||||
export type ObjectStorageQueue = Bull.Queue;
|
||||
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||
export type ScheduledNoteDeleteQueue = Bull.Queue<ScheduledNoteDeleteJobData>;
|
||||
|
||||
const $system: Provider = {
|
||||
provide: 'queue:system',
|
||||
@ -84,6 +86,12 @@ const $systemWebhookDeliver: Provider = {
|
||||
inject: [DI.config, DI.redisForJobQueue],
|
||||
};
|
||||
|
||||
const $scheduledNoteDelete: Provider = {
|
||||
provide: 'queue:scheduledNoteDelete',
|
||||
useFactory: (config: Config, redisForJobQueue: Redis.Redis) => new Bull.Queue(QUEUE.SCHEDULED_NOTE_DELETE, baseQueueOptions(config, QUEUE.SCHEDULED_NOTE_DELETE, redisForJobQueue)),
|
||||
inject: [DI.config, DI.redisForJobQueue],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
@ -97,6 +105,7 @@ const $systemWebhookDeliver: Provider = {
|
||||
$objectStorage,
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduledNoteDelete,
|
||||
],
|
||||
exports: [
|
||||
$system,
|
||||
@ -108,6 +117,7 @@ const $systemWebhookDeliver: Provider = {
|
||||
$objectStorage,
|
||||
$userWebhookDeliver,
|
||||
$systemWebhookDeliver,
|
||||
$scheduledNoteDelete,
|
||||
],
|
||||
})
|
||||
export class QueueModule implements OnApplicationShutdown {
|
||||
@ -121,6 +131,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
) {}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
@ -137,6 +148,7 @@ export class QueueModule implements OnApplicationShutdown {
|
||||
this.objectStorageQueue.close(),
|
||||
this.userWebhookDeliverQueue.close(),
|
||||
this.systemWebhookDeliverQueue.close(),
|
||||
this.scheduledNoteDeleteQueue.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,7 @@ import type {
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
ScheduledNoteDeleteQueue,
|
||||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
@ -52,6 +53,7 @@ export class QueueService {
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
) {
|
||||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
|
@ -412,6 +412,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
|
||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
||||
event: note.hasEvent ? this.populateEvent(note) : undefined,
|
||||
deleteAt: note.deleteAt?.toISOString() ?? undefined,
|
||||
|
||||
...(meId && Object.keys(reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction({
|
||||
|
@ -258,6 +258,11 @@ export class MiNote {
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public deleteAt: Date | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
|
@ -282,10 +282,14 @@ export const packedNoteSchema = {
|
||||
type: 'number',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
|
||||
myReaction: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
deleteAt: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -41,6 +41,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
|
||||
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
|
||||
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -83,6 +84,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
||||
InboxProcessorService,
|
||||
AggregateRetentionProcessorService,
|
||||
QueueProcessorService,
|
||||
ScheduledNoteDeleteProcessorService,
|
||||
],
|
||||
exports: [
|
||||
QueueProcessorService,
|
||||
|
@ -44,6 +44,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
|
||||
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
|
||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QUEUE, baseQueueOptions } from './const.js';
|
||||
|
||||
@ -85,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private relationshipQueueWorker: Bull.Worker;
|
||||
private objectStorageQueueWorker: Bull.Worker;
|
||||
private endedPollNotificationQueueWorker: Bull.Worker;
|
||||
private scheduledNoteDeleteQueueWorker: Bull.Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@ -127,6 +129,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
|
||||
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
|
||||
private cleanProcessorService: CleanProcessorService,
|
||||
private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
|
||||
@ -511,6 +514,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region scheduled note delete
|
||||
{
|
||||
this.scheduledNoteDeleteQueueWorker = new Bull.Worker(QUEUE.SCHEDULED_NOTE_DELETE, (job) => {
|
||||
if (this.config.sentryForBackend) {
|
||||
return Sentry.startSpan({ name: 'Queue: ScheduledNoteDelete' }, () => this.scheduledNoteDeleteProcessorService.process(job));
|
||||
} else {
|
||||
return this.scheduledNoteDeleteProcessorService.process(job);
|
||||
}
|
||||
}, {
|
||||
...baseQueueOptions(this.config, QUEUE.SCHEDULED_NOTE_DELETE, this.redisForJobQueue),
|
||||
autorun: false,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -525,6 +543,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
this.relationshipQueueWorker.run(),
|
||||
this.objectStorageQueueWorker.run(),
|
||||
this.endedPollNotificationQueueWorker.run(),
|
||||
this.scheduledNoteDeleteQueueWorker.run(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -540,6 +559,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
this.relationshipQueueWorker.close(),
|
||||
this.objectStorageQueueWorker.close(),
|
||||
this.endedPollNotificationQueueWorker.close(),
|
||||
this.scheduledNoteDeleteQueueWorker.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,7 @@ export const QUEUE = {
|
||||
OBJECT_STORAGE: 'objectStorage',
|
||||
USER_WEBHOOK_DELIVER: 'userWebhookDeliver',
|
||||
SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver',
|
||||
SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete',
|
||||
};
|
||||
|
||||
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE], redisConnection: Redis.Redis): Bull.QueueOptions {
|
||||
|
@ -0,0 +1,45 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { ScheduledNoteDeleteJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduledNoteDeleteProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private noteDeleteService: NoteDeleteService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('scheduled-note-delete');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<ScheduledNoteDeleteJobData>): Promise<void> {
|
||||
const note = await this.notesRepository.findOneBy({ id: job.data.noteId });
|
||||
|
||||
if (note == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.noteDeleteService.delete(user, note);
|
||||
this.logger.info(`Delete note ${note.id}`);
|
||||
}
|
||||
}
|
@ -133,3 +133,7 @@ export type UserWebhookDeliverJobData = {
|
||||
export type ThinUser = {
|
||||
id: MiUser['id'];
|
||||
};
|
||||
|
||||
export type ScheduledNoteDeleteJobData = {
|
||||
noteId: MiNote['id'];
|
||||
};
|
||||
|
@ -130,6 +130,12 @@ export const meta = {
|
||||
code: 'CONTAINS_TOO_MANY_MENTIONS',
|
||||
id: '4de0363a-3046-481b-9b0f-feff3e211025',
|
||||
},
|
||||
|
||||
cannotScheduleDeleteEarlierThanNow: {
|
||||
message: 'Cannot specify delete time earlier than now.',
|
||||
code: 'CANNOT_SCHEDULE_DELETE_EARLIER_THAN_NOW',
|
||||
id: '9f04994a-3aa2-11ef-a495-177eea74788f',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -200,6 +206,14 @@ export const paramDef = {
|
||||
metadata: { type: 'object' },
|
||||
},
|
||||
},
|
||||
scheduledDelete: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
deleteAt: { type: 'integer', nullable: true },
|
||||
deleteAfter: { type: 'integer', nullable: true, minimum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
// (re)note with text, files and poll are optional
|
||||
if: {
|
||||
@ -371,6 +385,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.scheduledDelete) {
|
||||
if (typeof ps.scheduledDelete.deleteAt === 'number') {
|
||||
if (ps.scheduledDelete.deleteAt < Date.now()) {
|
||||
throw new ApiError(meta.errors.cannotScheduleDeleteEarlierThanNow);
|
||||
} else if (typeof ps.scheduledDelete.deleteAfter === 'number') {
|
||||
ps.scheduledDelete.deleteAt = Date.now() + ps.scheduledDelete.deleteAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
try {
|
||||
const note = await this.noteCreateService.create(me, {
|
||||
@ -400,6 +424,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : ps.scheduledDelete?.deleteAfter ? new Date(Date.now() + ps.scheduledDelete.deleteAfter) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -33,6 +33,7 @@ import type {
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
ScheduledNoteDeleteQueue,
|
||||
} from '@/core/QueueModule.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@ -124,6 +125,7 @@ export class ClientServerService {
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
@ -252,6 +254,7 @@ export class ClientServerService {
|
||||
this.objectStorageQueue,
|
||||
this.userWebhookDeliverQueue,
|
||||
this.systemWebhookDeliverQueue,
|
||||
this.scheduledNoteDeleteQueue,
|
||||
].map(q => new BullMQAdapter(q)),
|
||||
serverAdapter: bullBoardServerAdapter,
|
||||
});
|
||||
|
@ -4466,6 +4466,8 @@ export type components = {
|
||||
reactionAndUserPairCache?: string[];
|
||||
clippedCount?: number;
|
||||
myReaction?: string | null;
|
||||
/** Format: date-time */
|
||||
deleteAt?: string | null;
|
||||
};
|
||||
NoteReaction: {
|
||||
/**
|
||||
@ -22661,6 +22663,10 @@ export type operations = {
|
||||
end?: number | null;
|
||||
metadata?: Record<string, never>;
|
||||
}) | null;
|
||||
scheduledDelete?: ({
|
||||
deleteAt?: number | null;
|
||||
deleteAfter?: number | null;
|
||||
}) | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -81,6 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i v-else-if="note.reactionAcceptance === 'likeOnly'" v-tooltip="i18n.ts.likeOnly" class="ti ti-heart"></i>
|
||||
</span>
|
||||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;"><i v-tooltip="i18n.ts._visibility['disableFederation']" class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="appearNote.deletedAt" style="margin-left: 0.5em;"><i v-tooltip="i18n.ts.scheduledNoteDelete" class="ti ti-bomb"></i></span>
|
||||
</div>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance" @click="showOnRemote"/>
|
||||
</div>
|
||||
|
@ -38,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-right: 0.5em;"><i v-tooltip="i18n.ts._visibility['disableFederation']" class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="note.channel" style="margin-right: 0.5em;"><i v-tooltip="note.channel.name" class="ti ti-device-tv"></i></span>
|
||||
<span v-if="note.deletedAt" style="margin-right: 0.5em;"><i v-tooltip="i18n.ts.scheduledNoteDelete" class="ti ti-bomb"></i></span>
|
||||
<div v-if="mock">
|
||||
<MkTime :time="note.createdAt" colored/>
|
||||
</div>
|
||||
|
@ -76,6 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<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"/>
|
||||
<MkNotePreview v-if="showPreview" :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>
|
||||
@ -91,6 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
|
||||
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
|
||||
<button v-tooltip="i18n.ts.scheduledNoteDelete" :class="['_button', $style.footerButton]" @click="toggleScheduledNoteDelete"><i class="ti ti-clock-hour-9"></i></button>
|
||||
</div>
|
||||
<div :class="$style.footerRight">
|
||||
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="$style.footerButton" @click="showPreviewMenu"><i class="ti ti-eye"></i></button>
|
||||
@ -136,6 +138,7 @@ import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
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';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@ -218,6 +221,7 @@ const imeText = ref('');
|
||||
const showingOptions = ref(false);
|
||||
const disableRightClick = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@ -396,6 +400,7 @@ function watchForDraft() {
|
||||
watch(localOnly, () => saveDraft());
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
watch(scheduledNoteDelete, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
@ -763,6 +768,7 @@ function saveDraft() {
|
||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
scheduledNoteDelete: scheduledNoteDelete.value,
|
||||
},
|
||||
};
|
||||
|
||||
@ -878,6 +884,7 @@ async function post(ev?: MouseEvent) {
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
disableRightClick: disableRightClick.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
scheduledDelete: scheduledNoteDelete.value,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
|
||||
@ -1091,6 +1098,17 @@ function showPreviewMenu(ev: MouseEvent) {
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function toggleScheduledNoteDelete() {
|
||||
if (scheduledNoteDelete.value) {
|
||||
scheduledNoteDelete.value = null;
|
||||
} else {
|
||||
scheduledNoteDelete.value = {
|
||||
deleteAt: null,
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
focus();
|
||||
@ -1166,6 +1184,12 @@ onMounted(() => {
|
||||
quoteId.value = init.renote ? init.renote.id : null;
|
||||
reactionAcceptance.value = init.reactionAcceptance;
|
||||
disableRightClick.value = init.disableRightClick != null;
|
||||
if (init.deletedAt) {
|
||||
scheduledNoteDelete.value = {
|
||||
deleteAt: init.deletedAt ? (new Date(init.deletedAt)).getTime() : null,
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => watchForDraft());
|
||||
|
@ -87,6 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<input v-show="withHashtags && showForm" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<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"/>
|
||||
<MkNotePreview v-if="showPreview && showForm" :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>
|
||||
@ -106,6 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
|
||||
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
|
||||
<button v-tooltip="i18n.ts.scheduledNoteDelete" :class="['_button', $style.footerButton]" @click="toggleScheduledNoteDelete"><i class="ti ti-clock-hour-9"></i></button>
|
||||
</div>
|
||||
<div :class="$style.footerRight">
|
||||
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="$style.footerButton" @click="showPreviewMenu"><i class="ti ti-eye"></i></button>
|
||||
@ -153,6 +155,7 @@ import { vibrate } from '@/scripts/vibrate.js';
|
||||
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';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@ -236,6 +239,7 @@ const imeText = ref('');
|
||||
const showingOptions = ref(false);
|
||||
const disableRightClick = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
@ -388,6 +392,7 @@ function watchForDraft() {
|
||||
watch(localOnly, () => saveDraft());
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
watch(scheduledNoteDelete, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
@ -752,6 +757,7 @@ function saveDraft() {
|
||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
scheduledNoteDelete: scheduledNoteDelete.value,
|
||||
},
|
||||
};
|
||||
|
||||
@ -867,6 +873,7 @@ async function post(ev?: MouseEvent) {
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
disableRightClick: disableRightClick.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
scheduledDelete: scheduledNoteDelete.value,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
|
||||
@ -1116,6 +1123,17 @@ function showPreviewMenu(ev: MouseEvent) {
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function toggleScheduledNoteDelete() {
|
||||
if (scheduledNoteDelete.value) {
|
||||
scheduledNoteDelete.value = null;
|
||||
} else {
|
||||
scheduledNoteDelete.value = {
|
||||
deleteAt: null,
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
focus();
|
||||
@ -1193,6 +1211,12 @@ onMounted(() => {
|
||||
quoteId.value = init.renote ? init.renote.id : null;
|
||||
reactionAcceptance.value = init.reactionAcceptance;
|
||||
disableRightClick.value = init.disableRightClick != null;
|
||||
if (init.deletedAt) {
|
||||
scheduledNoteDelete.value = {
|
||||
deleteAt: init.deletedAt ? (new Date(init.deletedAt)).getTime() : null,
|
||||
deleteAfter: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => watchForDraft());
|
||||
|
165
packages/frontend/src/components/MkScheduledNoteDelete.vue
Normal file
165
packages/frontend/src/components/MkScheduledNoteDelete.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<span>{{ i18n.ts.scheduledNoteDelete }}</span>
|
||||
<section>
|
||||
<div>
|
||||
<MkSelect v-model="expiration" small>
|
||||
<template #label>{{ i18n.ts._scheduledNoteDelete.expiration }}</template>
|
||||
<option value="at">{{ i18n.ts._scheduledNoteDelete.at }}</option>
|
||||
<option value="after">{{ i18n.ts._scheduledNoteDelete.after }}</option>
|
||||
</MkSelect>
|
||||
<section v-if="expiration === 'at'">
|
||||
<MkInput v-model="atDate" small type="date" class="input">
|
||||
<template #label>{{ i18n.ts._scheduledNoteDelete.deadlineDate }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="atTime" small type="time" class="input">
|
||||
<template #label>{{ i18n.ts._scheduledNoteDelete.deadlineTime }}</template>
|
||||
</MkInput>
|
||||
</section>
|
||||
<section v-else-if="expiration === 'after'">
|
||||
<MkInput v-model="after" small type="number" class="input">
|
||||
<template #label>{{ i18n.ts._scheduledNoteDelete.duration }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="unit" small>
|
||||
<option value="second">{{ i18n.ts._time.second }}</option>
|
||||
<option value="minute">{{ i18n.ts._time.minute }}</option>
|
||||
<option value="hour">{{ i18n.ts._time.hour }}</option>
|
||||
<option value="day">{{ i18n.ts._time.day }}</option>
|
||||
</MkSelect>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import MkSelect from './MkSelect.vue';
|
||||
import { formatDateTimeString } from '@/scripts/format-time-string.js';
|
||||
import { addTime } from '@/scripts/time.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export type DeleteScheduleEditorModelValue = {
|
||||
deleteAt: number | null;
|
||||
deleteAfter: number | null;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DeleteScheduleEditorModelValue;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', v: DeleteScheduleEditorModelValue): void;
|
||||
}>();
|
||||
|
||||
const expiration = ref('at');
|
||||
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
|
||||
const atTime = ref('00:00');
|
||||
const after = ref(0);
|
||||
const unit = ref('second');
|
||||
|
||||
if (props.modelValue.deleteAt) {
|
||||
expiration.value = 'at';
|
||||
const deleteAt = new Date(props.modelValue.deleteAt);
|
||||
atDate.value = formatDateTimeString(deleteAt, 'yyyy-MM-dd');
|
||||
atTime.value = formatDateTimeString(deleteAt, 'HH:mm');
|
||||
} else if (typeof props.modelValue.deleteAfter === 'number') {
|
||||
expiration.value = 'after';
|
||||
after.value = props.modelValue.deleteAfter / 1000;
|
||||
}
|
||||
|
||||
function get(): DeleteScheduleEditorModelValue {
|
||||
const calcAt = () => {
|
||||
return new Date(`${atDate.value} ${atTime.value}`).getTime();
|
||||
};
|
||||
|
||||
const calcAfter = () => {
|
||||
let base = parseInt(after.value.toString());
|
||||
switch (unit.value) {
|
||||
case 'day':
|
||||
return base *= 24;
|
||||
case 'hour':
|
||||
return base *= 60;
|
||||
case 'minute':
|
||||
return base *= 60;
|
||||
case 'second':
|
||||
return base *= 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deleteAt: expiration.value === 'at' ? calcAt() : null,
|
||||
deleteAfter: expiration.value === 'after' ? calcAfter() : null,
|
||||
};
|
||||
}
|
||||
|
||||
watch([expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), {
|
||||
deep: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 8px 16px;
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> ul {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
> .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> button {
|
||||
width: 32px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
margin: 16px 0 0 0;
|
||||
> div {
|
||||
margin: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
&:last-child {
|
||||
flex: 1 0 auto;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> section {
|
||||
// MAGIC: Prevent div above from growing unless wrapped to its own line
|
||||
flex-grow: 9999;
|
||||
align-items: end;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
> .input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user