Compare commits
18 Commits
develop
...
copilot/ad
| Author | SHA1 | Date |
|---|---|---|
|
|
368c40c653 | |
|
|
68d8445fe7 | |
|
|
6d80f9ef2f | |
|
|
4a16d7f354 | |
|
|
cdaf57d4c2 | |
|
|
9fe09c041f | |
|
|
6ef38a4cf9 | |
|
|
855a652439 | |
|
|
50bbc71098 | |
|
|
4762dfd5c7 | |
|
|
3e6150174a | |
|
|
6e2360e9b1 | |
|
|
7d1da29c48 | |
|
|
2b75b575ac | |
|
|
8d1b2f2089 | |
|
|
18ba94c909 | |
|
|
685847e1b6 | |
|
|
1810d6e837 |
|
|
@ -9,6 +9,8 @@ v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`
|
|||
- 依存関係の更新
|
||||
|
||||
### Client
|
||||
- Enhance: ミュートの付与期間を自由に設定できるように
|
||||
- Enhance: ロールの付与期間を自由に設定できるように
|
||||
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export class MiMuting {
|
|||
})
|
||||
public expiresAt: Date | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
default: 'all',
|
||||
})
|
||||
public mutingType: 'all' | 'timelineOnly';
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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": {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue