enhance(frontend): ミュート・ロール付与期間を任意の長さに設定できるように (#16766)

* enhance(frontend): ミュートの長さを自由に設定できるように

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

* Update Changelog
This commit is contained in:
かっこかり 2025-11-10 15:45:57 +09:00 committed by GitHub
parent 50bbc71098
commit 855a652439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 318 additions and 120 deletions

View File

@ -18,6 +18,8 @@
- Enhance: プッシュ通知を行うための権限確認をより確実に行うように
- Enhance: 投稿フォームのチュートリアルを追加
- Enhance: 「自動でもっと見る」をほとんどの箇所で利用可能に
- Enhance: ミュートの付与期間を自由に設定できるように
- Enhance: ロールの付与期間を自由に設定できるように
- Fix: 紙吹雪エフェクトがアニメーション設定を考慮せず常に表示される問題を修正
- Fix: ナビゲーションバーのリアルタイムモード切替ボタンの状態をよりわかりやすく表示するように
- Fix: ページのタイトルが長いとき、はみ出る問題を修正

4
locales/index.d.ts vendored
View File

@ -3846,6 +3846,10 @@ export interface Locale extends ILocale {
*
*/
"mutePeriod": string;
/**
*
*/
"mutePeriodDescription": string;
/**
*
*/

View File

@ -957,6 +957,7 @@ instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ"
instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ"
instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。"
mutePeriod: "ミュートする期限"
mutePeriodDescription: "期限はあくまで目安です。反映が数分遅れる場合があります。"
period: "期限"
indefinitely: "無期限"
tenMinutes: "10分"

View File

@ -15,17 +15,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div class="_gaps">
<MkSelect v-model="periodModel" :items="periodDef">
<template #label>{{ i18n.ts.mutePeriod }}</template>
</MkSelect>
<MkSelect v-model="muteTypeModel" :items="muteTypeDef">
<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 @click="ok">{{ i18n.ts.ok }}</MkButton>
<MkButton inline primary rounded :disabled="!canSave" @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</div>
</MkModal>
@ -34,6 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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,
@ -45,6 +56,8 @@ const periodItems = [{
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'custom', label: i18n.ts.custom,
}] as const satisfies MkSelectItem[];
const muteTypeItems = [{
@ -53,22 +66,29 @@ const muteTypeItems = [{
value: 'timelineOnly', label: i18n.ts.muteTypeTimeline,
}] as const satisfies MkSelectItem[];
export type MkMuteSettingDialogDoneEvent = { canceled: true } | { canceled: false, period: GetMkSelectValueTypesFromDef<typeof periodItems>, type: GetMkSelectValueTypesFromDef<typeof muteTypeItems> };
export type MkMuteSettingDialogDoneEvent = { canceled: true } | { canceled: false, expiresAt: number | null, type: GetMkSelectValueTypesFromDef<typeof muteTypeItems> };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
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 = shallowRef<InstanceType<typeof MkModal>>();
const modal = useTemplateRef('modal');
const {
def: periodDef,
@ -78,6 +98,15 @@ const {
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,
@ -91,7 +120,36 @@ 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
emit('done', { canceled, period: period!, type: type! });
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();
}

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,20 +18,9 @@ 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';
import type { MkMuteSettingDialogDoneEvent } from '@/components/MkMuteSettingDialog.vue';
function muteConfirm(): Promise<MkMuteSettingDialogDoneEvent> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkMuteSettingDialog.vue')), {}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null;
@ -46,19 +35,14 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
user.isMuted = false;
});
} else {
const res = await muteConfirm();
const res = await openMuteSettingDialog({
withMuteType: true,
});
if (res.canceled) return;
const expiresAt = res.period === 'indefinitely' ? null
: res.period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: res.period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: res.period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: res.period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
os.apiWithDialog('mute/create', {
userId: user.id,
expiresAt,
expiresAt: res.expiresAt,
mutingType: res.type,
}).then(() => {
user.isMuted = true;
@ -323,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();
},
});
});
}