Compare commits

...

10 Commits

Author SHA1 Message Date
hijiki
deaf864470 2024-10-20 23:09:38 +09:00
ひたりん
6d231c4bc9 Merge pull request #33 from lqvp/master
visibility
2024-10-20 22:59:00 +09:00
mai
a313fc6366 Merge pull request #23 from team-shahu/feat/muted-reaction
feat: リアクションした人一覧がブロック・ミュートを考慮するようにする設定
2024-10-20 22:55:59 +09:00
ひたりん
759109e7a1 Merge pull request #29 from lvpq/yami
fix
2024-10-20 22:53:57 +09:00
ひたりん
da27431d14 Merge pull request #28 from lvpq/yami
add lang
2024-10-20 22:53:04 +09:00
ひたりん
6072b8744f Merge pull request #27 from lvpq/yami
add lang
2024-10-20 22:52:30 +09:00
ひたりん
927111fc0d Merge pull request #26 from lvpq/yami
locale-fix?
2024-10-20 22:49:42 +09:00
ひたりん
455cce801d Merge pull request #25 from lvpq/yami
issueのあれをやった
2024-10-20 22:49:28 +09:00
hijiki
36657836f7 Revert "ノート自動削除"
This reverts commit 10454e688c.
2024-10-20 21:17:04 +09:00
hijiki
10454e688c ノート自動削除 2024-10-20 21:15:45 +09:00
36 changed files with 484 additions and 12 deletions

View File

@ -55,6 +55,11 @@
* ノートの自動削除(cherry-pick) * ノートの自動削除(cherry-pick)
* フォローリクエスト自動拒否(cherry-pick) * フォローリクエスト自動拒否(cherry-pick)
## 2024.8.0-yami-1.2.0
### Feat
* ノートの自動削除(cherry-pick)
* フォローリクエスト自動拒否(cherry-pick)
## 2024.8.0-yami-1.1.0 ## 2024.8.0-yami-1.1.0
### Server ### Server
* Cherry-Pick リバーシの連合に対応(yojo-art/cherrypick) * Cherry-Pick リバーシの連合に対応(yojo-art/cherrypick)

View File

@ -714,6 +714,8 @@ notificationDotWorking: "The notification dot is functioning properly on this in
notificationDotNotWorkingAdvice: "If the notification dot doesn't work, ask an admin to check our documentation {link}" notificationDotNotWorkingAdvice: "If the notification dot doesn't work, ask an admin to check our documentation {link}"
useGlobalSetting: "Use global settings" useGlobalSetting: "Use global settings"
useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made." useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made."
autoRejectFollowRequest: "Automatic rejection of follow requests"
autoRejectFollowRequestDescription: "Enables automatic rejection of follow requests. If \"Automatically approve follow requests from users you are following\" is turned on, follow requests from users you are following will be automatically approved, and follow requests from other users will be automatically rejected."
other: "Other" other: "Other"
regenerateLoginToken: "Regenerate login token" regenerateLoginToken: "Regenerate login token"
regenerateLoginTokenDescription: "Regenerates the token used internally during login. Normally this action is not necessary. If regenerated, all devices will be logged out." regenerateLoginTokenDescription: "Regenerates the token used internally during login. Normally this action is not necessary. If regenerated, all devices will be logged out."
@ -1336,6 +1338,9 @@ _delivery:
manuallySuspended: "Manually suspended" manuallySuspended: "Manually suspended"
goneSuspended: "Server is suspended due to server deletion" goneSuspended: "Server is suspended due to server deletion"
autoSuspendedForNotResponding: "Server is suspended due to no responding" autoSuspendedForNotResponding: "Server is suspended due to no responding"
scheduledNoteDelete: "Time Bomb"
noteDeletationAt: "This note will be deleted at {time}"
_bubbleGame: _bubbleGame:
howToPlay: "How to play" howToPlay: "How to play"
hold: "Hold" hold: "Hold"
@ -2819,3 +2824,6 @@ _contextMenu:
app: "Application" app: "Application"
appWithShift: "Application with shift key" appWithShift: "Application with shift key"
native: "Native" native: "Native"
_reactionChecksMuting:
title: "Check mutings when get reactions"
caption: "Check mutings when get reactions, but cache does not work and may increase traffic"

24
locales/index.d.ts vendored
View File

@ -4113,6 +4113,10 @@ export interface Locale extends ILocale {
* *
*/ */
"beta": string; "beta": string;
/**
*
*/
"originalFeature": string;
/** /**
* *
*/ */
@ -5266,6 +5270,14 @@ export interface Locale extends ILocale {
*/ */
"gameRetry": string; "gameRetry": string;
/** /**
*
*/
"scheduledNoteDelete": string;
/**
* {time}
*/
"noteDeletationAt": ParameterizedString<"time">;
/**
* 使 * 使
*/ */
"notUsePleaseLeaveBlank": string; "notUsePleaseLeaveBlank": string;
@ -5411,6 +5423,8 @@ export interface Locale extends ILocale {
"section3": string; "section3": string;
}; };
}; };
"autoRejectFollowRequest": string;
"autoRejectFollowRequestDescription": string;
"_announcement": { "_announcement": {
/** /**
* *
@ -10881,6 +10895,16 @@ export interface Locale extends ILocale {
*/ */
"native": string; "native": string;
}; };
"_reactionChecksMuting": {
/**
*
*/
"title": string;
/**
*
*/
"caption": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -1024,6 +1024,7 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある
cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。"
cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。"
beta: "ベータ" beta: "ベータ"
originalFeature: "独自機能"
enableAutoSensitive: "自動センシティブ判定" enableAutoSensitive: "自動センシティブ判定"
enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにセンシティブフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにセンシティブフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。"
activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。"
@ -1337,6 +1338,8 @@ _delivery:
manuallySuspended: "手動停止中" manuallySuspended: "手動停止中"
goneSuspended: "サーバー削除のため停止中" goneSuspended: "サーバー削除のため停止中"
autoSuspendedForNotResponding: "サーバー応答なしのため停止中" autoSuspendedForNotResponding: "サーバー応答なしのため停止中"
scheduledNoteDelete: "時限爆弾"
noteDeletationAt: "このノートは{time}に削除されます"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"
@ -1353,6 +1356,8 @@ _bubbleGame:
section1: "位置を調整してハコにモノを落とします。" section1: "位置を調整してハコにモノを落とします。"
section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。" section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。"
section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!" section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!"
autoRejectFollowRequest: "フォローリクエストを自動で拒否する"
autoRejectFollowRequestDescription: "フォローリクエストを自動で拒否するようにします。「フォロー中ユーザーからのフォロリクを自動承認」がONになっている場合は、フォロー中ユーザーからのフォローリクエストは自動的に承認され、それ以外のユーザーからのフォローリクエストは自動的に拒否されるようになります。"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"
@ -2894,3 +2899,7 @@ _contextMenu:
app: "アプリケーション" app: "アプリケーション"
appWithShift: "Shiftキーでアプリケーション" appWithShift: "Shiftキーでアプリケーション"
native: "ブラウザのUI" native: "ブラウザのUI"
_reactionChecksMuting:
title: "リアクションでミュートを考慮する"
caption: "リアクションがミュートを考慮しますが、キャッシュが効かず通信量が増えることがあります。"

View File

@ -693,6 +693,8 @@ notificationSetting: "通知設定"
notificationSettingDesc: "出す通知の種類えらんでや。" notificationSettingDesc: "出す通知の種類えらんでや。"
useGlobalSetting: "グローバル設定を使ってや" useGlobalSetting: "グローバル設定を使ってや"
useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。"
autoRejectFollowRequest: "フォローリクエストを自動で拒否するで"
autoRejectFollowRequestDescription: "フォローリクエストを自動で拒否するようになるで。「フォロー中ユーザーからのフォロリクを自動承認」がONになってはると、フォロー中ユーザーからフォローリクエストが自動で承認されはり、それ以外のユーザーからのフォローリクエストは自動的に拒否されるで。"
other: "その他" other: "その他"
regenerateLoginToken: "ログイントークンを再生成" regenerateLoginToken: "ログイントークンを再生成"
regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。"

View File

@ -0,0 +1,11 @@
export class FeatAutoRejectFollowRequest1697683129062 {
name = 'FeatAutoRejectFollowRequest1697683129062'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "autoRejectFollowRequest" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "autoRejectFollowRequest"`);
}
}

View File

@ -0,0 +1,11 @@
export class ScheduledNoteDelete1709187210308 {
name = 'ScheduledNoteDelete1709187210308'
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"`);
}
}

View File

@ -62,6 +62,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { Data } from 'ws';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -148,6 +149,7 @@ type Option = {
uri?: string | null; uri?: string | null;
url?: string | null; url?: string | null;
app?: MiApp | null; app?: MiApp | null;
deleteAt?: Date | null;
}; };
@Injectable() @Injectable()
@ -439,6 +441,7 @@ export class NoteCreateService implements OnApplicationShutdown {
name: data.name, name: data.name,
text: data.text, text: data.text,
hasPoll: data.poll != null, hasPoll: data.poll != null,
deleteAt: data.deleteAt,
cw: data.cw ?? null, cw: data.cw ?? null,
tags: tags.map(tag => normalizeForSearch(tag)), tags: tags.map(tag => normalizeForSearch(tag)),
emojis, emojis,
@ -614,6 +617,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,
});
}
if (!silent) { if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);

View File

@ -195,7 +195,9 @@ export class QueryService {
qb qb
.where('note.visibility = \'public\'') .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
})); })
.andWhere('note.localOnly = FALSE') // 連合なしのノートは未ログイン者には見せない
);
} else { } else {
const followingQuery = this.followingsRepository.createQueryBuilder('following') const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')

View File

@ -21,6 +21,7 @@ import type { Provider } from '@nestjs/common';
export type SystemQueue = Bull.Queue<Record<string, unknown>>; export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type ScheduledNoteDeleteQueue = Bull.Queue<ScheduledNoteDeleteJobData>
export type DeliverQueue = Bull.Queue<DeliverJobData>; export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>; export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue; export type DbQueue = Bull.Queue;
@ -41,6 +42,12 @@ const $endedPollNotification: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $scheduledNoteDeleted: Provider = {
provide: 'queue:scheduledNoteDelete',
useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULED_NOTE_DELETE, baseQueueOptions(config, QUEUE.SCHEDULED_NOTE_DELETE)),
inject: [DI.config],
};
const $deliver: Provider = { const $deliver: Provider = {
provide: 'queue:deliver', provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@ -89,6 +96,7 @@ const $systemWebhookDeliver: Provider = {
providers: [ providers: [
$system, $system,
$endedPollNotification, $endedPollNotification,
$scheduledNoteDeleted,
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
@ -100,6 +108,7 @@ const $systemWebhookDeliver: Provider = {
exports: [ exports: [
$system, $system,
$endedPollNotification, $endedPollNotification,
$scheduledNoteDeleted,
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
@ -113,6 +122,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor( constructor(
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@ -129,6 +139,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([ await Promise.all([
this.systemQueue.close(), this.systemQueue.close(),
this.endedPollNotificationQueue.close(), this.endedPollNotificationQueue.close(),
this.scheduledNoteDeleteQueue.close(),
this.deliverQueue.close(), this.deliverQueue.close(),
this.inboxQueue.close(), this.inboxQueue.close(),
this.dbQueue.close(), this.dbQueue.close(),

View File

@ -45,6 +45,7 @@ export class QueueService {
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,

View File

@ -214,6 +214,20 @@ export class UserFollowingService implements OnModuleInit {
} }
if (!autoAccept) { if (!autoAccept) {
// autoAcceptが無効かつautoRejectが有効な場合はフォローリクエストを拒否する
if (this.userEntityService.isLocalUser(followee) && followeeProfile.autoRejectFollowRequest) {
if (this.userEntityService.isRemoteUser(follower)) {
// リモートからならRejectを返す
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
// ローカルユーザーに対しては敢えてpublishUnfollowせずにお茶を濁す
// フォローできない不具合と勘違いされたりフォローリクエストを連打される可能性があるため
return;
}
await this.createFollowRequest(follower, followee, requestId, withReplies); await this.createFollowRequest(follower, followee, requestId, withReplies);
return; return;
} }
@ -579,7 +593,9 @@ export class UserFollowingService implements OnModuleInit {
}); });
if (!requestExist) { if (!requestExist) {
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); // throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
// 本来ならエラーを返すが、フォローリクエストの自動拒否機能の関係上、エラーを返さずに無視する
return;
} }
await this.followRequestsRepository.delete({ await this.followRequestsRepository.delete({

View File

@ -97,6 +97,11 @@ export class NoteEntityService implements OnModuleInit {
} }
} }
// 連合なしで未ログインなら非表示
if(packedNote.localOnly && !meId){
hide = true;
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (packedNote.visibility === 'followers') { if (packedNote.visibility === 'followers') {
if (meId == null) { if (meId == null) {
@ -241,6 +246,11 @@ export class NoteEntityService implements OnModuleInit {
} }
} }
// 連合なし、かつ visibility が home で未ログインなら非表示
if(note.localOnly && note.visibility === 'home' && !meId){
return false;
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (note.visibility === 'followers') { if (note.visibility === 'followers') {
if (meId == null) { if (meId == null) {
@ -395,6 +405,13 @@ export class NoteEntityService implements OnModuleInit {
withReactionAndUserPairCache: opts.withReactionAndUserPairCache, withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_, _hint_: options?._hint_,
}) : undefined, }) : undefined,
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
deleteAt: note.deleteAt?.toISOString() ?? undefined,
...(meId && Object.keys(note.reactions).length > 0 ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
} : {}),
} : {}), } : {}),
}); });

View File

@ -620,6 +620,7 @@ export class UserEntityService implements OnModuleInit {
autoSensitive: profile!.autoSensitive, autoSensitive: profile!.autoSensitive,
carefulBot: profile!.carefulBot, carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed, autoAcceptFollowed: profile!.autoAcceptFollowed,
autoRejectFollowRequest: profile!.autoRejectFollowRequest,
noCrawle: profile!.noCrawle, noCrawle: profile!.noCrawle,
preventAiLearning: profile!.preventAiLearning, preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable, isExplorable: user.isExplorable,

View File

@ -188,6 +188,11 @@ export class MiNote {
}) })
public hasPoll: boolean; public hasPoll: boolean;
@Column('timestamp with time zone', {
nullable: true,
})
public deleteAt: Date | null;
@Index() @Index()
@Column({ @Column({
...id(), ...id(),

View File

@ -172,6 +172,11 @@ export class MiUserProfile {
}) })
public autoAcceptFollowed: boolean; public autoAcceptFollowed: boolean;
@Column('boolean', {
default: false,
})
public autoRejectFollowRequest: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
comment: 'Whether reject index by crawler.', comment: 'Whether reject index by crawler.',

View File

@ -152,6 +152,11 @@ export const packedNoteSchema = {
}, },
}, },
}, },
deleteAt: {
type: 'string',
optional: true, nullable: true,
format: 'date-time',
},
emojis: { emojis: {
type: 'object', type: 'object',
optional: true, nullable: false, optional: true, nullable: false,

View File

@ -41,6 +41,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
@Module({ @Module({
imports: [ imports: [
@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
UserWebhookDeliverProcessorService, UserWebhookDeliverProcessorService,
SystemWebhookDeliverProcessorService, SystemWebhookDeliverProcessorService,
EndedPollNotificationProcessorService, EndedPollNotificationProcessorService,
ScheduledNoteDeleteProcessorService,
DeliverProcessorService, DeliverProcessorService,
InboxProcessorService, InboxProcessorService,
AggregateRetentionProcessorService, AggregateRetentionProcessorService,

View File

@ -45,6 +45,7 @@ import { AggregateRetentionProcessorService } from './processors/AggregateRetent
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QUEUE, baseQueueOptions } from './const.js'; import { QUEUE, baseQueueOptions } from './const.js';
import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
function httpRelatedBackoff(attemptsMade: number) { function httpRelatedBackoff(attemptsMade: number) {
@ -84,6 +85,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private relationshipQueueWorker: Bull.Worker; private relationshipQueueWorker: Bull.Worker;
private objectStorageQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker;
private endedPollNotificationQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker;
private scheduledNoteDeleteQueueWorker: Bull.Worker;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -93,6 +95,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService,
private deliverProcessorService: DeliverProcessorService, private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService, private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@ -513,6 +516,12 @@ export class QueueProcessorService implements OnApplicationShutdown {
}); });
} }
//#endregion //#endregion
//#region scheduled note delete
this.scheduledNoteDeleteQueueWorker = new Bull.Worker(QUEUE.SCHEDULED_NOTE_DELETE, (job) => this.scheduledNoteDeleteProcessorService.process(job), {
...baseQueueOptions(this.config, QUEUE.SCHEDULED_NOTE_DELETE),
autorun: false,
});
} }
@bindThis @bindThis
@ -527,6 +536,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(), this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(), this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(), this.endedPollNotificationQueueWorker.run(),
this.scheduledNoteDeleteQueueWorker.run(),
]); ]);
} }
@ -542,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(), this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(), this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(), this.endedPollNotificationQueueWorker.close(),
this.scheduledNoteDeleteQueueWorker.close(),
]); ]);
} }

View File

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

View File

@ -0,0 +1,43 @@
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(`Deleted note ${note.id}`);
}
}

View File

@ -131,6 +131,10 @@ export type EndedPollNotificationJobData = {
noteId: MiNote['id']; noteId: MiNote['id'];
}; };
export type ScheduledNoteDeleteJobData = {
noteId: MiNote['id'];
}
export type SystemWebhookDeliverJobData = { export type SystemWebhookDeliverJobData = {
type: string; type: string;
content: unknown; content: unknown;

View File

@ -183,6 +183,7 @@ export const paramDef = {
publicReactions: { type: 'boolean' }, publicReactions: { type: 'boolean' },
carefulBot: { type: 'boolean' }, carefulBot: { type: 'boolean' },
autoAcceptFollowed: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' },
autoRejectFollowRequest: { type: 'boolean' },
noCrawle: { type: 'boolean' }, noCrawle: { type: 'boolean' },
preventAiLearning: { type: 'boolean' }, preventAiLearning: { type: 'boolean' },
noindex: { type: 'boolean' }, noindex: { type: 'boolean' },
@ -337,6 +338,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
if (typeof ps.autoRejectFollowRequest === 'boolean') profileUpdates.autoRejectFollowRequest = ps.autoRejectFollowRequest;
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat; if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat;

View File

@ -104,6 +104,12 @@ export const meta = {
id: '04da457d-b083-4055-9082-955525eda5a5', id: '04da457d-b083-4055-9082-955525eda5a5',
}, },
cannotScheduleDeleteEarlierThanNow: {
message: 'Scheduled delete time is earlier than now.',
code: 'CANNOT_SCHEDULE_DELETE_EARLIER_THAN_NOW',
id: '9576c3c8-d8f3-11ee-ac15-00155d19d35d',
},
noSuchChannel: { noSuchChannel: {
message: 'No such channel.', message: 'No such channel.',
code: 'NO_SUCH_CHANNEL', code: 'NO_SUCH_CHANNEL',
@ -197,6 +203,14 @@ export const paramDef = {
}, },
required: ['choices'], required: ['choices'],
}, },
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 // (re)note with text, files and poll are optional
if: { if: {
@ -365,6 +379,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;
}
}
let channel: MiChannel | null = null; let channel: MiChannel | null = null;
if (ps.channelId != null) { if (ps.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
@ -396,6 +420,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
apMentions: ps.noExtractMentions ? [] : undefined, apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined,
deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : null,
}); });
return { return {

View File

@ -4,13 +4,12 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets, type FindOptionsWhere } from 'typeorm';
import type { NoteReactionsRepository } from '@/models/_.js'; import type { NoteReactionsRepository } from '@/models/_.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = { export const meta = {
tags: ['notes', 'reactions'], tags: ['notes', 'reactions'],
@ -59,6 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteReactionEntityService: NoteReactionEntityService, private noteReactionEntityService: NoteReactionEntityService,
private queryService: QueryService, private queryService: QueryService,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId)
@ -66,6 +66,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reaction.user', 'user') .leftJoinAndSelect('reaction.user', 'user')
.leftJoinAndSelect('reaction.note', 'note'); .leftJoinAndSelect('reaction.note', 'note');
if (me != null) {
const [userIdsWhoMeMuting, userIdsWhoBlockingMe] = await Promise.all([
this.cacheService.userMutingsCache.get(me.id),
this.cacheService.userBlockedCache.get(me.id),
]);
query.andWhere('reaction.userId NOT IN (:...userIds)', { userIds: Array.from(userIdsWhoMeMuting ?? []).concat(Array.from(userIdsWhoBlockingMe ?? [])) });
}
if (ps.type) { if (ps.type) {
// ローカルリアクションはホスト名が . とされているが // ローカルリアクションはホスト名が . とされているが
// DB 上ではそうではないので、必要に応じて変換 // DB 上ではそうではないので、必要に応じて変換

View File

@ -30,7 +30,7 @@ import type {
DeliverQueue, DeliverQueue,
EndedPollNotificationQueue, EndedPollNotificationQueue,
InboxQueue, InboxQueue,
ObjectStorageQueue, ObjectStorageQueue, ScheduledNoteDeleteQueue,
SystemQueue, SystemQueue,
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
@ -116,6 +116,7 @@ export class ClientServerService {
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@ -248,6 +249,7 @@ export class ClientServerService {
queues: [ queues: [
this.systemQueue, this.systemQueue,
this.endedPollNotificationQueue, this.endedPollNotificationQueue,
this.scheduledNoteDeleteQueue,
this.deliverQueue, this.deliverQueue,
this.inboxQueue, this.inboxQueue,
this.dbQueue, this.dbQueue,

View File

@ -0,0 +1,165 @@
<template>
<div class="zmdxowus">
<span>{{ i18n.ts.scheduledNoteDelete }}</span>
<section>
<div>
<MkSelect v-model="expiration" small>
<template #label>{{ i18n.ts._poll.expiration }}</template>
<option value="at">{{ i18n.ts._poll.at }}</option>
<option value="after">{{ i18n.ts._poll.after }}</option>
</MkSelect>
<section v-if="expiration === 'at'">
<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>
<section v-else-if="expiration === 'after'">
<MkInput v-model="after" small type="number" class="input">
<template #label>{{ i18n.ts._poll.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) {
// @ts-expect-error fallthrough
case 'day': base *= 24;
// @ts-expect-error fallthrough
case 'hour': base *= 60;
// @ts-expect-error fallthrough
case 'minute': base *= 60;
// eslint-disable-next-line no-fallthrough
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" scoped>
.zmdxowus {
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>

View File

@ -116,6 +116,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="notePage(appearNote)"> <MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/> <MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA> </MkA>
<span v-if="appearNote.deleteAt"><i class="ti ti-bomb"></i>{{ i18n.ts.scheduledNoteDelete }}: <MkTime :time="appearNote.deleteAt" mode="detail" colored/></span>
</div> </div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/> <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()"> <button class="_button" :class="$style.noteFooterButton" @click="reply()">

View File

@ -31,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
<span v-if="note.deleteAt" style="margin-left: 0.5em;" :title="i18n.tsx.noteDeletationAt({ time: dateTimeFormat.format(new Date(note.deleteAt)) })"><i class="ti ti-bomb"></i></span>
</div> </div>
</header> </header>
</template> </template>
@ -43,6 +44,7 @@ import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js';
import { popupMenu } from '@/os.js'; import { popupMenu } from '@/os.js';
import { dateTimeFormat } from '@/scripts/intl-const.js';
const props = defineProps<{ const props = defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;

View File

@ -74,6 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkDeleteScheduleEditor 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"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;"> <div v-if="showingOptions" style="padding: 8px 16px;">
</div> </div>
@ -81,6 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.footerLeft"> <div :class="$style.footerLeft">
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
<button v-tooltip="i18n.ts.scheduledNoteDelete" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: scheduledNoteDelete }]" @click="toggleScheduledNoteDelete"><i class="ti ti-bomb"></i></button>
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
@ -110,6 +112,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue'; import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
import MkDeleteScheduleEditor, { type DeleteScheduleEditorModelValue } from '@/components/MkDeleteScheduleEditor.vue';
import { host, url } from '@/config.js'; import { host, url } from '@/config.js';
import { erase, unique } from '@/scripts/array.js'; import { erase, unique } from '@/scripts/array.js';
import { extractMentions } from '@/scripts/extract-mentions.js'; import { extractMentions } from '@/scripts/extract-mentions.js';
@ -182,6 +185,7 @@ const posted = ref(false);
const text = ref(props.initialText ?? ''); const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []); const files = ref(props.initialFiles ?? []);
const poll = ref<PollEditorModelValue | null>(null); const poll = ref<PollEditorModelValue | null>(null);
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
const useCw = ref<boolean>(!!props.initialCw); const useCw = ref<boolean>(!!props.initialCw);
const showPreview = ref(defaultStore.state.showPreview); const showPreview = ref(defaultStore.state.showPreview);
watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
@ -366,6 +370,7 @@ function watchForDraft() {
watch(useCw, () => saveDraft()); watch(useCw, () => saveDraft());
watch(cw, () => saveDraft()); watch(cw, () => saveDraft());
watch(poll, () => saveDraft()); watch(poll, () => saveDraft());
watch(scheduledNoteDelete, () => saveDraft());
watch(files, () => saveDraft(), { deep: true }); watch(files, () => saveDraft(), { deep: true });
watch(visibility, () => saveDraft()); watch(visibility, () => saveDraft());
watch(localOnly, () => saveDraft()); watch(localOnly, () => saveDraft());
@ -421,6 +426,17 @@ function togglePoll() {
} }
} }
function toggleScheduledNoteDelete() {
if (scheduledNoteDelete.value) {
scheduledNoteDelete.value = null;
} else {
scheduledNoteDelete.value = {
deleteAt: null,
deleteAfter: null,
};
}
}
function addTag(tag: string) { function addTag(tag: string) {
insertTextAtCursor(textareaEl.value, ` #${tag} `); insertTextAtCursor(textareaEl.value, ` #${tag} `);
} }
@ -718,6 +734,7 @@ function saveDraft() {
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
quoteId: quoteId.value, quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
scheduledNoteDelete: scheduledNoteDelete.value,
}, },
}; };
@ -794,6 +811,7 @@ async function post(ev?: MouseEvent) {
renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined, renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
channelId: props.channel ? props.channel.id : undefined, channelId: props.channel ? props.channel.id : undefined,
poll: poll.value, poll: poll.value,
scheduledDelete: scheduledNoteDelete.value,
cw: useCw.value ? cw.value ?? '' : null, cw: useCw.value ? cw.value ?? '' : null,
localOnly: localOnly.value, localOnly: localOnly.value,
visibility: visibility.value, visibility: visibility.value,
@ -1029,6 +1047,9 @@ onMounted(() => {
} }
quoteId.value = draft.data.quoteId; quoteId.value = draft.data.quoteId;
reactionAcceptance.value = draft.data.reactionAcceptance; reactionAcceptance.value = draft.data.reactionAcceptance;
if (draft.data.scheduledNoteDelete) {
scheduledNoteDelete.value = draft.data.scheduledNoteDelete;
}
} }
} }
@ -1049,6 +1070,12 @@ onMounted(() => {
expiredAfter: null, expiredAfter: null,
}; };
} }
if (init.deleteAt) {
scheduledNoteDelete.value = {
deleteAt: init.deleteAt ? (new Date(init.deleteAt)).getTime() : null,
deleteAfter: null,
};
}
if (init.visibleUserIds) { if (init.visibleUserIds) {
misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => { misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => {
users.forEach(u => pushVisibleUser(u)); users.forEach(u => pushVisibleUser(u));

View File

@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar :class="$style.avatar" :user="u"/> <MkAvatar :class="$style.avatar" :user="u"/>
<MkUserName :user="u" :nowrap="true"/> <MkUserName :user="u" :nowrap="true"/>
</div> </div>
<div v-if="count <= 0" :class="$style.user"> {{ i18n.ts.noUsers }} </div>
<div v-if="count > 10" :class="$style.more">+{{ count - 10 }}</div> <div v-if="count > 10" :class="$style.more">+{{ count - 10 }}</div>
</div> </div>
</div> </div>
@ -26,6 +27,7 @@ import { } from 'vue';
import MkTooltip from './MkTooltip.vue'; import MkTooltip from './MkTooltip.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue';
import { getEmojiName } from '@/scripts/emojilist.js'; import { getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n';
defineProps<{ defineProps<{
showing: boolean; showing: boolean;

View File

@ -36,6 +36,8 @@ import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.j
import { customEmojisMap } from '@/custom-emojis.js'; import { customEmojisMap } from '@/custom-emojis.js';
import { getUnicodeEmoji } from '@/scripts/emojilist.js'; import { getUnicodeEmoji } from '@/scripts/emojilist.js';
const reactionChecksMuting = computed(defaultStore.makeGetterSetter('reactionChecksMuting'));
const props = defineProps<{ const props = defineProps<{
reaction: string; reaction: string;
count: number; count: number;
@ -146,7 +148,9 @@ onMounted(() => {
if (!mock) { if (!mock) {
useTooltip(buttonEl, async (showing) => { useTooltip(buttonEl, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', { const useGet = !reactionChecksMuting.value;
const apiCall = useGet ? misskeyApiGet : misskeyApi;
const reactions = await apiCall('notes/reactions', {
noteId: props.note.id, noteId: props.note.id,
type: props.reaction, type: props.reaction,
limit: 10, limit: 10,
@ -154,12 +158,13 @@ if (!mock) {
}); });
const users = reactions.map(x => x.user); const users = reactions.map(x => x.user);
const count = users.length;
const { dispose } = os.popup(XDetails, { const { dispose } = os.popup(XDetails, {
showing, showing,
reaction: props.reaction, reaction: props.reaction,
users, users,
count: props.count, count,
targetElement: buttonEl.value, targetElement: buttonEl.value,
}, { }, {
closed: () => dispose(), closed: () => dispose(),

View File

@ -235,6 +235,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
<MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch> <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch>
<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch> <MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch>
<MkSwitch v-model="reactionChecksMuting">
{{ i18n.ts._reactionChecksMuting.title }}<span class="_beta">{{ i18n.ts.originalFeature }}</span>
<template #caption>{{ i18n.ts._reactionChecksMuting.caption }}</template>
</MkSwitch>
</div> </div>
<MkSelect v-model="serverDisconnectedBehavior"> <MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template> <template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -421,8 +426,6 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
const showTickerOnReplies = computed(defaultStore.makeGetterSetter('showTickerOnReplies')); const showTickerOnReplies = computed(defaultStore.makeGetterSetter('showTickerOnReplies'));
//const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine'));
const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine'));
const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign')); const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign'));
const uncollapseCW = computed(defaultStore.makeGetterSetter('uncollapseCW')); const uncollapseCW = computed(defaultStore.makeGetterSetter('uncollapseCW'));
@ -435,6 +438,8 @@ const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('u
const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia')); const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia'));
const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine'));
const reactionChecksMuting = computed(defaultStore.makeGetterSetter('reactionChecksMuting'));
watch(lang, () => { watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string); miLocalStorage.setItem('lang', lang.value as string);

View File

@ -7,6 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkSwitch v-model="isLocked" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> <MkSwitch v-model="isLocked" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch>
<MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch> <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch>
<MkSwitch v-if="isLocked" v-model="autoRejectFollowRequest" @update:modelValue="save()">
{{ i18n.ts.autoRejectFollowRequest }}<span class="_beta">{{ i18n.ts.originalFeature }}</span>
<template #caption>{{ i18n.ts.autoRejectFollowRequestDescription }}</template>
</MkSwitch>
<MkSwitch v-model="publicReactions" @update:modelValue="save()"> <MkSwitch v-model="publicReactions" @update:modelValue="save()">
{{ i18n.ts.makeReactionsPublic }} {{ i18n.ts.makeReactionsPublic }}
@ -87,6 +91,7 @@ const $i = signinRequired();
const isLocked = ref($i.isLocked); const isLocked = ref($i.isLocked);
const autoAcceptFollowed = ref($i.autoAcceptFollowed); const autoAcceptFollowed = ref($i.autoAcceptFollowed);
const autoRejectFollowRequest = ref($i.autoRejectFollowRequest);
const noCrawle = ref($i.noCrawle); const noCrawle = ref($i.noCrawle);
const noindex = ref($i.noindex); const noindex = ref($i.noindex);
const isExplorable = ref($i.isExplorable); const isExplorable = ref($i.isExplorable);
@ -104,6 +109,7 @@ function save() {
misskeyApi('i/update', { misskeyApi('i/update', {
isLocked: !!isLocked.value, isLocked: !!isLocked.value,
autoAcceptFollowed: !!autoAcceptFollowed.value, autoAcceptFollowed: !!autoAcceptFollowed.value,
autoRejectFollowRequest: !!autoRejectFollowRequest.value,
noCrawle: !!noCrawle.value, noCrawle: !!noCrawle.value,
noindex: !!noindex.value, noindex: !!noindex.value,
isExplorable: !!isExplorable.value, isExplorable: !!isExplorable.value,

View File

@ -532,10 +532,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: false, default: false,
}, },
contextMenu: { contextMenu: {
where: 'device', where: 'device',
default: 'app' as 'app' | 'appWithShift' | 'native', default: 'app' as 'app' | 'appWithShift' | 'native',
}, },
sound_masterVolume: { sound_masterVolume: {
where: 'device', where: 'device',
@ -565,6 +565,14 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
}, },
searchEngine: {
where: 'device',
default: 'https://google.com/search?q=',
},
reactionChecksMuting: {
where: 'device',
default: true,
},
})); }));
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期

View File

@ -4145,7 +4145,7 @@ export type components = {
/** @enum {string} */ /** @enum {string} */
icon: 'info' | 'warning' | 'error' | 'success'; icon: 'info' | 'warning' | 'error' | 'success';
/** @enum {string} */ /** @enum {string} */
display: 'dialog' | 'normal' | 'banner'; display: 'dialog' | 'normal' | 'banner' | 'emergency';
needConfirmationToRead: boolean; needConfirmationToRead: boolean;
silence: boolean; silence: boolean;
forYou: boolean; forYou: boolean;
@ -4204,6 +4204,8 @@ export type components = {
votes: number; votes: number;
}[]; }[];
}) | null; }) | null;
/** Format: date-time */
deleteAt?: string | null;
emojis?: { emojis?: {
[key: string]: string; [key: string]: string;
}; };
@ -21897,6 +21899,10 @@ export type operations = {
expiresAt?: number | null; expiresAt?: number | null;
expiredAfter?: number | null; expiredAfter?: number | null;
}) | null; }) | null;
scheduledDelete?: ({
deleteAt?: number | null;
deleteAfter?: number | null;
}) | null;
}; };
}; };
}; };