Compare commits

...

18 Commits

Author SHA1 Message Date
kakkokari-gtyih 368c40c653 Merge remote-tracking branch 'msky/develop' into copilot/add-user-mute-settings 2025-12-16 22:09:06 +09:00
かっこかり 68d8445fe7
Update CHANGELOG.md 2025-12-02 16:52:45 +09:00
kakkokari-gtyih 6d80f9ef2f Merge branch 'develop' into copilot/add-user-mute-settings 2025-12-02 14:19:48 +09:00
Sayamame-beans 4a16d7f354
Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-27 22:58:26 +09:00
kakkokari-gtyih cdaf57d4c2 Update Changelog 2025-11-24 00:50:07 +09:00
kakkokari-gtyih 9fe09c041f Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-24 00:49:31 +09:00
かっこかり 6ef38a4cf9
Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-10 18:38:04 +09:00
かっこかり 855a652439
enhance(frontend): ミュート・ロール付与期間を任意の長さに設定できるように (#16766)
* enhance(frontend): ミュートの長さを自由に設定できるように

* enhance(frontend): ロールアサインの長さを自由に設定できるように

* Update Changelog
2025-11-10 15:45:57 +09:00
copilot-swe-agent[bot] 50bbc71098 Apply code review suggestions - use explicit null checks
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-11-09 15:03:53 +00:00
かっこかり 4762dfd5c7
Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-09 13:05:32 +09:00
kakkokari-gtyih 3e6150174a Update Changelog 2025-11-08 15:19:56 +09:00
kakkokari-gtyih 6e2360e9b1 fix 2025-11-08 13:39:12 +09:00
かっこかり 7d1da29c48
enhance(frontend): implement mute setting dialog for enhanced mute (#16763)
* enhance(frontend): implement mute setting dialog for enhanced mute

* remove unnecessary defs

* fix description

* fix lint
2025-11-08 13:11:52 +09:00
kakkokari-gtyih 2b75b575ac build misskey-js with types 2025-11-08 11:56:42 +09:00
copilot-swe-agent[bot] 8d1b2f2089 Fix code review issues - update comments to English
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:11:46 +00:00
copilot-swe-agent[bot] 18ba94c909 Add test for timeline-only muting feature
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:09:26 +00:00
copilot-swe-agent[bot] 685847e1b6 Add mutingType field to Muting model and update related code
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:04:42 +00:00
copilot-swe-agent[bot] 1810d6e837 Initial plan 2025-11-07 11:52:31 +00:00
28 changed files with 699 additions and 136 deletions

View File

@ -9,6 +9,8 @@ v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`
- 依存関係の更新
### Client
- Enhance: ミュートの付与期間を自由に設定できるように
- Enhance: ロールの付与期間を自由に設定できるように
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正

View File

@ -146,6 +146,9 @@ markAsSensitive: "センシティブとして設定"
unmarkAsSensitive: "センシティブを解除する"
enterFileName: "ファイル名を入力"
mute: "ミュート"
muteType: "ミュートする範囲"
muteTypeDescription: "ミュートを適用する範囲を設定できます。「タイムラインのみ」に設定すると、タイムラインや検索結果上からは見えなくなりますが、通知は受け取ります。"
muteTypeTimeline: "タイムラインのみ"
unmute: "ミュート解除"
renoteMute: "リノートをミュート"
renoteUnmute: "リノートのミュートを解除"
@ -956,6 +959,7 @@ instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ"
instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ"
instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。"
mutePeriod: "ミュートする期限"
mutePeriodDescription: "期限はあくまで目安です。反映が数分遅れる場合があります。"
period: "期限"
indefinitely: "無期限"
tenMinutes: "10分"
@ -1406,6 +1410,7 @@ youAreAdmin: "あなたは管理者です"
frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
muteConfirm: "ミュートしますか?"
_imageEditing:
_vars:

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddMutingType1762516776421 {
name = 'AddMutingType1762516776421'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "muting" ADD "mutingType" varchar(128) NOT NULL DEFAULT 'all'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "muting" DROP COLUMN "mutingType"`);
}
}

View File

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiMuting } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@ -21,7 +21,7 @@ export class CacheService implements OnApplicationShutdown {
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userMutingsCache: RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>;
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
@ -69,12 +69,12 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮
});
this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
this.userMutingsCache = new RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>(this.redisClient, 'userMutings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId', 'mutingType'] }).then(xs => new Map(xs.map(x => [x.muteeId, { mutingType: x.mutingType }]))),
toRedisConverter: (value) => JSON.stringify(Array.from(value.entries())),
fromRedisConverter: (value) => new Map(JSON.parse(value)),
});
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {

View File

@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService {
if (ps.me) {
const me = ps.me;
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
@ -124,6 +124,8 @@ export class FanoutTimelineEndpointService {
this.channelMutingService.mutingChannelsCache.fetch(me.id),
]);
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const parentFilter = filter;
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;

View File

@ -108,7 +108,8 @@ export class NotificationService implements OnApplicationShutdown {
}
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
if (mutings.has(notifierId)) {
const muting = mutings.get(notifierId);
if (muting != null && muting.mutingType === 'all') {
return null;
}

View File

@ -286,14 +286,16 @@ export class SearchService {
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
] = me
? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
])
: [new Set<string>(), new Set<string>()];
: [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.innerJoinAndSelect('note.user', 'user')

View File

@ -24,12 +24,13 @@ export class UserMutingService {
}
@bindThis
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise<void> {
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, mutingType: 'all' | 'timelineOnly' = 'all'): Promise<void> {
await this.mutingsRepository.insert({
id: this.idService.gen(),
expiresAt: expiresAt ?? null,
muterId: user.id,
muteeId: target.id,
mutingType: mutingType,
});
this.cacheService.userMutingsCache.refresh(user.id);

View File

@ -40,6 +40,7 @@ export class MutingEntityService {
id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(),
expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null,
mutingType: muting.mutingType,
muteeId: muting.muteeId,
mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, {
schema: 'UserDetailedNotMe',

View File

@ -289,12 +289,13 @@ export class NotificationEntityService implements OnModuleInit {
*/
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>,
userIdsWhoMeMutingMap: Map<MiUser['id'], { mutingType: 'all' | 'timelineOnly' }>,
userMutedInstances: Set<string>,
notifiers: MiUser[],
): boolean {
if (!('notifierId' in notification)) return true;
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
const muting = userIdsWhoMeMutingMap.get(notification.notifierId);
if (muting != null && muting.mutingType === 'all') return false;
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
@ -324,7 +325,7 @@ export class NotificationEntityService implements OnModuleInit {
meId: MiUser['id'],
): Promise<T[]> {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userMutedInstances,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId),
@ -337,7 +338,7 @@ export class NotificationEntityService implements OnModuleInit {
}) : [];
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => {
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
const isValid = this.#validateNotifier(notification, userIdsWhoMeMutingMap, userMutedInstances, notifiers);
return isValid ? notification : null;
}))) as [T | null] ).filter(x => x != null);

View File

@ -19,6 +19,12 @@ export class MiMuting {
})
public expiresAt: Date | null;
@Column('varchar', {
length: 128,
default: 'all',
})
public mutingType: 'all' | 'timelineOnly';
@Index()
@Column({
...id(),

View File

@ -22,6 +22,11 @@ export const packedMutingSchema = {
optional: false, nullable: true,
format: 'date-time',
},
mutingType: {
type: 'string',
optional: false, nullable: false,
enum: ['all', 'timelineOnly'],
},
muteeId: {
type: 'string',
optional: false, nullable: false,

View File

@ -55,6 +55,12 @@ export const paramDef = {
nullable: true,
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
},
mutingType: {
type: 'string',
enum: ['all', 'timelineOnly'],
default: 'all',
description: 'Type of muting. `all` mutes everything including notifications. `timelineOnly` mutes only timeline and search, but allows notifications.',
},
},
required: ['userId'],
} as const;
@ -98,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return;
}
await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null);
await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null, ps.mutingType ?? 'all');
});
}
}

View File

@ -80,12 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })

View File

@ -73,10 +73,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>()];
// Convert to Set for backward compatibility with isUserRelated
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })

View File

@ -94,7 +94,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>();
const userIdsWhoMeMutingMap = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Map<string, { mutingType: 'all' | 'timelineOnly' }>();
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)

View File

@ -38,6 +38,7 @@ export default class Connection {
public followingChannels: Set<string> = new Set();
public mutingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoMeMutingMap: Map<string, { mutingType: 'all' | 'timelineOnly' }> = new Map();
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
@ -64,7 +65,7 @@ export default class Connection {
following,
followingChannels,
mutingChannels,
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
userIdsWhoMeMutingRenotes,
] = await Promise.all([
@ -80,7 +81,8 @@ export default class Connection {
this.following = following;
this.followingChannels = followingChannels;
this.mutingChannels = mutingChannels;
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
this.userIdsWhoMeMutingMap = userIdsWhoMeMutingMap;
this.userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances);

View File

@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { api, post, react, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Timeline-only Mute', () => {
// alice timeline-only mutes carol
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
let carol: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
// Timeline-only mute: alice ==> carol
await api('mute/create', {
userId: carol.id,
mutingType: 'timelineOnly',
}, alice);
}, 1000 * 60 * 2);
test('タイムラインオンリーミュート作成', async () => {
const res = await api('mute/create', {
userId: bob.id,
mutingType: 'timelineOnly',
}, alice);
assert.strictEqual(res.status, 204);
// Clean up side effects so tests can run independently
await api('mute/delete', {
userId: bob.id,
}, alice);
});
describe('Timeline', () => {
test('タイムラインにtimelineOnlyミュートしているユーザーの投稿が含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
});
});
describe('Notification', () => {
test('timelineOnlyミュートしているユーザーからの通知は届く(リアクション)', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからのリプライ通知は届く', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so reply notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからのメンション通知は届く', async () => {
await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi' });
await post(carol, { text: '@alice hi' });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so mention notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからの引用リート通知は届く', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: 'hi', renoteId: aliceNote.id });
await post(carol, { text: 'hi', renoteId: aliceNote.id });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so quote notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてくる', async () => {
// Reset state
await api('notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
// carol is timelineOnly muted, so notification stream should fire
assert.strictEqual(fired, true);
});
});
});

View File

@ -0,0 +1,217 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root" class="_gaps_m">
<div class="_gaps_s">
<div :class="$style.header">
<div :class="$style.icon">
<i class="ti ti-alert-triangle"></i>
</div>
<div :class="$style.title">{{ i18n.ts.muteConfirm }}</div>
</div>
</div>
<div class="_gaps">
<FormSlot>
<div class="_gaps_s">
<MkSelect v-model="periodModel" :items="periodDef">
<template #label>{{ i18n.ts.mutePeriod }}</template>
</MkSelect>
<MkInput
v-if="periodModel === 'custom'"
v-model="manualExpiresAt"
type="datetime-local"
></MkInput>
</div>
<template #caption>{{ i18n.ts.mutePeriodDescription }}</template>
</FormSlot>
<MkSelect v-if="withMuteType" v-model="muteTypeModel" :items="muteTypeDef">
<template #label>{{ i18n.ts.muteType }}</template>
<template #caption>{{ i18n.ts.muteTypeDescription }}</template>
</MkSelect>
</div>
<div :class="$style.buttons">
<MkButton inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded :disabled="!canSave" @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts">
import { i18n } from '@/i18n.js';
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
import MkInput from './MkInput.vue';
const periodItems = [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'custom', label: i18n.ts.custom,
}] as const satisfies MkSelectItem[];
const muteTypeItems = [{
value: 'all', label: i18n.ts.all,
}, {
value: 'timelineOnly', label: i18n.ts.muteTypeTimeline,
}] as const satisfies MkSelectItem[];
export type MkMuteSettingDialogDoneEvent = { canceled: true } | { canceled: false, expiresAt: number | null, type: GetMkSelectValueTypesFromDef<typeof muteTypeItems> };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, useTemplateRef, ref, computed } from 'vue';
import FormSlot from '@/components/form/slot.vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
withDefaults(defineProps<{
withMuteType?: boolean;
}>(), {
withMuteType: false,
});
const emit = defineEmits<{
(ev: 'done', v: MkMuteSettingDialogDoneEvent): void;
(ev: 'closed'): void;
}>();
const modal = useTemplateRef('modal');
const {
def: periodDef,
model: periodModel,
} = useMkSelect({
items: periodItems,
initialValue: 'indefinitely',
});
const now = Date.now();
const manualExpiresAt = ref<string | null>(null);
const canSave = computed(() => {
if (periodModel.value === 'custom') {
return manualExpiresAt.value != null && new Date(manualExpiresAt.value).getTime() > now;
}
return true;
});
const {
def: muteTypeDef,
model: muteTypeModel,
} = useMkSelect({
items: muteTypeItems,
initialValue: 'all',
});
// overload function 使 lint
function done(canceled: true): void;
function done(canceled: false, period: typeof periodModel.value, type: typeof muteTypeModel.value): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, period?: typeof periodModel.value, type?: typeof muteTypeModel.value) { // eslint-disable-line no-redeclare
const expiresAt = (() => {
if (canceled) return null;
if (period === 'custom' && manualExpiresAt.value != null) {
return new Date(manualExpiresAt.value!).getTime();
}
const now = Date.now();
switch (period) {
case 'indefinitely':
return null;
case 'tenMinutes':
return now + 10 * 60 * 1000;
case 'oneHour':
return now + 60 * 60 * 1000;
case 'oneDay':
return now + 24 * 60 * 60 * 1000;
case 'oneWeek':
return now + 7 * 24 * 60 * 60 * 1000;
default:
return null;
}
})();
if (canceled) {
emit('done', { canceled: true });
} else {
emit('done', { canceled: false, expiresAt, type: type! });
}
modal.value?.close();
}
async function ok() {
done(false, periodModel.value, muteTypeModel.value);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
window.document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
width: 100%;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: 16px;
}
.header {
display: flex;
align-items: center;
gap: 0.75em;
}
.icon {
font-size: 18px;
color: var(--MI_THEME-warn);
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: right;
}
</style>

View File

@ -0,0 +1,187 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root">
<div v-if="title" class="_selectable" :class="$style.header">
<Mfm :text="title"/>
</div>
<div v-if="text" :class="$style.text" class="_selectable">
<Mfm :text="text"/>
</div>
<div class="_gaps_s">
<MkSelect v-model="periodModel" :items="periodDef"></MkSelect>
<MkInput
v-if="periodModel === 'custom'"
v-model="manualExpiresAt"
type="datetime-local"
></MkInput>
</div>
<div :class="$style.buttons">
<MkButton inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded :disabled="!canSave" @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts">
import { i18n } from '@/i18n.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import MkInput from './MkInput.vue';
const periodItems = [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'custom', label: i18n.ts.custom,
}] as const satisfies MkSelectItem[];
export type MkPeriodDialogDoneEvent = { canceled: true } | { canceled: false, expiresAt: number | null };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, useTemplateRef, ref, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
defineProps<{
title?: string;
text?: string;
}>();
const emit = defineEmits<{
(ev: 'done', v: MkPeriodDialogDoneEvent): void;
(ev: 'closed'): void;
}>();
const modal = useTemplateRef('modal');
const {
def: periodDef,
model: periodModel,
} = useMkSelect({
items: periodItems,
initialValue: 'indefinitely',
});
const now = Date.now();
const manualExpiresAt = ref<string | null>(null);
const canSave = computed(() => {
if (periodModel.value === 'custom') {
return manualExpiresAt.value != null && new Date(manualExpiresAt.value).getTime() > now;
}
return true;
});
// overload function 使 lint
function done(canceled: true): void;
function done(canceled: false, period: typeof periodModel.value): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, period?: typeof periodModel.value) { // eslint-disable-line no-redeclare
const expiresAt = (() => {
if (canceled) return null;
if (period === 'custom' && manualExpiresAt.value != null) {
return new Date(manualExpiresAt.value!).getTime();
}
const now = Date.now();
switch (period) {
case 'indefinitely':
return null;
case 'tenMinutes':
return now + 10 * 60 * 1000;
case 'oneHour':
return now + 60 * 60 * 1000;
case 'oneDay':
return now + 24 * 60 * 60 * 1000;
case 'oneWeek':
return now + 7 * 24 * 60 * 60 * 1000;
default:
return null;
}
})();
if (canceled) {
emit('done', { canceled: true });
} else {
emit('done', { canceled: false, expiresAt });
}
modal.value?.close();
}
async function ok() {
done(false, periodModel.value);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
window.document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: 16px;
}
.header {
margin: 0 0 8px 0;
text-align: center;
font-weight: bold;
font-size: 1.1em;
& + .text {
margin-top: 8px;
}
}
.text {
margin: 16px 0 0 0;
}
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

View File

@ -15,6 +15,7 @@ import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js';
import type { UploaderFeatures } from '@/composables/use-uploader.js';
import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
import type { MkPeriodDialogDoneEvent } from '@/components/MkPeriodDialog.vue';
import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -530,6 +531,19 @@ export function select<C extends OptionValue, D extends C | null = null>(props:
});
}
export function selectPeriod(options: { title?: string } = {}): Promise<MkPeriodDialogDoneEvent> {
return new Promise(async (resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkPeriodDialog.vue')), {
title: options.title,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
export function success(): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);

View File

@ -447,31 +447,13 @@ async function assignRole() {
});
if (canceled || roleId == null) return;
const { canceled: canceled2, result: period } = await os.select({
title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${roles.find(r => r.id === roleId)!.name}`,
});
if (canceled2) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
if (res.canceled) return;
await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt });
await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt: res.expiresAt });
refreshUser();
}

View File

@ -112,31 +112,12 @@ async function del() {
async function assign() {
const user = await os.selectUser({ includeSelf: true });
const { canceled: canceled2, result: period } = await os.select({
title: i18n.ts.period + ': ' + role.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${role.name}`,
});
if (canceled2) return;
if (res.canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id, expiresAt });
await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id, expiresAt: res.expiresAt });
//role.users.push(user);
}

View File

@ -81,6 +81,7 @@ import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { openMuteSettingDialog } from '@/utility/mute-confirm.js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
@ -195,33 +196,14 @@ async function mute() {
if (!channel.value) return;
const _channel = channel.value;
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}],
default: 'indefinitely',
const res = await openMuteSettingDialog({
withMuteType: false,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
if (res.canceled) return;
os.apiWithDialog('channels/mute/create', {
channelId: _channel.id,
expiresAt,
expiresAt: res.expiresAt,
}).then(() => {
_channel.isMuting = true;
});

View File

@ -18,6 +18,7 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-pe
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router.js';
import { genEmbedCode } from '@/utility/get-embed-code.js';
import { openMuteSettingDialog } from '@/utility/mute-confirm.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
@ -34,33 +35,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
user.isMuted = false;
});
} else {
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}],
default: 'indefinitely',
const res = await openMuteSettingDialog({
withMuteType: true,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
if (res.canceled) return;
os.apiWithDialog('mute/create', {
userId: user.id,
expiresAt,
expiresAt: res.expiresAt,
mutingType: res.type,
}).then(() => {
user.isMuted = true;
});
@ -324,31 +307,13 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
return roles.filter(r => r.target === 'manual').map(r => ({
text: r.name,
action: async () => {
const { canceled, result: period } = await os.select({
title: i18n.ts.period + ': ' + r.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${r.name}`,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
if (res.canceled) return;
os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt: res.expiresAt });
},
}));
},

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as os from '@/os.js';
import type { MkMuteSettingDialogDoneEvent } from '@/components/MkMuteSettingDialog.vue';
export function openMuteSettingDialog(opts?: { withMuteType?: boolean }): Promise<MkMuteSettingDialogDoneEvent> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkMuteSettingDialog.vue')), opts ?? {}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => {
dispose();
},
});
});
}

View File

@ -596,6 +596,18 @@ export interface Locale extends ILocale {
*
*/
"mute": string;
/**
*
*/
"muteType": string;
/**
*
*/
"muteTypeDescription": string;
/**
*
*/
"muteTypeTimeline": string;
/**
*
*/
@ -3836,6 +3848,10 @@ export interface Locale extends ILocale {
*
*/
"mutePeriod": string;
/**
*
*/
"mutePeriodDescription": string;
/**
*
*/
@ -5639,6 +5655,10 @@ export interface Locale extends ILocale {
*
*/
"zeroPadding": string;
/**
*
*/
"muteConfirm": string;
"_imageEditing": {
"_vars": {
/**

View File

@ -4824,6 +4824,8 @@ export type components = {
createdAt: string;
/** Format: date-time */
expiresAt: string | null;
/** @enum {string} */
mutingType: 'all' | 'timelineOnly';
/** Format: id */
muteeId: string;
mutee: components['schemas']['UserDetailedNotMe'];
@ -28619,6 +28621,12 @@ export interface operations {
userId: string;
/** @description A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute. */
expiresAt?: number | null;
/**
* @description Type of muting. `all` mutes everything including notifications. `timelineOnly` mutes only timeline and search, but allows notifications.
* @default all
* @enum {string}
*/
mutingType?: 'all' | 'timelineOnly';
};
};
};