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)
## 2024.8.0-yami-1.2.0
### Feat
* ノートの自動削除(cherry-pick)
* フォローリクエスト自動拒否(cherry-pick)
## 2024.8.0-yami-1.1.0
### Server
* 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}"
useGlobalSetting: "Use global settings"
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"
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."
@ -1336,6 +1338,9 @@ _delivery:
manuallySuspended: "Manually suspended"
goneSuspended: "Server is suspended due to server deletion"
autoSuspendedForNotResponding: "Server is suspended due to no responding"
scheduledNoteDelete: "Time Bomb"
noteDeletationAt: "This note will be deleted at {time}"
_bubbleGame:
howToPlay: "How to play"
hold: "Hold"
@ -2819,3 +2824,6 @@ _contextMenu:
app: "Application"
appWithShift: "Application with shift key"
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;
/**
*
*/
"originalFeature": string;
/**
*
*/
@ -5266,6 +5270,14 @@ export interface Locale extends ILocale {
*/
"gameRetry": string;
/**
*
*/
"scheduledNoteDelete": string;
/**
* {time}
*/
"noteDeletationAt": ParameterizedString<"time">;
/**
* 使
*/
"notUsePleaseLeaveBlank": string;
@ -5411,6 +5423,8 @@ export interface Locale extends ILocale {
"section3": string;
};
};
"autoRejectFollowRequest": string;
"autoRejectFollowRequestDescription": string;
"_announcement": {
/**
*
@ -10881,6 +10895,16 @@ export interface Locale extends ILocale {
*/
"native": string;
};
"_reactionChecksMuting": {
/**
*
*/
"title": string;
/**
*
*/
"caption": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

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

View File

@ -693,6 +693,8 @@ notificationSetting: "通知設定"
notificationSettingDesc: "出す通知の種類えらんでや。"
useGlobalSetting: "グローバル設定を使ってや"
useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。"
autoRejectFollowRequest: "フォローリクエストを自動で拒否するで"
autoRejectFollowRequestDescription: "フォローリクエストを自動で拒否するようになるで。「フォロー中ユーザーからのフォロリクを自動承認」がONになってはると、フォロー中ユーザーからフォローリクエストが自動で承認されはり、それ以外のユーザーからのフォローリクエストは自動的に拒否されるで。"
other: "その他"
regenerateLoginToken: "ログイントークンを再生成"
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 { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { Data } from 'ws';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -148,6 +149,7 @@ type Option = {
uri?: string | null;
url?: string | null;
app?: MiApp | null;
deleteAt?: Date | null;
};
@Injectable()
@ -439,6 +441,7 @@ export class NoteCreateService implements OnApplicationShutdown {
name: data.name,
text: data.text,
hasPoll: data.poll != null,
deleteAt: data.deleteAt,
cw: data.cw ?? null,
tags: tags.map(tag => normalizeForSearch(tag)),
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 (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);

View File

@ -195,7 +195,9 @@ export class QueryService {
qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}));
})
.andWhere('note.localOnly = FALSE') // 連合なしのノートは未ログイン者には見せない
);
} else {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.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 EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type ScheduledNoteDeleteQueue = Bull.Queue<ScheduledNoteDeleteJobData>
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue;
@ -41,6 +42,12 @@ const $endedPollNotification: Provider = {
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 = {
provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@ -89,6 +96,7 @@ const $systemWebhookDeliver: Provider = {
providers: [
$system,
$endedPollNotification,
$scheduledNoteDeleted,
$deliver,
$inbox,
$db,
@ -100,6 +108,7 @@ const $systemWebhookDeliver: Provider = {
exports: [
$system,
$endedPollNotification,
$scheduledNoteDeleted,
$deliver,
$inbox,
$db,
@ -113,6 +122,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@ -129,6 +139,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
this.scheduledNoteDeleteQueue.close(),
this.deliverQueue.close(),
this.inboxQueue.close(),
this.dbQueue.close(),

View File

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

View File

@ -214,6 +214,20 @@ export class UserFollowingService implements OnModuleInit {
}
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);
return;
}
@ -579,7 +593,9 @@ export class UserFollowingService implements OnModuleInit {
});
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({

View File

@ -97,6 +97,11 @@ export class NoteEntityService implements OnModuleInit {
}
}
// 連合なしで未ログインなら非表示
if(packedNote.localOnly && !meId){
hide = true;
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (packedNote.visibility === 'followers') {
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 かつ自分が投稿者のフォロワーでなかったら非表示
if (note.visibility === 'followers') {
if (meId == null) {
@ -395,6 +405,13 @@ export class NoteEntityService implements OnModuleInit {
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
}) : 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,
carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed,
autoRejectFollowRequest: profile!.autoRejectFollowRequest,
noCrawle: profile!.noCrawle,
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,

View File

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

View File

@ -172,6 +172,11 @@ export class MiUserProfile {
})
public autoAcceptFollowed: boolean;
@Column('boolean', {
default: false,
})
public autoRejectFollowRequest: boolean;
@Column('boolean', {
default: false,
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: {
type: 'object',
optional: true, nullable: false,

View File

@ -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: [
@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
UserWebhookDeliverProcessorService,
SystemWebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
ScheduledNoteDeleteProcessorService,
DeliverProcessorService,
InboxProcessorService,
AggregateRetentionProcessorService,

View File

@ -45,6 +45,7 @@ import { AggregateRetentionProcessorService } from './processors/AggregateRetent
import { QueueLoggerService } from './QueueLoggerService.js';
import { QUEUE, baseQueueOptions } from './const.js';
import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js';
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
function httpRelatedBackoff(attemptsMade: number) {
@ -84,6 +85,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)
@ -93,6 +95,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService,
private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@ -513,6 +516,12 @@ export class QueueProcessorService implements OnApplicationShutdown {
});
}
//#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
@ -527,6 +536,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(),
this.scheduledNoteDeleteQueueWorker.run(),
]);
}
@ -542,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(),
this.scheduledNoteDeleteQueueWorker.close(),
]);
}

View File

@ -11,6 +11,7 @@ export const QUEUE = {
INBOX: 'inbox',
SYSTEM: 'system',
ENDED_POLL_NOTIFICATION: 'endedPollNotification',
SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete',
DB: 'db',
RELATIONSHIP: 'relationship',
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'];
};
export type ScheduledNoteDeleteJobData = {
noteId: MiNote['id'];
}
export type SystemWebhookDeliverJobData = {
type: string;
content: unknown;

View File

@ -183,6 +183,7 @@ export const paramDef = {
publicReactions: { type: 'boolean' },
carefulBot: { type: 'boolean' },
autoAcceptFollowed: { type: 'boolean' },
autoRejectFollowRequest: { type: 'boolean' },
noCrawle: { type: 'boolean' },
preventAiLearning: { 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.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
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.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat;

View File

@ -104,6 +104,12 @@ export const meta = {
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: {
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
@ -197,6 +203,14 @@ export const paramDef = {
},
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
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;
if (ps.channelId != null) {
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,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : null,
});
return {

View File

@ -4,13 +4,12 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets, type FindOptionsWhere } from 'typeorm';
import type { NoteReactionsRepository } from '@/models/_.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['notes', 'reactions'],
@ -59,6 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteReactionEntityService: NoteReactionEntityService,
private queryService: QueryService,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
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.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) {
// ローカルリアクションはホスト名が . とされているが
// DB 上ではそうではないので、必要に応じて変換

View File

@ -30,7 +30,7 @@ import type {
DeliverQueue,
EndedPollNotificationQueue,
InboxQueue,
ObjectStorageQueue,
ObjectStorageQueue, ScheduledNoteDeleteQueue,
SystemQueue,
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
@ -116,6 +116,7 @@ export class ClientServerService {
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@ -248,6 +249,7 @@ export class ClientServerService {
queues: [
this.systemQueue,
this.endedPollNotificationQueue,
this.scheduledNoteDeleteQueue,
this.deliverQueue,
this.inboxQueue,
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)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
<span v-if="appearNote.deleteAt"><i class="ti ti-bomb"></i>{{ i18n.ts.scheduledNoteDelete }}: <MkTime :time="appearNote.deleteAt" mode="detail" colored/></span>
</div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/>
<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.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.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>
</header>
</template>
@ -43,6 +44,7 @@ import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js';
import { popupMenu } from '@/os.js';
import { dateTimeFormat } from '@/scripts/intl-const.js';
const props = defineProps<{
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">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<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"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
@ -81,6 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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.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.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>
@ -110,6 +112,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
import MkDeleteScheduleEditor, { type DeleteScheduleEditorModelValue } from '@/components/MkDeleteScheduleEditor.vue';
import { host, url } from '@/config.js';
import { erase, unique } from '@/scripts/array.js';
import { extractMentions } from '@/scripts/extract-mentions.js';
@ -182,6 +185,7 @@ const posted = ref(false);
const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []);
const poll = ref<PollEditorModelValue | null>(null);
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null);
const useCw = ref<boolean>(!!props.initialCw);
const showPreview = ref(defaultStore.state.showPreview);
watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
@ -366,6 +370,7 @@ function watchForDraft() {
watch(useCw, () => saveDraft());
watch(cw, () => saveDraft());
watch(poll, () => saveDraft());
watch(scheduledNoteDelete, () => saveDraft());
watch(files, () => saveDraft(), { deep: true });
watch(visibility, () => 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) {
insertTextAtCursor(textareaEl.value, ` #${tag} `);
}
@ -718,6 +734,7 @@ function saveDraft() {
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
quoteId: quoteId.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,
channelId: props.channel ? props.channel.id : undefined,
poll: poll.value,
scheduledDelete: scheduledNoteDelete.value,
cw: useCw.value ? cw.value ?? '' : null,
localOnly: localOnly.value,
visibility: visibility.value,
@ -1029,6 +1047,9 @@ onMounted(() => {
}
quoteId.value = draft.data.quoteId;
reactionAcceptance.value = draft.data.reactionAcceptance;
if (draft.data.scheduledNoteDelete) {
scheduledNoteDelete.value = draft.data.scheduledNoteDelete;
}
}
}
@ -1049,6 +1070,12 @@ onMounted(() => {
expiredAfter: null,
};
}
if (init.deleteAt) {
scheduledNoteDelete.value = {
deleteAt: init.deleteAt ? (new Date(init.deleteAt)).getTime() : null,
deleteAfter: null,
};
}
if (init.visibleUserIds) {
misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => {
users.forEach(u => pushVisibleUser(u));

View File

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

View File

@ -36,6 +36,8 @@ import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.j
import { customEmojisMap } from '@/custom-emojis.js';
import { getUnicodeEmoji } from '@/scripts/emojilist.js';
const reactionChecksMuting = computed(defaultStore.makeGetterSetter('reactionChecksMuting'));
const props = defineProps<{
reaction: string;
count: number;
@ -146,7 +148,9 @@ onMounted(() => {
if (!mock) {
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,
type: props.reaction,
limit: 10,
@ -154,12 +158,13 @@ if (!mock) {
});
const users = reactions.map(x => x.user);
const count = users.length;
const { dispose } = os.popup(XDetails, {
showing,
reaction: props.reaction,
users,
count: props.count,
count,
targetElement: buttonEl.value,
}, {
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="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</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>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -421,8 +426,6 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
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 uncollapseCW = computed(defaultStore.makeGetterSetter('uncollapseCW'));
@ -435,6 +438,8 @@ const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('u
const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia'));
const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
const searchEngine = computed(defaultStore.makeGetterSetter('searchEngine'));
const reactionChecksMuting = computed(defaultStore.makeGetterSetter('reactionChecksMuting'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);

View File

@ -7,6 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<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="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()">
{{ i18n.ts.makeReactionsPublic }}
@ -87,6 +91,7 @@ const $i = signinRequired();
const isLocked = ref($i.isLocked);
const autoAcceptFollowed = ref($i.autoAcceptFollowed);
const autoRejectFollowRequest = ref($i.autoRejectFollowRequest);
const noCrawle = ref($i.noCrawle);
const noindex = ref($i.noindex);
const isExplorable = ref($i.isExplorable);
@ -104,6 +109,7 @@ function save() {
misskeyApi('i/update', {
isLocked: !!isLocked.value,
autoAcceptFollowed: !!autoAcceptFollowed.value,
autoRejectFollowRequest: !!autoRejectFollowRequest.value,
noCrawle: !!noCrawle.value,
noindex: !!noindex.value,
isExplorable: !!isExplorable.value,

View File

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

View File

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