diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-10-08 13:18:08 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-08 13:18:08 +0000 |
| commit | 56cc89b521e8ca0d302230d123c3924e4461556d (patch) | |
| tree | 242411d50ffd1ed7096f95ecdafe91b482628a46 /packages/frontend/src | |
| parent | Merge pull request #16521 from misskey-dev/develop (diff) | |
| parent | Release: 2025.10.0 (diff) | |
| download | misskey-56cc89b521e8ca0d302230d123c3924e4461556d.tar.gz misskey-56cc89b521e8ca0d302230d123c3924e4461556d.tar.bz2 misskey-56cc89b521e8ca0d302230d123c3924e4461556d.zip | |
Merge pull request #16591 from misskey-dev/develop
Release: 2025.10.0
Diffstat (limited to 'packages/frontend/src')
161 files changed, 4396 insertions, 1518 deletions
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 574012ff78..4becf32ab5 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -151,7 +151,21 @@ export async function common(createVue: () => Promise<App<Element>>) { } //#endregion + //#region Sync dark mode + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', isDeviceDarkmode()); + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', mql.matches); + } + }); + //#endregion + // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) + // NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため) + // see: https://github.com/misskey-dev/misskey/issues/16562 watch(store.r.darkMode, (darkMode) => { const theme = (() => { if (darkMode) { @@ -183,18 +197,6 @@ export async function common(createVue: () => Promise<App<Element>>) { }); } - //#region Sync dark mode - if (prefer.s.syncDeviceDarkMode) { - store.set('darkMode', isDeviceDarkmode()); - } - - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { - if (prefer.s.syncDeviceDarkMode) { - store.set('darkMode', mql.matches); - } - }); - //#endregion - if (!isSafeMode) { if (prefer.s.darkTheme && store.s.darkMode) { if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 19a21f6e24..0e1018dcbf 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import isChromatic from 'chromatic/isChromatic'; +import { initShaderProgram } from '@/utility/webgl.js'; const canvasEl = useTemplateRef('canvasEl'); @@ -21,47 +22,6 @@ const props = withDefaults(defineProps<{ focus: 1.0, }); -function loadShader(gl: WebGLRenderingContext, type: number, source: string) { - const shader = gl.createShader(type); - if (shader == null) return null; - - gl.shaderSource(shader, source); - gl.compileShader(shader); - - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - alert( - `falied to compile shader: ${gl.getShaderInfoLog(shader)}`, - ); - gl.deleteShader(shader); - return null; - } - - return shader; -} - -function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) { - const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); - const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); - - const shaderProgram = gl.createProgram(); - if (vertexShader == null || fragmentShader == null) return null; - - gl.attachShader(shaderProgram, vertexShader); - gl.attachShader(shaderProgram, fragmentShader); - gl.linkProgram(shaderProgram); - - if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { - alert( - `failed to init shader: ${gl.getProgramInfoLog( - shaderProgram, - )}`, - ); - return null; - } - - return shaderProgram; -} - let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null; onMounted(() => { @@ -71,7 +31,7 @@ onMounted(() => { canvas.width = width; canvas.height = height; - const maybeGl = canvas.getContext('webgl', { premultipliedAlpha: true }); + const maybeGl = canvas.getContext('webgl2', { premultipliedAlpha: true }); if (maybeGl == null) return; const gl = maybeGl; @@ -82,18 +42,16 @@ onMounted(() => { const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - const shaderProgram = initShaderProgram(gl, ` - attribute vec2 vertex; - + const shaderProgram = initShaderProgram(gl, `#version 300 es + in vec2 position; uniform vec2 u_scale; - - varying vec2 v_pos; + out vec2 in_uv; void main() { - gl_Position = vec4(vertex, 0.0, 1.0); - v_pos = vertex / u_scale; + gl_Position = vec4(position, 0.0, 1.0); + in_uv = position / u_scale; } - `, ` + `, `#version 300 es precision mediump float; vec3 mod289(vec3 x) { @@ -143,6 +101,7 @@ onMounted(() => { return 130.0 * dot(m, g); } + in vec2 in_uv; uniform float u_time; uniform vec2 u_resolution; uniform float u_spread; @@ -150,8 +109,7 @@ onMounted(() => { uniform float u_warp; uniform float u_focus; uniform float u_itensity; - - varying vec2 v_pos; + out vec4 out_color; float circle( in vec2 _pos, in vec2 _origin, in float _radius ) { float SPREAD = 0.7 * u_spread; @@ -182,13 +140,13 @@ onMounted(() => { float ratio = u_resolution.x / u_resolution.y; - vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5; + vec2 uv = vec2( in_uv.x, in_uv.y / ratio ) * 0.5 + 0.5; vec3 color = vec3( 0.0 ); - float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5; - float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5; - float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5; + float greenMix = snoise( in_uv * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5; + float purpleMix = snoise( in_uv * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5; + float orangeMix = snoise( in_uv * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5; float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 ); float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 ); @@ -198,10 +156,10 @@ onMounted(() => { color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix ); color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix ); - color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 ); + color *= u_itensity + 1.0 * pow( snoise( vec2( in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 ); vec3 inverted = vec3( 1.0 ) - color; - gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) ); + out_color = vec4(color, max(max(color.x, color.y), color.z)); } `); if (shaderProgram == null) return; @@ -223,7 +181,7 @@ onMounted(() => { gl.uniform1f(u_itensity, 0.5); gl.uniform2fv(u_scale, [props.scale, props.scale]); - const vertex = gl.getAttribLocation(shaderProgram, 'vertex'); + const vertex = gl.getAttribLocation(shaderProgram, 'position'); gl.enableVertexAttribArray(vertex); gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0); diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e2febf7225..a41fdbc45d 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> </MkInput> - <MkSelect v-model="src"> + <MkSelect v-model="src" :items="antennaSourcesSelectDef"> <template #label>{{ i18n.ts.antennaSource }}</template> - <option value="all">{{ i18n.ts._antennaSources.all }}</option> - <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> - <option value="users">{{ i18n.ts._antennaSources.users }}</option> - <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> - <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option> </MkSelect> - <MkSelect v-if="src === 'list'" v-model="userListId"> + <MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> <template #label>{{ i18n.ts.users }}</template> @@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; @@ -64,6 +58,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -99,9 +94,35 @@ const emit = defineEmits<{ (ev: 'deleted'): void, }>(); +const { + model: src, + def: antennaSourcesSelectDef, +} = useMkSelect({ + items: [ + { value: 'all', label: i18n.ts._antennaSources.all }, + //{ value: 'home', label: i18n.ts._antennaSources.homeTimeline }, + { value: 'users', label: i18n.ts._antennaSources.users }, + //{ value: 'list', label: i18n.ts._antennaSources.userList }, + { value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist }, + ], + initialValue: initialAntenna.src, +}); + +const { + model: userListId, + def: userListsSelectDef, +} = useMkSelect({ + items: computed(() => { + if (userLists.value == null) return []; + return userLists.value.map(list => ({ + value: list.id, + label: list.name, + })); + }), + initialValue: initialAntenna.userListId, +}); + const name = ref<string>(initialAntenna.name); -const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src); -const userListId = ref<string | null>(initialAntenna.userListId); const users = ref<string>(initialAntenna.users.join('\n')); const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 20a953c72c..a3b6112629 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> - <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> </MkSelect> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> <div v-else-if="c.type === 'postForm'" :class="$style.postForm"> @@ -74,6 +73,7 @@ import MkSelect from '@/components/MkSelect.vue'; import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ component: AsUiComponent; @@ -130,7 +130,19 @@ function onSwitchUpdate(v: boolean) { } } -const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null); +const { + model: valueForSelect, + def: selectDef, +} = useMkSelect({ + items: computed(() => { + if (c.type !== 'select') return []; + return (c.items ?? []).map(item => ({ + value: item.value, + label: item.text, + })); + }), + initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null, +}); function onSelectUpdate(v) { valueForSelect.value = v; diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 1c44ed60d8..4bd6c62a5f 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -29,6 +29,6 @@ const users = ref<Misskey.entities.UserLite[]>([]); onMounted(async () => { users.value = await misskeyApi('users/show', { userIds: props.userIds, - }) as unknown as Misskey.entities.UserLite[]; + }); }); </script> diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 8b39468d4c..f669e4b87a 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- eslint-disable vue/no-v-html --> <template> -<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div> +<div + :class="[$style.codeBlockRoot, { + [$style.codeEditor]: codeEditor, + [$style.outerStyle]: !codeEditor && withOuterStyle, + [$style.dark]: darkMode, + [$style.light]: !darkMode, + }]" v-html="html"></div> </template> <script lang="ts" setup> @@ -15,11 +21,15 @@ import type { BundledLanguage } from 'shiki/langs'; import { getHighlighter, getTheme } from '@/utility/code-highlighter.js'; import { store } from '@/store.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ code: string; lang?: string; codeEditor?: boolean; -}>(); + withOuterStyle?: boolean; +}>(), { + codeEditor: false, + withOuterStyle: true, +}); const highlighter = await getHighlighter(); const darkMode = store.r.darkMode; @@ -73,17 +83,13 @@ watch(() => props.lang, (to) => { <style module lang="scss"> .codeBlockRoot :global(.shiki) { - padding: 1em; - margin: 0; overflow: auto; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; color: var(--shiki-fallback); - background-color: var(--shiki-fallback-bg); & span { color: var(--shiki-fallback); - background-color: var(--shiki-fallback-bg); } & pre, @@ -92,26 +98,40 @@ watch(() => props.lang, (to) => { } } +.outerStyle.codeBlockRoot :global(.shiki) { + padding: 1em; + margin: 0; + border-radius: 8px; + border: 1px solid var(--MI_THEME-divider); + background-color: var(--shiki-fallback-bg); +} + .light.codeBlockRoot :global(.shiki) { color: var(--shiki-light); - background-color: var(--shiki-light-bg); & span { color: var(--shiki-light); - background-color: var(--shiki-light-bg); } } +.light.outerStyle.codeBlockRoot :global(.shiki), +.light.codeEditor.codeBlockRoot :global(.shiki) { + background-color: var(--shiki-light-bg); +} + .dark.codeBlockRoot :global(.shiki) { color: var(--shiki-dark); - background-color: var(--shiki-dark-bg); & span { color: var(--shiki-dark); - background-color: var(--shiki-dark-bg); } } +.dark.outerStyle.codeBlockRoot :global(.shiki), +.dark.codeEditor.codeBlockRoot :global(.shiki) { + background-color: var(--shiki-dark-bg); +} + .codeBlockRoot.codeEditor { min-width: 100%; height: 100%; diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index f41cb0d00b..f43035f0e8 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -5,15 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.codeBlockRoot"> - <button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy"> + <button v-if="copyButton" :class="[$style.codeBlockCopyButton, { [$style.withOuterStyle]: withOuterStyle }]" class="_button" @click="copy"> <i class="ti ti-copy"></i> </button> <Suspense> <template #fallback> - <MkLoading/> + <pre + class="_selectable" + :class="[$style.codeBlockFallbackRoot, { + [$style.outerStyle]: withOuterStyle, + }]" + ><code :class="$style.codeBlockFallbackCode">Loading...</code></pre> </template> - <XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/> - <pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <XCode + v-if="show && lang" + class="_selectable" + :code="code" + :lang="lang" + :withOuterStyle="withOuterStyle" + /> + <pre + v-else-if="show" + class="_selectable" + :class="[$style.codeBlockFallbackRoot, { + [$style.outerStyle]: withOuterStyle, + }]" + ><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> <div :class="$style.codePlaceholderContainer"> <div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div> @@ -26,8 +43,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; -import * as os from '@/os.js'; -import MkLoading from '@/components/global/MkLoading.vue'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { prefer } from '@/preferences.js'; @@ -36,10 +51,12 @@ const props = withDefaults(defineProps<{ code: string; forceShow?: boolean; copyButton?: boolean; + withOuterStyle?: boolean; lang?: string; }>(), { copyButton: true, forceShow: false, + withOuterStyle: true, }); const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code); @@ -58,10 +75,16 @@ function copy() { .codeBlockCopyButton { position: absolute; - top: 8px; - right: 8px; opacity: 0.5; + top: 0; + right: 0; + + &.withOuterStyle { + top: 8px; + right: 8px; + } + &:hover { opacity: 0.8; } @@ -70,11 +93,17 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - padding: 1em; - margin: 0; overflow: auto; } +.outerStyle.codeBlockFallbackRoot { + background: var(--MI_THEME-bg); + padding: 1em; + margin: .5em 0; + border-radius: 8px; + border: 1px solid var(--MI_THEME-divider); +} + .codeBlockFallbackCode { font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; } diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 3f7519a43f..705301a6a6 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> - <MkSelect v-if="select" v-model="selectedValue" autofocus> - <template v-if="select.items"> - <template v-for="item in select.items"> - <optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle"> - <option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option> - </optgroup> - <option v-else :value="item.value">{{ item.text }}</option> - </template> - </template> - </MkSelect> + <MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> @@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; type Input = { @@ -67,17 +60,9 @@ type Input = { maxLength?: number; }; -type SelectItem = { - value: any; - text: string; -}; - type Select = { - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - })[]; - default: string | null; + items: MkSelectItem[]; + default: OptionValue | null; }; type Result = string | number | true | null; @@ -115,7 +100,6 @@ const emit = defineEmits<{ const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); -const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { @@ -134,6 +118,14 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character return null; }); +const { + def: selectDef, + model: selectedValue, +} = useMkSelect({ + items: computed(() => props.select?.items ?? []), + initialValue: props.select?.default ?? null, +}); + // overload function を使いたいので lint エラーを無視する function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 19c98c3738..7213e3496d 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -35,18 +35,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="select === 'folder'"> <template v-if="folder == null"> <MkButton v-if="!isRootSelected" @click="isRootSelected = true"> - <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + <i class="ti ti-square"></i> {{ i18n.ts.selectFolder }} </MkButton> <MkButton v-else @click="isRootSelected = false"> - <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectFolder }} </MkButton> </template> <template v-else> <MkButton v-if="!selectedFolders.some(f => f.id === folder!.id)" @click="selectedFolders.push(folder)"> - <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + <i class="ti ti-square"></i> {{ i18n.ts.selectFolder }} </MkButton> <MkButton v-else @click="selectedFolders = selectedFolders.filter(f => f.id !== folder!.id)"> - <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectFolder }} </MkButton> </template> </div> @@ -112,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty"> - <div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div> + <div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div> <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong></div> <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> </div> diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 17823deb85..0cb8499699 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>px</template> <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> </MkInput> - <MkSelect v-model="colorMode"> + <MkSelect v-model="colorMode" :items="colorModeDef"> <template #label>{{ i18n.ts.theme }}</template> - <option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option> - <option value="light">{{ i18n.ts.light }}</option> - <option value="dark">{{ i18n.ts.dark }}</option> </MkSelect> <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> @@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; @@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro const header = ref(props.params?.header ?? true); const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); -const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); +const { + model: colorMode, + def: colorModeDef, +} = useMkSelect({ + items: [ + { value: 'auto', label: i18n.ts.syncDeviceDarkMode }, + { value: 'light', label: i18n.ts.light }, + { value: 'dark', label: i18n.ts.dark }, + ], + initialValue: props.params?.colorMode ?? 'auto', +}); + const rounded = ref(props.params?.rounded ?? true); const border = ref(props.params?.border ?? true); diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 6904c417ce..452546375c 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -326,7 +326,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { + if (keywords.every(keyword => index[emoji.char]?.some(k => k.includes(keyword)))) { matches.add(emoji); if (matches.size >= max) break; } @@ -343,7 +343,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (index[emoji.char].some(k => k.startsWith(newQ))) { + if (index[emoji.char]?.some(k => k.startsWith(newQ))) { matches.add(emoji); if (matches.size >= max) break; } @@ -360,7 +360,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (index[emoji.char].some(k => k.includes(newQ))) { + if (index[emoji.char]?.some(k => k.includes(newQ))) { matches.add(emoji); if (matches.size >= max) break; } @@ -530,6 +530,14 @@ defineExpose({ --eachSize: 50px; } + &.s4 { + --eachSize: 55px; + } + + &.s5 { + --eachSize: 60px; + } + &.w1 { width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); --columns: 1fr 1fr 1fr 1fr 1fr; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 9f5bc8da6c..94fdf6da36 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, ref, useTemplateRef } from 'vue'; +import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'; import { prefer } from '@/preferences.js'; import { getBgColor } from '@/utility/get-bg-color.js'; import { pageFolderTeleportCount, popup } from '@/os.js'; @@ -119,6 +119,11 @@ const props = withDefaults(defineProps<{ canPage: true, }); +const emit = defineEmits<{ + (ev: 'opened'): void; + (ev: 'closed'): void; +}>(); + const rootEl = useTemplateRef('rootEl'); const asPage = props.canPage && deviceKind === 'smartphone' && prefer.s['experimental.enableFolderPageView']; const bgSame = ref(false); @@ -164,7 +169,7 @@ function afterLeave(el: Element) { let pageId = pageFolderTeleportCount.value; pageFolderTeleportCount.value += 1000; -async function toggle() { +async function toggle(ev: MouseEvent) { if (asPage && !opened.value) { pageId++; const { dispose } = await popup(MkFolderPage, { @@ -192,6 +197,14 @@ onMounted(() => { const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); bgSame.value = parentBg === myBg; }); + +watch(opened, (isOpened) => { + if (isOpened) { + emit('opened'); + } else { + emit('closed'); + } +}, { flush: 'post' }); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 8d697499a5..142ccb12a3 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-text="v.label || k"></span> <template v-if="v.description" #caption>{{ v.description }}</template> </MkSwitch> - <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in v.enum" :key="getEnumKey(option)" :value="getEnumValue(option)">{{ getEnumLabel(option) }}</option> </MkSelect> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> @@ -77,7 +76,8 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -120,16 +120,14 @@ function cancel() { dialog.value?.close(); } -function getEnumLabel(e: EnumItem) { - return typeof e === 'string' ? e : e.label; -} - -function getEnumValue(e: EnumItem) { - return typeof e === 'string' ? e : e.value; -} - -function getEnumKey(e: EnumItem) { - return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value); +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); } function getRadioKey(e: RadioFormItem['options'][number]) { diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 2c6185fd33..5ce514f93e 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -19,9 +19,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.container"> <div :class="$style.preview"> - <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown="onImagePointerdown"></canvas> <div :class="$style.previewContainer"> <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div class="_acrylic" :class="$style.editControls"> + <button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button> + </div> <div class="_acrylic" :class="$style.previewControls"> <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> @@ -212,6 +215,147 @@ watch(enabled, () => { renderer.render(); } }); + +const penMode = ref<'fill' | 'blur' | 'pixelate' | null>(null); + +function showPenMenu(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._imageEffector._fxs.fill, + action: () => { + penMode.value = 'fill'; + }, + }, { + text: i18n.ts._imageEffector._fxs.blur, + action: () => { + penMode.value = 'blur'; + }, + }, { + text: i18n.ts._imageEffector._fxs.pixelate, + action: () => { + penMode.value = 'pixelate'; + }, + }], ev.currentTarget ?? ev.target); +} + +function onImagePointerdown(ev: PointerEvent) { + if (canvasEl.value == null || imageBitmap == null || penMode.value == null) return; + + const AW = canvasEl.value.clientWidth; + const AH = canvasEl.value.clientHeight; + const BW = imageBitmap.width; + const BH = imageBitmap.height; + + let xOffset = 0; + let yOffset = 0; + + if (AW / AH < BW / BH) { // 横長 + yOffset = AH - BH * (AW / BW); + } else { // 縦長 + xOffset = AW - BW * (AH / BH); + } + + xOffset /= 2; + yOffset /= 2; + + let startX = ev.offsetX - xOffset; + let startY = ev.offsetY - yOffset; + + if (AW / AH < BW / BH) { // 横長 + startX = startX / (Math.max(AW, AH) / Math.max(BH / BW, 1)); + startY = startY / (Math.max(AW, AH) / Math.max(BW / BH, 1)); + } else { // 縦長 + startX = startX / (Math.min(AW, AH) / Math.max(BH / BW, 1)); + startY = startY / (Math.min(AW, AH) / Math.max(BW / BH, 1)); + } + + const id = genId(); + if (penMode.value === 'fill') { + layers.push({ + id, + fxId: 'fill', + params: { + offsetX: 0, + offsetY: 0, + scaleX: 0.1, + scaleY: 0.1, + angle: 0, + opacity: 1, + color: [1, 1, 1], + }, + }); + } else if (penMode.value === 'blur') { + layers.push({ + id, + fxId: 'blur', + params: { + offsetX: 0, + offsetY: 0, + scaleX: 0.1, + scaleY: 0.1, + angle: 0, + radius: 3, + }, + }); + } else if (penMode.value === 'pixelate') { + layers.push({ + id, + fxId: 'pixelate', + params: { + offsetX: 0, + offsetY: 0, + scaleX: 0.1, + scaleY: 0.1, + angle: 0, + strength: 0.2, + }, + }); + } + + _move(ev.offsetX, ev.offsetY); + + function _move(pointerX: number, pointerY: number) { + let x = pointerX - xOffset; + let y = pointerY - yOffset; + + if (AW / AH < BW / BH) { // 横長 + x = x / (Math.max(AW, AH) / Math.max(BH / BW, 1)); + y = y / (Math.max(AW, AH) / Math.max(BW / BH, 1)); + } else { // 縦長 + x = x / (Math.min(AW, AH) / Math.max(BH / BW, 1)); + y = y / (Math.min(AW, AH) / Math.max(BW / BH, 1)); + } + + const scaleX = Math.abs(x - startX); + const scaleY = Math.abs(y - startY); + + const layerIndex = layers.findIndex((l) => l.id === id); + const layer = layerIndex !== -1 ? layers[layerIndex] : null; + if (layer != null) { + layer.params.offsetX = (x + startX) - 1; + layer.params.offsetY = (y + startY) - 1; + layer.params.scaleX = scaleX; + layer.params.scaleY = scaleY; + layers[layerIndex] = layer; + } + } + + function move(ev: PointerEvent) { + _move(ev.offsetX, ev.offsetY); + } + + function up() { + canvasEl.value?.removeEventListener('pointermove', move); + canvasEl.value?.removeEventListener('pointerup', up); + canvasEl.value?.removeEventListener('pointercancel', up); + canvasEl.value?.releasePointerCapture(ev.pointerId); + + penMode.value = null; + } + + canvasEl.value.addEventListener('pointermove', move); + canvasEl.value.addEventListener('pointerup', up); + canvasEl.value.setPointerCapture(ev.pointerId); +} </script> <style module> @@ -251,6 +395,18 @@ watch(enabled, () => { font-size: 85%; } +.editControls { + position: absolute; + z-index: 100; + bottom: 8px; + left: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + .previewControls { position: absolute; z-index: 100; @@ -283,9 +439,13 @@ watch(enabled, () => { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; - padding: 20px; + /* なんかiOSでレンダリングがおかしい + width: stretch; + height: stretch; + */ + width: calc(100% - 40px); + height: calc(100% - 40px); + margin: 20px; box-sizing: border-box; object-fit: contain; } diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 15578ca1c9..13048a2e1b 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>Chart</template> <div :class="$style.chart"> <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup v-if="shouldShowFederation" :label="i18n.ts.federation"> - <option value="federation">{{ i18n.ts._charts.federation }}</option> - <option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="i18n.ts.users"> - <option value="users">{{ i18n.ts._charts.usersIncDec }}</option> - <option value="users-total">{{ i18n.ts._charts.usersTotal }}</option> - <option value="active-users">{{ i18n.ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="i18n.ts.notes"> - <option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> - <option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="i18n.ts.drive"> - <option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option> - <option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ i18n.ts.perHour }}</option> - <option value="day">{{ i18n.ts.perDay }}</option> - </MkSelect> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0; flex: 1;"></MkSelect> + <MkSelect v-model="chartSpan" :items="chartSpanDef" style="margin: 0 0 0 10px;"></MkSelect> </div> <div class="chart _panel"> <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart> @@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection class="item"> <template #header>Active users heatmap</template> - <MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> - <option value="active-users">Active users</option> - <option value="notes">Notes</option> - <option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> - </MkSelect> + <MkSelect v-model="heatmapSrc" :items="heatmapSrcDef" style="margin: 0 0 12px 0;"></MkSelect> <div class="_panel" :class="$style.heatmap"> <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> </div> @@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, useTemplateRef } from 'vue'; +import { onMounted, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; @@ -101,15 +72,96 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import { initChart } from '@/utility/init-chart.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; initChart(); const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator); const chartLimit = 500; -const chartSpan = ref<'hour' | 'day'>('hour'); -const chartSrc = ref<ChartSrc>('active-users'); -const heatmapSrc = ref<HeatmapSource>('active-users'); +const { + model: chartSpan, + def: chartSpanDef, +} = useMkSelect({ + items: [ + { value: 'hour', label: i18n.ts.perHour }, + { value: 'day', label: i18n.ts.perDay }, + ], + initialValue: 'hour', +}); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: computed<MkSelectItem<ChartSrc>[]>(() => { + const items: MkSelectItem<ChartSrc>[] = []; + + if (shouldShowFederation.value) { + items.push({ + type: 'group', + label: i18n.ts.federation, + items: [ + { value: 'federation', label: i18n.ts._charts.federation }, + { value: 'ap-request', label: i18n.ts._charts.apRequest }, + ], + }); + } + + items.push({ + type: 'group', + label: i18n.ts.users, + items: [ + { value: 'users', label: i18n.ts._charts.usersIncDec }, + { value: 'users-total', label: i18n.ts._charts.usersTotal }, + { value: 'active-users', label: i18n.ts._charts.activeUsers }, + ], + }); + + const notesItems: ItemOption<ChartSrc>[] = [ + { value: 'notes', label: i18n.ts._charts.notesIncDec }, + { value: 'local-notes', label: i18n.ts._charts.localNotesIncDec }, + ]; + + if (shouldShowFederation.value) notesItems.push({ value: 'remote-notes', label: i18n.ts._charts.remoteNotesIncDec }); + + notesItems.push( + { value: 'notes-total', label: i18n.ts._charts.notesTotal }, + ); + + items.push({ + type: 'group', + label: i18n.ts.notes, + items: notesItems, + }); + + items.push({ + type: 'group', + label: i18n.ts.drive, + items: [ + { value: 'drive-files', label: i18n.ts._charts.filesIncDec }, + { value: 'drive', label: i18n.ts._charts.storageUsageIncDec }, + ], + }); + + return items; + }), + initialValue: 'active-users', +}); +const { + model: heatmapSrc, + def: heatmapSrcDef, +} = useMkSelect({ + items: computed(() => [ + { value: 'active-users' as const, label: 'Active Users' }, + { value: 'notes' as const, label: 'Notes' }, + ...(shouldShowFederation.value ? [ + { value: 'ap-requests-inbox-received' as const, label: 'AP Requests: inboxReceived' }, + { value: 'ap-requests-deliver-succeeded' as const, label: 'AP Requests: deliverSucceeded' }, + { value: 'ap-requests-deliver-failed' as const, label: 'AP Requests: deliverFailed' }, + ] : []), + ]), + initialValue: 'active-users', +}); const subDoughnutEl = useTemplateRef('subDoughnutEl'); const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue index 5b8211b715..3f0a5a5247 100644 --- a/packages/frontend/src/components/MkNoteDraftsDialog.vue +++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue @@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only @esc="cancel()" > <template #header> - {{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }}) + {{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }}) </template> - <div class="_spacer"> - <MkPagination :paginator="paginator" withControl> - <template #empty> - <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/> - </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div - v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])" - :key="draft.id" - v-panel - :class="[$style.draft]" - > - <div :class="$style.draftBody" class="_gaps_s"> - <div :class="$style.draftInfo"> - <div :class="$style.draftMeta"> - <div v-if="draft.reply" class="_nowrap"> - <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> - <template #user> - <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/> - <MkAcct v-else :user="draft.reply.user"/> - </template> - </I18n> - </div> - <div v-else-if="draft.replyId" class="_nowrap"> - <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> - <template #user> - {{ i18n.ts.deletedNote }} - </template> - </I18n> - </div> - <div v-if="draft.renote && draft.text != null" class="_nowrap"> - <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> - <template #user> - <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/> - <MkAcct v-else :user="draft.renote.user"/> - </template> - </I18n> - </div> - <div v-else-if="draft.renoteId" class="_nowrap"> - <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> - <template #user> - {{ i18n.ts.deletedNote }} - </template> - </I18n> + <MkStickyContainer> + <template #header> + <MkTabs + v-model:tab="tab" + centered + :class="$style.tabs" + :tabs="[ + { + key: 'drafts', + title: i18n.ts.drafts, + icon: 'ti ti-pencil-question', + }, + { + key: 'scheduled', + title: i18n.ts.scheduled, + icon: 'ti ti-calendar-clock', + }, + ]" + /> + </template> + + <div class="_spacer"> + <MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl> + <template #empty> + <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div + v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])" + :key="draft.id" + v-panel + :class="[$style.draft]" + > + <div :class="$style.draftBody" class="_gaps_s"> + <MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> + <I18n :src="i18n.ts.scheduledToPostOnX" tag="span"> + <template #x> + <MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/> + </template> + </I18n> + </MkInfo> + <div :class="$style.draftInfo"> + <div :class="$style.draftMeta"> + <div v-if="draft.reply" class="_nowrap"> + <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> + <template #user> + <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/> + <MkAcct v-else :user="draft.reply.user"/> + </template> + </I18n> + </div> + <div v-else-if="draft.replyId" class="_nowrap"> + <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> + <template #user> + {{ i18n.ts.deletedNote }} + </template> + </I18n> + </div> + <div v-if="draft.renote && draft.text != null" class="_nowrap"> + <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> + <template #user> + <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/> + <MkAcct v-else :user="draft.renote.user"/> + </template> + </I18n> + </div> + <div v-else-if="draft.renoteId" class="_nowrap"> + <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> + <template #user> + {{ i18n.ts.deletedNote }} + </template> + </I18n> + </div> + <div v-if="draft.channel" class="_nowrap"> + <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }} + </div> </div> - <div v-if="draft.channel" class="_nowrap"> - <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }} + </div> + <div :class="$style.draftContent"> + <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/> + </div> + <div :class="$style.draftFooter"> + <div :class="$style.draftVisibility"> + <span :title="i18n.ts._visibility[draft.visibility]"> + <i v-if="draft.visibility === 'public'" class="ti ti-world"></i> + <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i> + </span> + <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> + <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/> </div> </div> - <div :class="$style.draftContent"> - <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/> - </div> - <div :class="$style.draftFooter"> - <div :class="$style.draftVisibility"> - <span :title="i18n.ts._visibility[draft.visibility]"> - <i v-if="draft.visibility === 'public'" class="ti ti-world"></i> - <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i> - <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i> - <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i> - </span> - <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> - </div> - <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/> + + <div :class="$style.draftActions" class="_buttons"> + <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> + <MkButton + :class="$style.itemButton" + small + @click="cancelSchedule(draft)" + > + <i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }} + </MkButton> + <!-- TODO + <MkButton + :class="$style.itemButton" + small + @click="reSchedule(draft)" + > + <i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }} + </MkButton> + --> + </template> + <MkButton + v-else + :class="$style.itemButton" + small + @click="restoreDraft(draft)" + > + <i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }} + </MkButton> + <MkButton + v-tooltip="i18n.ts._drafts.delete" + danger + small + :iconOnly="true" + :class="$style.itemButton" + style="margin-left: auto;" + @click="deleteDraft(draft)" + > + <i class="ti ti-trash"></i> + </MkButton> </div> </div> - <div :class="$style.draftActions" class="_buttons"> - <MkButton - :class="$style.itemButton" - small - @click="restoreDraft(draft)" - > - <i class="ti ti-corner-up-left"></i> - {{ i18n.ts._drafts.restore }} - </MkButton> - <MkButton - v-tooltip="i18n.ts._drafts.delete" - danger - small - :iconOnly="true" - :class="$style.itemButton" - @click="deleteDraft(draft)" - > - <i class="ti ti-trash"></i> - </MkButton> - </div> </div> - </div> - </template> - </MkPagination> - </div> + </template> + </MkPagination> + </div> + </MkStickyContainer> </MkModalWindow> </template> @@ -125,6 +175,12 @@ import * as os from '@/os.js'; import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api'; import { Paginator } from '@/utility/paginator.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const props = defineProps<{ + scheduled?: boolean; +}>(); const emit = defineEmits<{ (ev: 'restore', draft: Misskey.entities.NoteDraft): void; @@ -132,8 +188,20 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const paginator = markRaw(new Paginator('notes/drafts/list', { +const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts'); + +const draftsPaginator = markRaw(new Paginator('notes/drafts/list', { + limit: 10, + params: { + scheduled: false, + }, +})); + +const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', { limit: 10, + params: { + scheduled: true, + }, })); const currentDraftsCount = ref(0); @@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) { if (canceled) return; os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => { - paginator.reload(); + draftsPaginator.reload(); + }); +} + +async function cancelSchedule(draft: Misskey.entities.NoteDraft) { + os.apiWithDialog('notes/drafts/update', { + draftId: draft.id, + isActuallyScheduled: false, + scheduledAt: null, + }).then(() => { + scheduledPaginator.reload(); }); } </script> @@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) { padding-top: 16px; border-top: solid 1px var(--MI_THEME-divider); } + +.tabs { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 21104b41df..45a74e3f02 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> @@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_mention]: notification.type === 'mention', [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', + [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', + [$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed', [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', @@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> + <i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i> + <i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> @@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tail"> <header :class="$style.header"> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> + <span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span> + <span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span> @@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> <i class="ti ti-quote" :class="$style.quote"></i> </MkA> + <MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ti ti-quote" :class="$style.quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> + <i class="ti ti-quote" :class="$style.quote"></i> + </MkA> <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> {{ notification.role.name }} </div> @@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_scheduledNotePosted { + background: var(--eventOther); + pointer-events: none; +} + +.t_scheduledNotePostFailed { + background: var(--eventOther); + pointer-events: none; +} + .t_achievementEarned { background: var(--eventAchievement); pointer-events: none; diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue index 10bed575a4..55aa3f2dc2 100644 --- a/packages/frontend/src/components/MkPaginationControl.vue +++ b/packages/frontend/src/components/MkPaginationControl.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.control"> - <MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]"> + <MkSelect v-model="order" :class="$style.order" :items="orderDef"> <template #prefix><i class="ti ti-arrows-sort"></i></template> </MkSelect> <MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton> @@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ paginator: T; @@ -58,7 +59,16 @@ const props = withDefaults(defineProps<{ const searchOpened = ref(false); const filterOpened = ref(props.filterOpened); -const order = ref<'newest' | 'oldest'>('newest'); +const { + model: order, + def: orderDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._order.newest, value: 'newest' }, + { label: i18n.ts._order.oldest, value: 'oldest' }, + ], + initialValue: 'newest', +}); const date = ref<number | null>(null); const q = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue index 285c4d0b79..4f1346b685 100644 --- a/packages/frontend/src/components/MkPolkadots.vue +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -4,14 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, accented ? $style.accented : null]"></div> +<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/> </template> <script lang="ts" setup> const props = withDefaults(defineProps<{ accented?: boolean; + revered?: boolean; + height?: number; }>(), { accented: false, + revered: false, + height: 200, }); </script> @@ -27,14 +31,17 @@ const props = withDefaults(defineProps<{ --dot-size: 2px; --gap-size: 40px; --offset: calc(var(--gap-size) / 2); + --height: v-bind('props.height + "px"'); - height: 200px; - margin-bottom: -200px; - + height: var(--height); background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); background-position: 0 0, 0 0, var(--offset) var(--offset); background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); mask-image: linear-gradient(to bottom, black 0%, transparent 100%); pointer-events: none; + + &.revered { + mask-image: linear-gradient(to top, black 0%, transparent 100%); + } } </style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 359ee08812..76c65397ae 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -27,16 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; -import { useInterval } from '@@/js/use-interval.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import { sum } from '@/utility/array.js'; import { pleaseLogin } from '@/utility/please-login.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { useLowresTime } from '@/composables/use-lowres-time.js'; const props = defineProps<{ noteId: string; @@ -48,7 +48,21 @@ const props = defineProps<{ author?: Misskey.entities.UserLite; }>(); -const remaining = ref(-1); +const now = useLowresTime(); + +const expiresAtTime = computed(() => props.expiresAt ? new Date(props.expiresAt).getTime() : null); + +const remaining = computed(() => { + if (expiresAtTime.value == null) return -1; + return Math.floor(Math.max(expiresAtTime.value - now.value, 0) / 1000); +}); + +const remainingWatchStop = watch(remaining, (to) => { + if (to <= 0) { + showResult.value = true; + remainingWatchStop(); + } +}, { immediate: true }); const total = computed(() => sum(props.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); @@ -71,22 +85,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ url: `https://${host}/notes/${props.noteId}`, })); -// 期限付きアンケート -if (props.expiresAt) { - const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000); - if (remaining.value === 0) { - showResult.value = true; - } - }; - - useInterval(tick, 3000, { - immediate: true, - afterMounted: false, - }); -} - -const vote = async (id) => { +const vote = async (id: number) => { if (props.readOnly || closed.value || isVoted.value) return; pleaseLogin({ openOnRemote: pleaseLoginContext.value }); diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 174c923bcf..b7c3d1f42d 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -22,11 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration" small> + <MkSelect v-model="expiration" :items="expirationDef" small> <template #label>{{ i18n.ts._poll.expiration }}</template> - <option value="infinite">{{ i18n.ts._poll.infinite }}</option> - <option value="at">{{ i18n.ts._poll.at }}</option> - <option value="after">{{ i18n.ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> <MkInput v-model="atDate" small type="date" class="input"> @@ -40,12 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="after" small type="number" :min="1" class="input"> <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit" small> - <option value="second">{{ i18n.ts._time.second }}</option> - <option value="minute">{{ i18n.ts._time.minute }}</option> - <option value="hour">{{ i18n.ts._time.hour }}</option> - <option value="day">{{ i18n.ts._time.day }}</option> - </MkSelect> + <MkSelect v-model="unit" :items="unitDef" small></MkSelect> </section> </div> </section> @@ -61,6 +53,7 @@ import MkButton from './MkButton.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; export type PollEditorModelValue = { expiresAt: number | null; @@ -78,11 +71,32 @@ const emit = defineEmits<{ const choices = ref(props.modelValue.choices); const multiple = ref(props.modelValue.multiple); -const expiration = ref('infinite'); +const { + model: expiration, + def: expirationDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._poll.infinite, value: 'infinite' }, + { label: i18n.ts._poll.at, value: 'at' }, + { label: i18n.ts._poll.after, value: 'after' }, + ], + initialValue: 'infinite', +}); const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atTime = ref('00:00'); const after = ref(0); -const unit = ref('second'); +const { + model: unit, + def: unitDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._time.second, value: 'second' }, + { label: i18n.ts._time.minute, value: 'minute' }, + { label: i18n.ts._time.hour, value: 'hour' }, + { label: i18n.ts._time.day, value: 'day' }, + ], + initialValue: 'second', +}); if (props.modelValue.expiresAt) { expiration.value = 'at'; diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue index 739f55125b..6f12aada30 100644 --- a/packages/frontend/src/components/MkPositionSelector.vue +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root]"> <div :class="$style.items"> - <button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button> - <button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button> - <button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button> - <button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button> - <button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button> - <button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button> - <button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button> - <button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button> - <button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-arrow-up-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-arrow-up"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-arrow-up-right"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-arrow-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-focus-2"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-arrow-right"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-arrow-down-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-arrow-down"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-arrow-down-right"></i></button> </div> </div> </template> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 56683b8f8c..c1b950a6c8 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.headerLeft"> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> <button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu"> - <MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/> + <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/> </button> - <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button> + <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button> </div> <div :class="$style.headerRight"> <template v-if="!(targetChannel != null && fixed)"> @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="posted"></template> <template v-else-if="posting"><MkEllipsis/></template> <template v-else>{{ submitText }}</template> - <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i> + <i style="margin-left: 6px;" :class="submitIcon"></i> </div> </button> </div> @@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> </div> + <MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt"> + <I18n :src="i18n.ts.scheduleToPostOnX" tag="span"> + <template #x> + <MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/> + </template> + </I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button> + </MkInfo> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <div v-show="useCw" :class="$style.cwOuter"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> @@ -105,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -199,6 +206,7 @@ if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } const reactionAcceptance = ref(store.s.reactionAcceptance); +const scheduledAt = ref<number | null>(null); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -218,6 +226,10 @@ const uploader = useUploader({ multiple: true, }); +onUnmounted(() => { + uploader.dispose(); +}); + uploader.events.on('itemUploaded', ctx => { files.value.push(ctx.item.uploaded!); uploader.removeItem(ctx.item); @@ -258,11 +270,17 @@ const placeholder = computed((): string => { }); const submitText = computed((): string => { - return renoteTargetNote.value - ? i18n.ts.quote - : replyTargetNote.value - ? i18n.ts.reply - : i18n.ts.note; + return scheduledAt.value != null + ? i18n.ts.schedule + : renoteTargetNote.value + ? i18n.ts.quote + : replyTargetNote.value + ? i18n.ts.reply + : i18n.ts.note; +}); + +const submitIcon = computed((): string => { + return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send'; }); const textLength = computed((): number => { @@ -410,6 +428,7 @@ function watchForDraft() { watch(localOnly, () => saveDraft()); watch(quoteId, () => saveDraft()); watch(reactionAcceptance, () => saveDraft()); + watch(scheduledAt, () => saveDraft()); } function checkMissingMention() { @@ -567,11 +586,11 @@ async function toggleReactionAcceptance() { const select = await os.select({ title: i18n.ts.reactionAcceptance, items: [ - { value: null, text: i18n.ts.all }, - { value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, - { value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly }, - { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, - { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, + { value: null, label: i18n.ts.all }, + { value: 'likeOnlyForRemote' as const, label: i18n.ts.likeOnlyForRemote }, + { value: 'nonSensitiveOnly' as const, label: i18n.ts.nonSensitiveOnly }, + { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, + { value: 'likeOnly' as const, label: i18n.ts.likeOnly }, ], default: reactionAcceptance.value, }); @@ -601,7 +620,13 @@ function showOtherSettings() { action: () => { toggleReactionAcceptance(); }, - }, { type: 'divider' }, { + }, ...($i.policies.scheduledNoteLimit > 0 ? [{ + icon: 'ti ti-calendar-time', + text: i18n.ts.schedulePost + '...', + action: () => { + schedule(); + }, + }] : []), { type: 'divider' }, { type: 'switch', icon: 'ti ti-eye', text: i18n.ts.preview, @@ -650,6 +675,7 @@ function clear() { files.value = []; poll.value = null; quoteId.value = null; + scheduledAt.value = null; } function onKeydown(ev: KeyboardEvent) { @@ -805,6 +831,7 @@ function saveDraft() { ...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), quoteId: quoteId.value, reactionAcceptance: reactionAcceptance.value, + scheduledAt: scheduledAt.value, }, }; @@ -819,29 +846,25 @@ function deleteDraft() { miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } -async function saveServerDraft(clearLocal = false) { +async function saveServerDraft(options: { + isActuallyScheduled?: boolean; +} = {}) { return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', { ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }), text: text.value, - useCw: useCw.value, - cw: cw.value, + cw: useCw.value ? cw.value || null : null, visibility: visibility.value, localOnly: localOnly.value, hashtag: hashtags.value, - ...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}), + fileIds: files.value.map(f => f.id), poll: poll.value, - ...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), - renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : undefined, - replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined, - quoteId: quoteId.value, - channelId: targetChannel.value ? targetChannel.value.id : undefined, + visibleUserIds: visibleUsers.value.map(x => x.id), + renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null, + replyId: replyTargetNote.value ? replyTargetNote.value.id : null, + channelId: targetChannel.value ? targetChannel.value.id : null, reactionAcceptance: reactionAcceptance.value, - }).then(() => { - if (clearLocal) { - clear(); - deleteDraft(); - } - }).catch((err) => { + scheduledAt: scheduledAt.value, + isActuallyScheduled: options.isActuallyScheduled ?? false, }); } @@ -876,6 +899,21 @@ async function post(ev?: MouseEvent) { } } + if (scheduledAt.value != null) { + if (uploader.items.value.some(x => x.uploaded == null)) { + await uploadFiles(); + + // アップロード失敗したものがあったら中止 + if (uploader.items.value.some(x => x.uploaded == null)) { + return; + } + } + + await postAsScheduled(); + clear(); + return; + } + if (props.mock) return; if (visibility.value === 'public' && ( @@ -1047,6 +1085,14 @@ async function post(ev?: MouseEvent) { }); } +async function postAsScheduled() { + if (props.mock) return; + + await saveServerDraft({ + isActuallyScheduled: true, + }); +} + function cancel() { emit('cancel'); } @@ -1141,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) } function showDraftMenu(ev: MouseEvent) { - function showDraftsDialog() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, { + function showDraftsDialog(scheduled: boolean) { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), { + scheduled, + }, { restore: async (draft: Misskey.entities.NoteDraft) => { text.value = draft.text ?? ''; useCw.value = draft.cw != null; @@ -1173,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) { renoteTargetNote.value = draft.renote; replyTargetNote.value = draft.reply; reactionAcceptance.value = draft.reactionAcceptance; + scheduledAt.value = draft.scheduledAt ?? null; if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel; visibleUsers.value = []; @@ -1213,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) { text: i18n.ts._drafts.listDrafts, icon: 'ti ti-cloud-download', action: () => { - showDraftsDialog(); + showDraftsDialog(false); + }, + }, { type: 'divider' }, { + type: 'button', + text: i18n.ts._drafts.listScheduledNotes, + icon: 'ti ti-clock-down', + action: () => { + showDraftsDialog(true); }, }], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } +async function schedule() { + const { canceled, result } = await os.inputDatetime({ + title: i18n.ts.schedulePost, + }); + if (canceled) return; + if (result.getTime() <= Date.now()) return; + + scheduledAt.value = result.getTime(); +} + +function cancelSchedule() { + scheduledAt.value = null; +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1253,6 +1323,7 @@ onMounted(() => { } quoteId.value = draft.data.quoteId; reactionAcceptance.value = draft.data.reactionAcceptance; + scheduledAt.value = draft.data.scheduledAt ?? null; } } @@ -1302,6 +1373,7 @@ async function canClose() { defineExpose({ clear, + abortUploader: () => uploader.abortAll(), canClose, }); </script> @@ -1516,6 +1588,10 @@ html[data-color-scheme=light] .preview { margin: 0 20px 16px 20px; } +.scheduledAt { + margin: 0 20px 16px 20px; +} + .cw, .hashtags, .text { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index bf332e706e..ba8d3a7210 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -54,6 +54,7 @@ function onPosted() { async function _close() { const canClose = await form.value?.canClose(); if (!canClose) return; + form.value?.abortUploader(); modal.value?.close(); } diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 9c37eb5e72..697346020c 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -90,7 +90,7 @@ function subscribe() { publickey: encode(subscription.getKey('p256dh')), }); }, async err => { // When subscribe failed - // 通知が許可されていなかったとき + // 通知が許可されていなかったとき if (err?.name === 'NotAllowedError') { console.info('User denied the notification permission request.'); return; @@ -114,14 +114,13 @@ async function unsubscribe() { if ($i && accounts.length >= 2) { apiWithDialog('sw/unregister', { - i: $i.token, endpoint, - }); + }, $i.token); } else { pushSubscription.value.unsubscribe(); apiWithDialog('sw/unregister', { endpoint, - }); + }, null); pushSubscription.value = null; } } @@ -134,7 +133,7 @@ function encode(buffer: ArrayBuffer | null) { * Convert the URL safe base64 string to a Uint8Array * @param base64String base64 string */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { +function urlBase64ToUint8Array(base64String: string): BufferSource { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index abe6466971..71f3cf7fe4 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkKeyValue> - <template #key>{{ i18n.ts.id }}</template> + <template #key>{{ i18n.ts.name }}</template> <template #value>{{ name }}</template> </MkKeyValue> <MkKeyValue> diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 15149b3f0c..8e5cbde8c3 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> <template v-if="forModeration"> - <i v-if="role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--MI_THEME-success)"></i> + <i v-if="'isPublic' in role && role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--MI_THEME-success)"></i> <i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--MI_THEME-warn)"></i> </template> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </span> <span :class="$style.bodyName">{{ role.name }}</span> - <template v-if="detailed"> + <template v-if="detailed && 'target' in role && 'usersCount' in role"> <span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span> <span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span> </template> @@ -39,7 +39,7 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - role: Misskey.entities.Role; + role: Misskey.entities.Role | Misskey.entities.IResponse['roles'][number]; forModeration: boolean; detailed?: boolean; }>(), { diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index f1cc98def4..937804703d 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -102,12 +102,12 @@ async function addRole() { const items = roles.value .filter(r => r.isPublic) .filter(r => !selectedRoleIds.value.includes(r.id)) - .map(r => ({ text: r.name, value: r })); + .map(r => ({ label: r.name, value: r.id })); - const { canceled, result: role } = await os.select({ items }); - if (canceled || role == null) return; + const { canceled, result: roleId } = await os.select({ items }); + if (canceled || roleId == null) return; - selectedRoleIds.value.push(role.id); + selectedRoleIds.value.push(roleId); } async function removeRole(roleId: string) { diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 9cbaf676c7..f130145e36 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,46 +40,41 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -type ItemOption = { +export type OptionValue = string | number | null; + +export type ItemOption<T extends OptionValue = OptionValue> = { type?: 'option'; - value: string | number | null; + value: T; label: string; }; -type ItemGroup = { +export type ItemGroup<T extends OptionValue = OptionValue> = { type: 'group'; - label: string; - items: ItemOption[]; + label?: string; + items: ItemOption<T>[]; }; -export type MkSelectItem = ItemOption | ItemGroup; +export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>; -type ValuesOfItems<T> = T extends (infer U)[] - ? U extends { type: 'group'; items: infer V } - ? V extends (infer W)[] - ? W extends { value: infer X } - ? X - : never - : never - : U extends { value: infer Y } - ? Y - : never +export type GetMkSelectValueType<T extends MkSelectItem> = T extends ItemGroup + ? T['items'][number]['value'] + : T extends ItemOption + ? T['value'] + : never; + +export type GetMkSelectValueTypesFromDef<T extends MkSelectItem[]> = T[number] extends MkSelectItem + ? GetMkSelectValueType<T[number]> : never; </script> -<script lang="ts" setup generic="T extends MkSelectItem[]"> -import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; +<script lang="ts" setup generic="const ITEMS extends MkSelectItem[], MODELT extends OptionValue"> +import { onMounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue'; import { useInterval } from '@@/js/use-interval.js'; -import type { VNode, VNodeChild } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) -// see: https://github.com/misskey-dev/misskey/issues/15558 -// あと型推論と相性が良くない - const props = defineProps<{ - modelValue: ValuesOfItems<T>; + items: ITEMS; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -88,23 +83,24 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; - items?: T; }>(); -const emit = defineEmits<{ - (ev: 'update:modelValue', value: ValuesOfItems<T>): void; -}>(); +type ModelTChecked = MODELT & ( + MODELT extends GetMkSelectValueTypesFromDef<ITEMS> + ? unknown + : 'Error: The type of model does not match the type of items.' +); -const slots = useSlots(); +const model = defineModel<ModelTChecked>({ required: true }); -const { modelValue, autofocus } = toRefs(props); +const { autofocus } = toRefs(props); const focused = ref(false); const opening = ref(false); const currentValueText = ref<string | null>(null); -const inputEl = ref<HTMLObjectElement | null>(null); -const prefixEl = ref<HTMLElement | null>(null); -const suffixEl = ref<HTMLElement | null>(null); -const container = ref<HTMLElement | null>(null); +const inputEl = useTemplateRef('inputEl'); +const prefixEl = useTemplateRef('prefixEl'); +const suffixEl = useTemplateRef('suffixEl'); +const container = useTemplateRef('container'); const height = props.small ? 33 : props.large ? 39 : @@ -140,52 +136,26 @@ onMounted(() => { }); }); -watch([modelValue, () => props.items], () => { - if (props.items) { - let found: ItemOption | null = null; - for (const item of props.items) { - if (item.type === 'group') { - for (const option of item.items) { - if (option.value === modelValue.value) { - found = option; - break; - } - } - } else { - if (item.value === modelValue.value) { - found = item; +watch([model, () => props.items], () => { + let found: ItemOption | null = null; + for (const item of props.items) { + if (item.type === 'group') { + for (const option of item.items) { + if (option.value === model.value) { + found = option; break; } } - } - if (found) { - currentValueText.value = found.label; - } - return; - } - - const scanOptions = (options: VNodeChild[]) => { - for (const vnode of options) { - if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; - if (vnode.type === 'optgroup') { - const optgroup = vnode; - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? - } else { - const option = vnode; - if (option.props?.value === modelValue.value) { - currentValueText.value = option.children as string; - break; - } + } else { + if (item.value === model.value) { + found = item; + break; } } - }; - - scanOptions(slots.default!()); + } + if (found) { + currentValueText.value = found.label; + } }, { immediate: true, deep: true }); function show() { @@ -196,68 +166,32 @@ function show() { const menu: MenuItem[] = []; - if (props.items) { - for (const item of props.items) { - if (item.type === 'group') { + for (const item of props.items) { + if (item.type === 'group') { + if (item.label != null) { menu.push({ type: 'label', text: item.label, }); - for (const option of item.items) { - menu.push({ - text: option.label, - active: computed(() => modelValue.value === option.value), - action: () => { - emit('update:modelValue', option.value); - }, - }); - } - } else { + } + for (const option of item.items) { menu.push({ - text: item.label, - active: computed(() => modelValue.value === item.value), + text: option.label, + active: computed(() => model.value === option.value), action: () => { - emit('update:modelValue', item.value); + model.value = option.value as ModelTChecked; }, }); } - } - } else { - let options = slots.default!(); - - const pushOption = (option: VNode) => { + } else { menu.push({ - text: option.children as string, - active: computed(() => modelValue.value === option.props?.value), + text: item.label, + active: computed(() => model.value === item.value), action: () => { - emit('update:modelValue', option.props?.value); + model.value = item.value as ModelTChecked; }, }); - }; - - const scanOptions = (options: VNodeChild[]) => { - for (const vnode of options) { - if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props?.label, - }); - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); + } } os.popupMenu(menu, container.value, { diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index f557ffa5dc..d8ae52482e 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -3,76 +3,85 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<template> + <div :class="$style.tabsRoot"> + <button + v-for="option in tabs" + :key="option.key" + :class="['_button', $style.tabButton, { [$style.active]: modelValue === option.key }]" + :disabled="modelValue === option.key" + @click="update(option.key)" + > + <i v-if="option.icon" :class="[option.icon, $style.icon]"></i> + {{ option.label }} + </button> + </div> +</template> + <script lang="ts"> -import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; +export type Tab<T = string> = { + key: T; + icon?: string; + label?: string; +}; +</script> + +<script setup lang="ts" generic="const T extends Tab"> +import { defineProps, defineEmits } from 'vue'; -export default defineComponent({ - props: { - modelValue: { - required: true, - }, - }, - setup(props, { emit, slots }) { - const options = slots.default?.() ?? []; +defineProps<{ + tabs: T[]; +}>(); - return () => h('div', { - class: 'pxhvhrfw', - }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: props.modelValue === option.props?.value }], - key: option.key as string, - disabled: props.modelValue === option.props?.value, - onClick: () => { - emit('update:modelValue', option.props?.value); - }, - }, option.children ?? []), [ - [resolveDirective('click-anime')], - ]))); - }, -}); +const model = defineModel<T['key']>(); + +function update(key: T['key']) { + model.value = key; +} </script> -<style lang="scss"> -.pxhvhrfw { +<style module lang="scss"> +.tabsRoot { display: flex; font-size: 90%; +} - > button { - flex: 1; - padding: 10px 8px; - border-radius: 999px; +.tabButton { + flex: 1; + padding: 10px 8px; + border-radius: 999px; - &:disabled { - opacity: 1 !important; - cursor: default; - } + &:disabled { + opacity: 1 !important; + cursor: default; + } - &.active { - color: var(--MI_THEME-accent); - background: var(--MI_THEME-accentedBg); - } + &.active { + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); + } - &:not(.active):hover { - color: var(--MI_THEME-fgHighlighted); - background: var(--MI_THEME-panelHighlight); - } + &:not(.active):hover { + color: var(--MI_THEME-fgHighlighted); + background: var(--MI_THEME-panelHighlight); + } - &:not(:first-child) { - margin-left: 8px; - } + &:not(:first-child) { + margin-left: 8px; + } - > .icon { - margin-right: 6px; - } + > .icon { + margin-right: 6px; } } @container (max-width: 500px) { - .pxhvhrfw { + .tabsRoot { font-size: 80%; + } - > button { - padding: 11px 8px; - } + .tabButton { + padding: 11px 8px; } } </style> diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue index 57fb6548ba..9798e2c3b3 100644 --- a/packages/frontend/src/components/MkTabs.vue +++ b/packages/frontend/src/components/MkTabs.vue @@ -4,12 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.tabs, { [$style.centered]: props.centered }]"> +<div :class="[$style.tabs, { [$style.centered]: props.centered }]" :style="{ '--tabAnchorName': tabAnchorName }"> <div :class="$style.tabsInner"> <button - v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" - class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" - @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + v-for="t in tabs" + :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" + v-tooltip.noDelay="t.title" + class="_button" + :class="[$style.tab, { + [$style.active]: t.key != null && t.key === tab, + [$style.animate]: prefer.s.animation, + }]" + :style="getTabStyle(t)" + @mousedown="(ev) => onTabMousedown(t, ev)" + @click="(ev) => onTabClick(t, ev)" > <div :class="$style.tabInner"> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> @@ -20,7 +28,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ t.title }} </div> <Transition - v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + v-else + mode="in-out" + @enter="enter" + @afterEnter="afterEnter" + @leave="leave" @afterLeave="afterLeave" > <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> @@ -36,8 +48,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -export type Tab = { - key: string; +export type Tab<K = string> = { + key: K; onClick?: (ev: MouseEvent) => void; iconOnly?: boolean; title: string; @@ -45,31 +57,46 @@ export type Tab = { }; </script> -<script lang="ts" setup> +<script lang="ts" setup generic="const T extends Tab"> import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; import { prefer } from '@/preferences.js'; +import { genId } from '@/utility/id.js'; + +const cssAnchorSupported = CSS.supports('position-anchor', '--anchor-name'); +const tabAnchorName = `--${genId()}-currentTab`; const props = withDefaults(defineProps<{ - tabs?: Tab[]; - tab?: string; + tabs?: T[]; centered?: boolean; tabHighlightUpper?: boolean; }>(), { - tabs: () => ([] as Tab[]), + tabs: () => ([] as T[]), }); const emit = defineEmits<{ - (ev: 'update:tab', key: string); (ev: 'tabClick', key: string); }>(); +const tab = defineModel<T['key']>('tab'); + const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs: Record<string, HTMLElement | null> = {}; -function onTabMousedown(tab: Tab, ev: MouseEvent): void { +function getTabStyle(t: Tab): Record<string, string> { + if (!cssAnchorSupported) return {}; + if (t.key === tab.value) { + return { + anchorName: tabAnchorName, + }; + } else { + return {}; + } +} + +function onTabMousedown(selectedTab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない - if (tab.key) { - emit('update:tab', tab.key); + if (selectedTab.key) { + tab.value = selectedTab.key; } } @@ -83,12 +110,14 @@ function onTabClick(t: Tab, ev: MouseEvent): void { } if (t.key) { - emit('update:tab', t.key); + tab.value = t.key; } } function renderTab() { - const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (cssAnchorSupported) return; + + const tabEl = tab.value ? tabRefs[tab.value] : undefined; if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 @@ -138,14 +167,14 @@ function afterLeave(el: Element) { } onMounted(() => { - watch([() => props.tab, () => props.tabs], () => { - nextTick(() => { - if (entering) return; - renderTab(); - }); - }, { - immediate: true, - }); + if (!cssAnchorSupported) { + watch([tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { immediate: true }); + } }); onUnmounted(() => { @@ -238,4 +267,11 @@ onUnmounted(() => { bottom: auto; } } + +@supports (position-anchor: --anchor-name) { + .tabHighlight { + left: anchor(var(--tabAnchorName) start); + width: anchor-size(var(--tabAnchorName) width); + } +} </style> diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue index f1370965c4..f31c717ad5 100644 --- a/packages/frontend/src/components/MkUploaderItems.vue +++ b/packages/frontend/src/components/MkUploaderItems.vue @@ -10,7 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only :key="item.id" v-panel :class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]" - :style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }" + :style="{ + '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%', + '--pp': item.preprocessProgress != null ? `${item.preprocessProgress * 100}%` : '100%', + }" @contextmenu.prevent.stop="onContextmenu(item, $event)" > <div :class="$style.itemInner"> @@ -19,11 +22,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div> <div :class="$style.itemBody"> - <div><i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div> + <div> + <i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i> + <MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine> + </div> <div :class="$style.itemInfo"> <span>{{ item.file.type }}</span> <span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span> <span v-else>{{ bytes(item.file.size) }}</span> + <span v-if="item.preprocessing">{{ i18n.ts.preprocessing }}<MkLoading inline em style="margin-left: 0.5em;"/></span> </div> <div> </div> @@ -97,7 +104,7 @@ function onThumbnailClick(item: UploaderItem, ev: MouseEvent) { position: absolute; top: 0; left: 0; - width: 100%; + width: var(--pp, 100%); height: 100%; background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); background-size: 25px 25px; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 2a423bfa55..9b587178fe 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -72,7 +72,7 @@ import { getStaticImageUrl } from '@/utility/media-proxy.js'; const props = defineProps<{ showing: boolean; - q: string; + q: string | Misskey.entities.UserDetailed; source: HTMLElement; }>(); @@ -99,10 +99,11 @@ async function fetchUser() { user.value = props.q; error.value = false; } else { - const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ? + const query: Misskey.entities.UsersShowRequest = props.q.startsWith('@') ? Misskey.acct.parse(props.q.substring(1)) : { userId: props.q }; + // @ts-expect-error payloadの引数側の型が正常に解決されない misskeyApi('users/show', query).then(res => { if (!props.showing) return; user.value = res; diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index 11ae091d90..288293db3f 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -19,6 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSlot> <MkRange + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'text' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange v-model="layer.scale" :min="0" :max="1" @@ -67,6 +79,18 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSlot> <MkRange + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'image' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange v-model="layer.scale" :min="0" :max="1" @@ -107,6 +131,55 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> + <template v-else-if="layer.type === 'qr'"> + <MkInput v-model="layer.data" debounce> + <template #label>{{ i18n.ts._watermarkEditor.text }}</template> + <template #caption>{{ i18n.ts._watermarkEditor.leaveBlankToAccountUrl }}</template> + </MkInput> + + <FormSlot> + <template #label>{{ i18n.ts._watermarkEditor.position }}</template> + <MkPositionSelector + v-model:x="layer.align.x" + v-model:y="layer.align.y" + ></MkPositionSelector> + </FormSlot> + + <MkRange + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'qr' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> + <template v-else-if="layer.type === 'stripe'"> <MkRange v-model="layer.frequency" diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 206298b194..0d0488d9bc 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -30,22 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.controls"> <div class="_spacer _gaps"> - <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> - <template #label>{{ i18n.ts._watermarkEditor.type }}</template> - </MkSelect> - - <div v-if="type === 'text' || type === 'image'"> - <XLayer - v-for="(layer, i) in preset.layers" - :key="layer.id" - v-model:layer="preset.layers[i]" - ></XLayer> - </div> - <div v-else-if="type === 'advanced'" class="_gaps_s"> + <div class="_gaps_s"> <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> <template #label> <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div> <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> @@ -86,6 +76,7 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const $i = ensureSignin(); @@ -94,7 +85,7 @@ function createTextLayer(): WatermarkPreset['layers'][number] { id: genId(), type: 'text', text: `(c) @${$i.username}`, - align: { x: 'right', y: 'bottom' }, + align: { x: 'right', y: 'bottom', margin: 0 }, scale: 0.3, angle: 0, opacity: 0.75, @@ -108,7 +99,7 @@ function createImageLayer(): WatermarkPreset['layers'][number] { type: 'image', imageId: null, imageUrl: null, - align: { x: 'right', y: 'bottom' }, + align: { x: 'right', y: 'bottom', margin: 0 }, scale: 0.3, angle: 0, opacity: 0.75, @@ -117,6 +108,17 @@ function createImageLayer(): WatermarkPreset['layers'][number] { }; } +function createQrLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'qr', + data: '', + align: { x: 'right', y: 'bottom', margin: 0 }, + scale: 0.3, + opacity: 1, + }; +} + function createStripeLayer(): WatermarkPreset['layers'][number] { return { id: genId(), @@ -164,7 +166,7 @@ const props = defineProps<{ const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { id: genId(), name: '', - layers: [createTextLayer()], + layers: [], }); const emit = defineEmits<{ @@ -186,17 +188,6 @@ async function cancel() { dialog.value?.close(); } -const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); -watch(type, () => { - if (type.value === 'text') { - preset.layers = [createTextLayer()]; - } else if (type.value === 'image') { - preset.layers = [createImageLayer()]; - } else if (type.value === 'advanced') { - // nop - } -}); - watch(preset, async (newValue, oldValue) => { if (renderer != null) { renderer.setLayers(preset.layers); @@ -327,6 +318,11 @@ function addLayer(ev: MouseEvent) { preset.layers.push(createImageLayer()); }, }, { + text: i18n.ts._watermarkEditor.qr, + action: () => { + preset.layers.push(createQrLayer()); + }, + }, { text: i18n.ts._watermarkEditor.stripe, action: () => { preset.layers.push(createStripeLayer()); diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 08a018ea9b..cf7c2cda80 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <template v-if="edit"> <header :class="$style.editHeader"> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> + <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> - <option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> </MkSelect> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -59,6 +58,7 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -89,7 +89,15 @@ const widgetRefs = {}; const configWidget = (id: string) => { widgetRefs[id].configure(); }; -const widgetAdderSelected = ref<string | null>(null); + +const { + model: widgetAdderSelected, + def: widgetAdderSelectedDef, +} = useMkSelect({ + items: computed(() => [{ label: i18n.ts.none, value: null }, ..._widgetDefs.value.map(x => ({ label: i18n.ts._widgets[x], value: x }))]), + initialValue: null, +}); + const addWidget = () => { if (widgetAdderSelected.value == null) return; diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index e60155f4af..63cf1815c0 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -4,31 +4,39 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.inline]: inline }]"> - <a v-if="external" :class="$style.main" class="_button" :href="to" target="_blank"> +<component + :is="to ? 'div' : 'button'" + :class="[ + $style.root, + { + [$style.inline]: inline, + '_button': !to, + }, + ]" +> + <component + :is="to ? (external ? 'a' : 'MkA') : 'div'" + :class="[$style.main, { [$style.active]: active }]" + class="_button" + v-bind="to ? (external ? { href: to, target: '_blank' } : { to, behavior }) : {}" + > <span :class="$style.icon"><slot name="icon"></slot></span> - <span :class="$style.text"><slot></slot></span> + <div :class="$style.headerText"> + <div> + <MkCondensedLine :minScale="2 / 3"><slot></slot></MkCondensedLine> + </div> + </div> <span :class="$style.suffix"> <span :class="$style.suffixText"><slot name="suffix"></slot></span> - <i class="ti ti-external-link"></i> + <i :class="to && external ? 'ti ti-external-link' : 'ti ti-chevron-right'"></i> </span> - </a> - <MkA v-else :class="[$style.main, { [$style.active]: active }]" class="_button" :to="to" :behavior="behavior"> - <span :class="$style.icon"><slot name="icon"></slot></span> - <span :class="$style.text"><slot></slot></span> - <span :class="$style.suffix"> - <span :class="$style.suffixText"><slot name="suffix"></slot></span> - <i class="ti ti-chevron-right"></i> - </span> - </MkA> -</div> + </component> +</component> </template> <script lang="ts" setup> -import { } from 'vue'; - -const props = defineProps<{ - to: string; +defineProps<{ + to?: string; active?: boolean; external?: boolean; behavior?: null | 'window' | 'browser'; @@ -75,17 +83,18 @@ const props = defineProps<{ &:empty { display: none; - & + .text { + & + .headerText { padding-left: 4px; } } } -.text { - flex-shrink: 1; - white-space: normal; +.headerText { + white-space: nowrap; + text-overflow: ellipsis; + text-align: start; + overflow: hidden; padding-right: 12px; - text-align: center; } .suffix { diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 07e06a6897..6110dae7c5 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { expect, userEvent, waitFor, within } from '@storybook/test'; import MkAd from './MkAd.vue'; import type { StoryObj } from '@storybook/vue3'; @@ -75,6 +75,7 @@ const common = { place: '', imageUrl: '', dayOfWeek: 7, + isSensitive: false, }, }, parameters: { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index a1b57f30d9..1ef75281fd 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -4,12 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="el" :class="$style.tabs" @wheel="onTabWheel"> +<div ref="el" :class="$style.tabs" :style="{ '--tabAnchorName': tabAnchorName }" @wheel="onTabWheel"> <div :class="$style.tabsInner"> <button - v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" - class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" - @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + v-for="t in tabs" + :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" + v-tooltip.noDelay="t.title" + class="_button" + :class="[$style.tab, { + [$style.active]: t.key != null && t.key === props.tab, + [$style.animate]: prefer.s.animation + }]" + :style="getTabStyle(t)" + @mousedown="(ev) => onTabMousedown(t, ev)" + @click="(ev) => onTabClick(t, ev)" > <div :class="$style.tabInner"> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> @@ -48,6 +56,10 @@ export type Tab = { <script lang="ts" setup> import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; import { prefer } from '@/preferences.js'; +import { genId } from '@/utility/id.js'; + +const cssAnchorSupported = CSS.supports('position-anchor', '--anchor-name'); +const tabAnchorName = `--${genId()}-currentTab`; const props = withDefaults(defineProps<{ tabs?: Tab[]; @@ -66,6 +78,17 @@ const el = useTemplateRef('el'); const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs: Record<string, HTMLElement | null> = {}; +function getTabStyle(t: Tab) { + if (!cssAnchorSupported) return {}; + if (t.key === props.tab) { + return { + anchorName: tabAnchorName, + }; + } else { + return {}; + } +} + function onTabMousedown(tab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない if (tab.key) { @@ -88,6 +111,8 @@ function onTabClick(t: Tab, ev: MouseEvent): void { } function renderTab() { + if (cssAnchorSupported) return; + const tabEl = props.tab ? tabRefs[props.tab] : undefined; if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある @@ -152,22 +177,24 @@ function afterLeave(el: Element) { let ro2: ResizeObserver | null; onMounted(() => { - watch([() => props.tab, () => props.tabs], () => { - nextTick(() => { - if (entering) return; - renderTab(); + if (!cssAnchorSupported) { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { + immediate: true, }); - }, { - immediate: true, - }); - if (props.rootEl) { - ro2 = new ResizeObserver((entries, observer) => { - if (window.document.body.contains(el.value as HTMLElement)) { - nextTick(() => renderTab()); - } - }); - ro2.observe(props.rootEl); + if (props.rootEl) { + ro2 = new ResizeObserver(() => { + if (window.document.body.contains(el.value as HTMLElement)) { + nextTick(() => renderTab()); + } + }); + ro2.observe(props.rootEl); + } } }); @@ -246,4 +273,11 @@ onUnmounted(() => { transition: width 0.15s ease, left 0.15s ease; } } + +@supports (position-anchor: --anchor-name) { + .tabHighlight { + left: anchor(var(--tabAnchorName) start); + width: anchor-size(var(--tabAnchorName) width); + } +} </style> diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index f600f7eed2..88cccb99a2 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -14,9 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import isChromatic from 'chromatic/isChromatic'; -import { onMounted, onUnmounted, ref, computed } from 'vue'; +import { computed } from 'vue'; import { i18n } from '@/i18n.js'; import { dateTimeFormat } from '@@/js/intl-const.js'; +import { useLowresTime } from '@/composables/use-lowres-time.js'; const props = withDefaults(defineProps<{ time: Date | string | number | null; @@ -46,8 +47,10 @@ const _time = props.time == null ? NaN : getDateSafe(props.time).getTime(); const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; +const actualNow = useLowresTime(); +const now = computed(() => (props.origin ? props.origin.getTime() : actualNow.value)); + // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const now = ref(props.origin?.getTime() ?? Date.now()); const ago = computed(() => (now.value - _time) / 1000/*ms*/); const relative = computed<string>(() => { @@ -72,29 +75,6 @@ const relative = computed<string>(() => { i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() }) ); }); - -let tickId: number; -let currentInterval: number; - -function tick() { - now.value = Date.now(); - const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000; - - if (currentInterval !== nextInterval) { - if (tickId) window.clearInterval(tickId); - currentInterval = nextInterval; - tickId = window.setInterval(tick, nextInterval); - } -} - -if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) { - onMounted(() => { - tick(); - }); - onUnmounted(() => { - if (tickId) window.clearInterval(tickId); - }); -} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d368dee88a..aac87b7669 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPageHeader v-else v-model:tab="tab" v-bind="pageHeaderProps"/> </template> <div :class="$style.body"> - <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> + <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs ?? []"> <slot></slot> </MkSwiper> <slot v-else></slot> @@ -45,7 +45,7 @@ const props = withDefaults(defineProps<PageHeaderProps & { }); const pageHeaderProps = computed(() => { - const { reversed, ...rest } = props; + const { reversed, tab, ...rest } = props; return rest; }); @@ -75,10 +75,6 @@ defineExpose({ </script> <style lang="scss" module> -.root { - -} - .body, .swiper { min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); } diff --git a/packages/frontend/src/composables/use-lowres-time.ts b/packages/frontend/src/composables/use-lowres-time.ts new file mode 100644 index 0000000000..3c5b561f51 --- /dev/null +++ b/packages/frontend/src/composables/use-lowres-time.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, readonly, computed } from 'vue'; + +const time = ref(Date.now()); + +export const TIME_UPDATE_INTERVAL = 10000; // 10秒 + +/** + * 精度が求められないが定期的に更新しないといけない時計で使用(10秒に一度更新)。 + * tickを各コンポーネントで行うのではなく、ここで一括して行うことでパフォーマンスを改善する。 + * + * ※ マウント前の時刻を返す可能性があるため、通常は`useLowresTime`を使用する +*/ +export const lowresTime = readonly(time); + +/** + * 精度が求められないが定期的に更新しないといけない時計で使用(10秒に一度更新)。 + * tickを各コンポーネントで行うのではなく、ここで一括して行うことでパフォーマンスを改善する。 + * + * 必ず現在時刻以降を返すことを保証するコンポーサブル + */ +export function useLowresTime() { + // lowresTime自体はマウント前の時刻を返す可能性があるため、必ず現在時刻以降を返すことを保証する + const now = Date.now(); + return computed(() => Math.max(time.value, now)); +} + +window.setInterval(() => { + time.value = Date.now(); +}, TIME_UPDATE_INTERVAL); diff --git a/packages/frontend/src/composables/use-mkselect.ts b/packages/frontend/src/composables/use-mkselect.ts new file mode 100644 index 0000000000..7cb470d169 --- /dev/null +++ b/packages/frontend/src/composables/use-mkselect.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import type { Ref, MaybeRefOrGetter } from 'vue'; +import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; + +type UnwrapReadonlyItems<T> = T extends readonly (infer U)[] ? U[] : T; + +/** 指定したオプション定義をもとに型を狭めたrefを生成するコンポーサブル */ +export function useMkSelect< + const TItemsInput extends MaybeRefOrGetter<MkSelectItem[]>, + const TItems extends TItemsInput extends MaybeRefOrGetter<infer U> ? U : never, + TInitialValue extends OptionValue | void = void, + TItemsValue = GetMkSelectValueTypesFromDef<UnwrapReadonlyItems<TItems>>, + ModelType = TInitialValue extends void + ? TItemsValue + : (TItemsValue | TInitialValue) +>(opts: { + items: TItemsInput; + initialValue?: (TInitialValue | (OptionValue extends TItemsValue ? OptionValue : TInitialValue)) & ( + TItemsValue extends TInitialValue + ? unknown + : { 'Error: Type of initialValue must include all types of items': TItemsValue } + ); +}): { + def: TItemsInput; + model: Ref<ModelType>; +} { + const model = ref(opts.initialValue ?? null); + + return { + def: opts.items, + model: model as Ref<ModelType>, + }; +} diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts index 826d8c5203..12b6e85940 100644 --- a/packages/frontend/src/composables/use-uploader.ts +++ b/packages/frontend/src/composables/use-uploader.ts @@ -43,6 +43,12 @@ const IMAGE_EDITING_SUPPORTED_TYPES = [ 'image/webp', ]; +const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO + 'video/mp4', + 'video/quicktime', + 'video/x-matroska', +]; + const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; const IMAGE_PREPROCESS_NEEDED_TYPES = [ @@ -51,6 +57,10 @@ const IMAGE_PREPROCESS_NEEDED_TYPES = [ ...IMAGE_EDITING_SUPPORTED_TYPES, ]; +const VIDEO_PREPROCESS_NEEDED_TYPES = [ + ...VIDEO_COMPRESSION_SUPPORTED_TYPES, +]; + const mimeTypeMap = { 'image/webp': 'webp', 'image/jpeg': 'jpg', @@ -64,6 +74,7 @@ export type UploaderItem = { progress: { max: number; value: number } | null; thumbnail: string | null; preprocessing: boolean; + preprocessProgress: number | null; uploading: boolean; uploaded: Misskey.entities.DriveFile | null; uploadFailed: boolean; @@ -76,6 +87,7 @@ export type UploaderItem = { isSensitive?: boolean; caption?: string | null; abort?: (() => void) | null; + abortPreprocess?: (() => void) | null; }; function getCompressionSettings(level: 0 | 1 | 2 | 3) { @@ -129,11 +141,12 @@ export function useUploader(options: { progress: null, thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null, preprocessing: false, + preprocessProgress: null, uploading: false, aborted: false, uploaded: null, uploadFailed: false, - compressionLevel: prefer.s.defaultImageCompressionLevel, + compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0, watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, file: markRaw(file), }); @@ -318,7 +331,7 @@ export function useUploader(options: { } if ( - IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && + (IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) && !item.preprocessing && !item.uploading && !item.uploaded @@ -391,6 +404,19 @@ export function useUploader(options: { removeItem(item); }, }); + } else if (item.preprocessing && item.abortPreprocess != null) { + menu.push({ + type: 'divider', + }, { + icon: 'ti ti-player-stop', + text: i18n.ts.abort, + danger: true, + action: () => { + if (item.abortPreprocess != null) { + item.abortPreprocess(); + } + }, + }); } else if (item.uploading) { menu.push({ type: 'divider', @@ -474,6 +500,10 @@ export function useUploader(options: { continue; } + if (item.abortPreprocess != null) { + item.abortPreprocess(); + } + if (item.abort != null) { item.abort(); } @@ -484,18 +514,30 @@ export function useUploader(options: { async function preprocess(item: UploaderItem): Promise<void> { item.preprocessing = true; + item.preprocessProgress = null; - try { - if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) { + if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) { + try { await preprocessForImage(item); - } - } catch (err) { - console.error('Failed to preprocess image', err); + } catch (err) { + console.error('Failed to preprocess image', err); // nop + } + } + + if (VIDEO_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) { + try { + await preprocessForVideo(item); + } catch (err) { + console.error('Failed to preprocess video', err); + + // nop + } } item.preprocessing = false; + item.preprocessProgress = null; } async function preprocessForImage(item: UploaderItem): Promise<void> { @@ -564,10 +606,74 @@ export function useUploader(options: { item.preprocessedFile = markRaw(preprocessedFile); } - onUnmounted(() => { + async function preprocessForVideo(item: UploaderItem): Promise<void> { + let preprocessedFile: Blob | File = item.file; + + const needsCompress = item.compressionLevel !== 0 && VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type); + + if (needsCompress) { + const mediabunny = await import('mediabunny'); + + const source = new mediabunny.BlobSource(preprocessedFile); + + const input = new mediabunny.Input({ + source, + formats: mediabunny.ALL_FORMATS, + }); + + const output = new mediabunny.Output({ + target: new mediabunny.BufferTarget(), + format: new mediabunny.Mp4OutputFormat(), + }); + + const currentConversion = await mediabunny.Conversion.init({ + input, + output, + video: { + //width: 320, // Height will be deduced automatically to retain aspect ratio + bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW, + }, + audio: { + bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW, + }, + }); + + currentConversion.onProgress = newProgress => item.preprocessProgress = newProgress; + + item.abortPreprocess = () => { + item.abortPreprocess = null; + currentConversion.cancel(); + item.preprocessing = false; + item.preprocessProgress = null; + }; + + await currentConversion.execute(); + + item.abortPreprocess = null; + + preprocessedFile = new Blob([output.target.buffer!], { type: output.format.mimeType }); + item.compressedSize = output.target.buffer!.byteLength; + item.uploadName = `${item.name}.mp4`; + } else { + item.compressedSize = null; + item.uploadName = item.name; + } + + if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); + item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null; + item.preprocessedFile = markRaw(preprocessedFile); + } + + function dispose() { for (const item of items.value) { if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); } + + abortAll(); + } + + onUnmounted(() => { + dispose(); }); return { @@ -575,6 +681,7 @@ export function useUploader(options: { addFiles, removeItem, abortAll, + dispose, upload, getMenu, uploading: computed(() => items.value.some(item => item.uploading)), diff --git a/packages/frontend/src/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts index 3c6f22f24b..670912241e 100644 --- a/packages/frontend/src/drag-and-drop.ts +++ b/packages/frontend/src/drag-and-drop.ts @@ -23,6 +23,15 @@ export function setDragData<T extends keyof DragDataMap>( event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data)); } +export function setPlainDragData( + event: DragEvent, + data: string, +) { + if (event.dataTransfer == null) return; + + event.dataTransfer.setData('text/plain', data); +} + export function getDragData<T extends keyof DragDataMap>( event: DragEvent, type: T, @@ -35,6 +44,17 @@ export function getDragData<T extends keyof DragDataMap>( return JSON.parse(data); } +export function getPlainDragData( + event: DragEvent, +): string | null { + if (event.dataTransfer == null) return null; + + const data = event.dataTransfer.getData('text/plain'); + if (data == null || data === '') return null; + + return data; +} + export function checkDragDataType( event: DragEvent, types: (keyof DragDataMap)[], diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index 649561cd75..8cac1b6d2a 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -24,7 +24,7 @@ export const globalEvents = new EventEmitter<Events>(); export function useGlobalEvent<T extends keyof Events>( event: T, - callback: Events[T], + callback: EventEmitter.EventListener<Events, T>, ): void { globalEvents.on(event, callback); onBeforeUnmount(() => { diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 20d44032df..6dffcf9478 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -94,7 +94,7 @@ export class Pizzax<T extends StateDef> { private mergeState<X>(value: X, def: X): X { if (this.isPureObject(value) && this.isPureObject(def)) { - const merged = deepMerge(value, def); + const merged = deepMerge<Record<PropertyKey, unknown>>(value, def); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index c0fe0f2b85..a162b3aa9e 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -66,6 +66,12 @@ export const navbarItemDef = reactive({ lookup(); }, }, + qr: { + title: i18n.ts.qr, + icon: 'ti ti-qrcode', + show: computed(() => $i != null), + to: '/qr', + }, lists: { title: i18n.ts.lists, icon: 'ti ti-list', @@ -111,7 +117,7 @@ export const navbarItemDef = reactive({ to: '/channels', }, chat: { - title: i18n.ts.chat, + title: i18n.ts.directMessage_short, icon: 'ti ti-messages', to: '/chat', show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'), diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 56a2b8d269..aafa1c4b21 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -14,6 +14,7 @@ import type { Form, GetFormResultType } from '@/utility/form.js'; 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 MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -35,9 +36,9 @@ import { focusParent } from '@/utility/focus.js'; export const openingWindowsCount = ref(0); export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>; -export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( +export const apiWithDialog = (<E extends keyof Misskey.Endpoints>( endpoint: E, - data: P, + data: Misskey.Endpoints[E]['req'], token?: string | null | undefined, customErrors?: ApiWithDialogCustomErrors, ) => { @@ -75,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Miss } else if (err.code === 'ROLE_PERMISSION_DENIED') { title = i18n.ts.permissionDeniedError; text = i18n.ts.permissionDeniedErrorDescription; - } else if (err.code.startsWith('TOO_MANY')) { + } else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する title = i18n.ts.youCannotCreateAnymore; text = `${i18n.ts.error}: ${err.id}`; } else if (err.message.startsWith('Unexpected token')) { @@ -459,7 +460,7 @@ export function inputNumber(props: { }); } -export function inputDate(props: { +export function inputDatetime(props: { title?: string; text?: string; placeholder?: string | null; @@ -474,13 +475,13 @@ export function inputDate(props: { title: props.title, text: props.text, input: { - type: 'date', + type: 'datetime-local', placeholder: props.placeholder, default: props.default ?? null, }, }, { done: result => { - resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); + resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, closed: () => dispose(), }); @@ -502,50 +503,15 @@ export function authenticateDialog(): Promise<{ }); } -type SelectItem<C> = { - value: C; - text: string; -}; - -// default が指定されていたら result は null になり得ないことを保証する overload function -export function select<C = unknown>(props: { +export function select<C extends OptionValue, D extends C | null = null>(props: { title?: string; text?: string; - default: string; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C; -}>; -export function select<C = unknown>(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C | null; -}>; -export function select<C = unknown>(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; + default?: D; + items: (MkSelectItem<C> | undefined)[]; }): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: C | null; + canceled: false; result: Exclude<D, undefined> extends null ? C | null : C; }> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 7e514c5a73..3957cc422f 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -11,12 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - - <!-- たくさんあると邪魔 - <div class="tags"> - <span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> - </div> - --> </div> <MkFoldableSection v-if="searchEmojis"> @@ -26,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> - <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category ?? '___root___'"> + <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category ?? '___root___'" :expanded="false"> <template #header>{{ category || i18n.ts.other }}</template> <div :class="$style.emojis"> <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> @@ -42,51 +36,33 @@ import XEmoji from './emojis.emoji.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; +import { customEmojis, customEmojiCategories } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; -const customEmojiTags = getCustomEmojiTags(); const q = ref(''); const searchEmojis = ref<Misskey.entities.EmojiSimple[] | null>(null); -const selectedTags = ref(new Set()); function search() { - if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) { + if (q.value === '' || q.value == null) { searchEmojis.value = null; return; } - if (selectedTags.value.size === 0) { - const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); - - if (queryarry) { - searchEmojis.value = customEmojis.value.filter(emoji => - queryarry.includes(`:${emoji.name}:`), - ); - } else { - searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); - } - } else { - searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t))); - } -} + const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); -function toggleTag(tag) { - if (selectedTags.value.has(tag)) { - selectedTags.value.delete(tag); + if (queryarry) { + searchEmojis.value = customEmojis.value.filter(emoji => + queryarry.includes(`:${emoji.name}:`), + ); } else { - selectedTags.value.add(tag); + searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); } } watch(q, () => { search(); }); - -watch(selectedTags, () => { - search(); -}, { deep: true }); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index fd5e061d52..bbfb9a3b7c 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -11,56 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.host }}</template> </MkInput> <FormSplit style="margin-top: var(--MI-margin);"> - <MkSelect v-model="state"> + <MkSelect v-model="state" :items="stateDef"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="federating">{{ i18n.ts.federating }}</option> - <option value="subscribing">{{ i18n.ts.subscribing }}</option> - <option value="publishing">{{ i18n.ts.publishing }}</option> - <option value="suspended">{{ i18n.ts.suspended }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> - <option value="blocked">{{ i18n.ts.blocked }}</option> - <option value="notResponding">{{ i18n.ts.notResponding }}</option> </MkSelect> - <MkSelect - v-model="sort" :items="[{ - label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, - value: '+pubSub', - }, { - label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, - value: '-pubSub', - }, { - label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, - value: '+notes', - }, { - label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, - value: '-notes', - }, { - label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, - value: '+users', - }, { - label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, - value: '-users', - }, { - label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, - value: '+following', - }, { - label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, - value: '-following', - }, { - label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, - value: '+followers', - }, { - label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, - value: '-followers', - }, { - label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, - value: '+firstRetrievedAt', - }, { - label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, - value: '-firstRetrievedAt', - }] as const" - > + <MkSelect v-model="sort" :items="sortDef"> <template #label>{{ i18n.ts.sort }}</template> </MkSelect> </FormSplit> @@ -85,11 +39,46 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; const host = ref(''); -const state = ref('federating'); -const sort = ref<NonNullable<Misskey.entities.FederationInstancesRequest['sort']>>('+pubSub'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.federating, value: 'federating' }, + { label: i18n.ts.subscribing, value: 'subscribing' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.suspended, value: 'suspended' }, + { label: i18n.ts.silence, value: 'silenced' }, + { label: i18n.ts.blocked, value: 'blocked' }, + { label: i18n.ts.notResponding, value: 'notResponding' }, + ], + initialValue: 'federating', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' }, + { label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' }, + { label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' }, + { label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' }, + { label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' }, + { label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' }, + { label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' }, + { label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' }, + { label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' }, + { label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' }, + ], + initialValue: '+pubSub', +}); const paginator = markRaw(new Paginator('federation/instances', { limit: 10, offsetMode: true, diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 38e3c7a11b..6d3cc9c1b7 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -151,19 +151,17 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'announcements'" class="_gaps"> - <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> + <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.createNew }}</MkButton> - <MkSelect v-model="announcementsStatus"> + <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef"> <template #label>{{ i18n.ts.filter }}</template> - <option value="active">{{ i18n.ts.active }}</option> - <option value="archived">{{ i18n.ts.archived }}</option> </MkSelect> <MkPagination :paginator="announcementsPaginator"> <template #default="{ items }"> <div class="_gaps_s"> <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> - <span style="margin-right: 0.5em;"> + <span v-if="'icon' in announcement" style="margin-right: 0.5em;"> <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> @@ -184,8 +182,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'chart'" class="_gaps_m"> <div class="cmhjzshm"> <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="per-user-notes">{{ i18n.ts.notes }}</option> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;"> </MkSelect> </div> <div class="charts"> @@ -229,10 +226,12 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { Paginator } from '@/utility/paginator.js'; +import type { ChartSrc } from '@/components/MkChart.vue'; const $i = ensureSignin(); @@ -246,7 +245,15 @@ const props = withDefaults(defineProps<{ const result = await _fetch_(); const tab = ref(props.initialTab); -const chartSrc = ref('per-user-notes'); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.notes, value: 'per-user-notes' }, +], + initialValue: 'per-user-notes', +}); const user = ref(result.user); const info = ref(result.info); const ips = ref(result.ips); @@ -263,7 +270,16 @@ const filesPaginator = markRaw(new Paginator('admin/drive/files', { })), })); -const announcementsStatus = ref<'active' | 'archived'>('active'); +const { + model: announcementsStatus, + def: announcementsStatusDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.active, value: 'active' }, + { label: i18n.ts.archived, value: 'archived' }, + ], + initialValue: 'active', +}); const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', { limit: 10, @@ -427,22 +443,22 @@ async function assignRole() { const { canceled, result: roleId } = await os.select({ title: i18n.ts._role.chooseRoleToAssign, - items: roles.map(r => ({ text: r.name, value: r.id })), + items: roles.map(r => ({ label: r.name, value: r.id })), }); - if (canceled) return; + 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', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }, { - value: 'oneMonth', text: i18n.ts.oneMonth, + value: 'oneMonth', label: i18n.ts.oneMonth, }], default: 'indefinitely', }); diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 89ecc155b2..9d9db9158d 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -6,26 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div :class="$style.header"> - <MkSelect v-model="type" :class="$style.typeSelect"> - <option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option> - <option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option> - <option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option> - <option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option> - <option value="isBot">{{ i18n.ts._role._condition.isBot }}</option> - <option value="isCat">{{ i18n.ts._role._condition.isCat }}</option> - <option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option> - <option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option> - <option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option> - <option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option> - <option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option> - <option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option> - <option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option> - <option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option> - <option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option> - <option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option> - <option value="and">{{ i18n.ts._role._condition.and }}</option> - <option value="or">{{ i18n.ts._role._condition.or }}</option> - <option value="not">{{ i18n.ts._role._condition.not }}</option> + <MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect"> </MkSelect> <button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle"> <i class="ti ti-menu-2"></i> @@ -58,8 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> </MkInput> - <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId"> - <option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option> + <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef"> </MkSelect> </div> </template> @@ -69,6 +49,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { genId } from '@/utility/id.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/utility/clone.js'; @@ -99,7 +80,29 @@ watch(v, () => { emit('update:modelValue', v.value); }, { deep: true }); -const type = computed({ +const typeDef = [ + { label: i18n.ts._role._condition.isLocal, value: 'isLocal' }, + { label: i18n.ts._role._condition.isRemote, value: 'isRemote' }, + { label: i18n.ts._role._condition.isSuspended, value: 'isSuspended' }, + { label: i18n.ts._role._condition.isLocked, value: 'isLocked' }, + { label: i18n.ts._role._condition.isBot, value: 'isBot' }, + { label: i18n.ts._role._condition.isCat, value: 'isCat' }, + { label: i18n.ts._role._condition.isExplorable, value: 'isExplorable' }, + { label: i18n.ts._role._condition.roleAssignedTo, value: 'roleAssignedTo' }, + { label: i18n.ts._role._condition.createdLessThan, value: 'createdLessThan' }, + { label: i18n.ts._role._condition.createdMoreThan, value: 'createdMoreThan' }, + { label: i18n.ts._role._condition.followersLessThanOrEq, value: 'followersLessThanOrEq' }, + { label: i18n.ts._role._condition.followersMoreThanOrEq, value: 'followersMoreThanOrEq' }, + { label: i18n.ts._role._condition.followingLessThanOrEq, value: 'followingLessThanOrEq' }, + { label: i18n.ts._role._condition.followingMoreThanOrEq, value: 'followingMoreThanOrEq' }, + { label: i18n.ts._role._condition.notesLessThanOrEq, value: 'notesLessThanOrEq' }, + { label: i18n.ts._role._condition.notesMoreThanOrEq, value: 'notesMoreThanOrEq' }, + { label: i18n.ts._role._condition.and, value: 'and' }, + { label: i18n.ts._role._condition.or, value: 'or' }, + { label: i18n.ts._role._condition.not, value: 'not' }, +] as const satisfies MkSelectItem[]; + +const type = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({ get: () => v.value.type, set: (t) => { if (t === 'and') v.value.values = []; @@ -118,6 +121,8 @@ const type = computed({ }, }); +const assignedToDef = computed(() => roles.filter(r => r.target === 'manual').map(r => ({ label: r.name, value: r.id })) satisfies MkSelectItem[]); + function addValue() { v.value.values.push({ id: genId(), type: 'isRemote' }); } diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index b69c818b48..7c3f736506 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -22,27 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="title"> <template #label>{{ i18n.ts.title }}</template> </MkInput> - <MkSelect v-model="method"> + <MkSelect v-model="method" :items="methodDef"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> - <option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> - <option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> <template #caption> {{ methodCaption }} </template> </MkSelect> <div> - <MkSelect v-if="method === 'email'" v-model="userId"> + <MkSelect v-if="method === 'email'" v-model="userId" :items="userIdDef"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template> - <option v-for="user in moderators" :key="user.id" :value="user.id"> - {{ user.name ? `${user.name}(${user.username})` : user.username }} - </option> </MkSelect> <div v-else-if="method === 'webhook'" :class="$style.systemWebhook"> - <MkSelect v-model="systemWebhookId" style="flex: 1"> + <MkSelect v-model="systemWebhookId" :items="systemWebhookIdDef" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template> - <option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id"> - {{ webhook.name }} - </option> </MkSelect> <MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked"> <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/> @@ -79,14 +71,13 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkSelect from '@/components/MkSelect.vue'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkDivider from '@/components/MkDivider.vue'; import * as os from '@/os.js'; -type NotificationRecipientMethod = 'email' | 'webhook'; - const emit = defineEmits<{ (ev: 'submitted'): void; (ev: 'canceled'): void; @@ -105,9 +96,28 @@ const dialogEl = useTemplateRef('dialogEl'); const loading = ref<number>(0); const title = ref<string>(''); -const method = ref<NotificationRecipientMethod>('email'); -const userId = ref<string | null>(null); -const systemWebhookId = ref<string | null>(null); +const { + model: method, + def: methodDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' }, + ], + initialValue: 'email', +}); +const { + model: userId, + def: userIdDef, +} = useMkSelect({ + items: computed(() => moderators.value.map(u => ({ label: u.name ? `${u.name}(${u.username})` : u.username, value: u.id as string | null }))), +}); +const { + model: systemWebhookId, + def: systemWebhookIdDef, +} = useMkSelect({ + items: computed(() => systemWebhooks.value.map(w => ({ label: w.name, value: w.id }))), +}); const isActive = ref<boolean>(true); const moderators = ref<entities.User[]>([]); diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index f5e77cbe4e..893bd8d6d3 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -13,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> <div :class="$style.subMenus" class="_gaps_s"> - <MkSelect v-model="filterMethod" style="flex: 1"> + <MkSelect v-model="filterMethod" :items="filterMethodDef" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> - <option :value="null">-</option> - <option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> - <option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> </MkSelect> <MkInput v-model="filterText" type="search" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template> @@ -51,10 +48,21 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import MkDivider from '@/components/MkDivider.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]); -const filterMethod = ref<string | null>(null); +const { + model: filterMethod, + def: filterMethodDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: null }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' }, + ], + initialValue: null, +}); const filterText = ref<string>(''); const filteredRecipients = computed(() => { diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index ab462229a7..76bf20b409 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -16,23 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkTip> <div :class="$style.inputs" class="_gaps"> - <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <MkSelect v-model="state" :items="stateDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="unresolved">{{ i18n.ts.unresolved }}</option> - <option value="resolved">{{ i18n.ts.resolved }}</option> </MkSelect> - <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <MkSelect v-model="targetUserOrigin" :items="targetUserOriginDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.reporteeOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> - <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <MkSelect v-model="reporterOrigin" :items="reporterOriginDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.reporterOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> </div> @@ -64,13 +55,44 @@ import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkButton from '@/components/MkButton.vue'; import { store } from '@/store.js'; import { Paginator } from '@/utility/paginator.js'; -const state = ref('unresolved'); -const reporterOrigin = ref('combined'); -const targetUserOrigin = ref('combined'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.unresolved, value: 'unresolved' }, + { label: i18n.ts.resolved, value: 'resolved' }, + ], + initialValue: 'unresolved', +}); +const { + model: reporterOrigin, + def: reporterOriginDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'combined', +}); +const { + model: targetUserOrigin, + def: targetUserOriginDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'combined', +}); const searchUsername = ref(''); const searchHost = ref(''); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 06a28db088..94940a84ae 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -6,27 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 900px;"> - <MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems"> + <MkSelect v-model="filterType" :items="filterTypeDef" :class="$style.input" @update:modelValue="filterItems"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="publishing">{{ i18n.ts.publishing }}</option> - <option value="expired">{{ i18n.ts.expired }}</option> </MkSelect> + <div> <div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad"> <MkAd v-if="ad.url" :key="ad.id" :specify="ad"/> + <MkInput v-model="ad.url" type="url"> <template #label>URL</template> </MkInput> + <MkInput v-model="ad.imageUrl" type="url"> <template #label>{{ i18n.ts.imageUrl }}</template> </MkInput> + <MkRadios v-model="ad.place"> <template #label>Form</template> <option value="square">square</option> <option value="horizontal">horizontal</option> <option value="horizontal-big">horizontal-big</option> </MkRadios> + <!-- <div style="margin: 32px 0;"> {{ i18n.ts.priority }} @@ -35,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> </div> --> + <FormSplit> <MkInput v-model="ad.ratio" type="number"> <template #label>{{ i18n.ts.ratio }}</template> @@ -46,6 +49,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.expiration }}</template> </MkInput> </FormSplit> + + <MkSwitch v-model="ad.isSensitive"> + <template #label>{{ i18n.ts.sensitive }}</template> + </MkSwitch> + <MkFolder> <template #label>{{ i18n.ts.advancedSettings }}</template> <span> @@ -59,9 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </span> </MkFolder> + <MkTextarea v-model="ad.memo"> <template #label>{{ i18n.ts.memo }}</template> </MkTextarea> + <div class="_buttons"> <MkButton inline primary style="margin-right: 12px;" @click="save(ad)"> <i @@ -73,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> </div> + <MkButton @click="more()"> <i class="ti ti-reload"></i>{{ i18n.ts.more }} </MkButton> @@ -91,10 +102,12 @@ import MkRadios from '@/components/MkRadios.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const ads = ref<Misskey.entities.Ad[]>([]); @@ -102,7 +115,17 @@ const ads = ref<Misskey.entities.Ad[]>([]); const localTime = new Date(); const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; -const filterType = ref('all'); +const { + model: filterType, + def: filterTypeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.expired, value: 'expired' }, + ], + initialValue: 'all', +}); let publishing: boolean | null = null; misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { @@ -121,7 +144,7 @@ misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { } }); -const filterItems = (v) => { +const filterItems = (v: typeof filterType.value) => { if (v === 'publishing') { publishing = true; } else if (v === 'expired') { @@ -134,7 +157,7 @@ const filterItems = (v) => { }; // 選択された曜日(index)のビットフラグを操作する -function toggleDayOfWeek(ad, index) { +function toggleDayOfWeek(ad: Misskey.entities.Ad, index: number) { ad.dayOfWeek ^= 1 << index; } @@ -150,10 +173,11 @@ function add() { expiresAt: new Date().toISOString(), startsAt: new Date().toISOString(), dayOfWeek: 0, + isSensitive: false, }); } -function remove(ad) { +function remove(ad: Misskey.entities.Ad) { os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: ad.url }), @@ -169,7 +193,7 @@ function remove(ad) { }); } -function save(ad) { +function save(ad: Misskey.entities.Ad) { if (ad.id === '') { misskeyApi('admin/ad/create', { ...ad, diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e5903d6257..b90a724b17 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -10,10 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> - <MkSelect v-model="announcementsStatus"> + <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef"> <template #label>{{ i18n.ts.filter }}</template> - <option value="active">{{ i18n.ts.active }}</option> - <option value="archived">{{ i18n.ts.archived }}</option> </MkSelect> <MkLoading v-if="loading"/> @@ -98,8 +96,18 @@ import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; -const announcementsStatus = ref<'active' | 'archived'>('active'); +const { + model: announcementsStatus, + def: announcementsStatusDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.active, value: 'active' }, + { label: i18n.ts.archived, value: 'archived' }, + ], + initialValue: 'active', +}); const loading = ref(true); const loadingMore = ref(false); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue index 9938d5cc4a..6b5272914b 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue @@ -56,20 +56,24 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkSelect v-model="model.sensitive" + :items="[ + { label: '-', value: null }, + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ]" > <template #label>sensitive</template> - <option :value="null">-</option> - <option :value="true">true</option> - <option :value="false">false</option> </MkSelect> <MkSelect v-model="model.localOnly" + :items="[ + { label: '-', value: null }, + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ]" > <template #label>localOnly</template> - <option :value="null">-</option> - <option :value="true">true</option> - <option :value="false">false</option> </MkSelect> <MkInput v-model="model.updatedAtFrom" diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index 621ec8a6a8..c343d88eb1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -12,11 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template> <div class="_gaps"> - <MkSelect v-model="selectedFolderId"> + <MkSelect v-model="selectedFolderId" :items="selectedFolderIdDef"> <template #label>{{ i18n.ts.uploadFolder }}</template> - <option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id"> - {{ folder.name }} - </option> </MkSelect> <MkSwitch v-model="directoryToCategory"> @@ -63,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; -import { onMounted, ref, useCssModule } from 'vue'; +import { computed, onMounted, ref, useCssModule } from 'vue'; import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import type { DroppedFile } from '@/utility/file-drop.js'; @@ -87,6 +84,7 @@ import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { prefer } from '@/preferences.js'; @@ -229,7 +227,13 @@ function setupGrid(): GridSetting { const uploadFolders = ref<FolderItem[]>([]); const gridItems = ref<GridItem[]>([]); -const selectedFolderId = ref(prefer.s.uploadFolder); +const { + model: selectedFolderId, + def: selectedFolderIdDef, +} = useMkSelect({ + items: computed(() => uploadFolders.value.map(folder => ({ label: folder.name, value: folder.id || '' }))), + initialValue: prefer.s.uploadFolder, +}); const directoryToCategory = ref<boolean>(false); const registerButtonDisabled = ref<boolean>(false); const requestLogs = ref<RequestLogItem[]>([]); @@ -303,8 +307,8 @@ async function onFileSelectClicked() { const driveFiles = await chooseFileFromPcAndUpload({ multiple: true, folderId: selectedFolderId.value, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + // // 拡張子は消す + // nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), }); gridItems.value.push(...driveFiles.map(fromDriveFile)); diff --git a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 9a311b5772..420219c22c 100644 --- a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue @@ -26,10 +26,10 @@ const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; function setData(values) { - if (chartInstance == null) return; + if (chartInstance == null || chartInstance.data.labels == null) return; for (const value of values) { chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); @@ -42,7 +42,7 @@ function setData(values) { } function pushData(value) { - if (chartInstance == null) return; + if (chartInstance == null || chartInstance.data.labels == null) return; chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); if (chartInstance.data.datasets[0].data.length > 200) { @@ -69,6 +69,8 @@ const color = onMounted(() => { const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'line', data: { diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index ddc3ff7b79..cbf7dbbff5 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -13,31 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.host }}</template> </MkInput> <FormSplit style="margin-top: var(--MI-margin);"> - <MkSelect v-model="state"> + <MkSelect v-model="state" :items="stateDef"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="federating">{{ i18n.ts.federating }}</option> - <option value="subscribing">{{ i18n.ts.subscribing }}</option> - <option value="publishing">{{ i18n.ts.publishing }}</option> - <option value="suspended">{{ i18n.ts.suspended }}</option> - <option value="blocked">{{ i18n.ts.blocked }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> - <option value="notResponding">{{ i18n.ts.notResponding }}</option> </MkSelect> - <MkSelect v-model="sort"> + <MkSelect v-model="sort" :items="sortDef"> <template #label>{{ i18n.ts.sort }}</template> - <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> </MkSelect> </FormSplit> </div> @@ -64,11 +44,46 @@ import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; const host = ref(''); -const state = ref('federating'); -const sort = ref('+pubSub'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.federating, value: 'federating' }, + { label: i18n.ts.subscribing, value: 'subscribing' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.suspended, value: 'suspended' }, + { label: i18n.ts.blocked, value: 'blocked' }, + { label: i18n.ts.silence, value: 'silenced' }, + { label: i18n.ts.notResponding, value: 'notResponding' }, + ], + initialValue: 'federating', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' }, + { label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' }, + { label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' }, + { label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' }, + { label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' }, + { label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' }, + { label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' }, + { label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' }, + { label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' }, + { label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' }, + ], + initialValue: '+pubSub', +}); const paginator = markRaw(new Paginator('federation/instances', { limit: 10, offsetMode: true, diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index b4ec930997..c8b5980883 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -8,11 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_gaps"> <div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;"> - <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <MkSelect v-model="origin" :items="originDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.instance }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'"> <template #label>{{ i18n.ts.host }}</template> @@ -42,9 +39,20 @@ import * as os from '@/os.js'; import { lookupFile } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; -const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local'); +const { + model: origin, + def: originDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'local', +}); const type = ref<string | null>(null); const searchHost = ref(''); const userId = ref(''); diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 1c551cb477..d52a57e582 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -26,19 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> <div :class="$style.inputs"> - <MkSelect v-model="type" :class="$style.input"> + <MkSelect v-model="type" :items="typeDef" :class="$style.input"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="unused">{{ i18n.ts.unused }}</option> - <option value="used">{{ i18n.ts.used }}</option> - <option value="expired">{{ i18n.ts.expired }}</option> </MkSelect> - <MkSelect v-model="sort" :class="$style.input"> + <MkSelect v-model="sort" :items="sortDef" :class="$style.input"> <template #label>{{ i18n.ts.sort }}</template> - <option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option> - <option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option> </MkSelect> </div> <MkPagination :paginator="paginator"> @@ -67,10 +59,33 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; -const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all'); -const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt'); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.unused, value: 'unused' }, + { label: i18n.ts.used, value: 'used' }, + { label: i18n.ts.expired, value: 'expired' }, + ], + initialValue: 'all', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.createdAt} (${i18n.ts.ascendingOrder})`, value: '+createdAt' }, + { label: `${i18n.ts.createdAt} (${i18n.ts.descendingOrder})`, value: '-createdAt' }, + { label: `${i18n.ts.usedAt} (${i18n.ts.ascendingOrder})`, value: '+usedAt' }, + { label: `${i18n.ts.usedAt} (${i18n.ts.descendingOrder})`, value: '-usedAt' }, + ], + initialValue: '+createdAt', +}); const paginator = markRaw(new Paginator('admin/invite/list', { limit: 10, diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue index 0856bac860..b18049cb11 100644 --- a/packages/frontend/src/pages/admin/job-queue.vue +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -210,6 +210,7 @@ async function fetchCurrentQueue() { } async function fetchJobs() { + if (tab.value === '-') return; jobsFetching.value = true; const state = jobState.value; jobs.value = await misskeyApi('admin/queue/jobs', { @@ -307,6 +308,7 @@ async function removeJobs() { } async function refreshJob(jobId: string) { + if (tab.value === '-') return; const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId }); const index = jobs.value.findIndex((job) => job.id === jobId); if (index !== -1) { diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 435dd9c462..a11278b68a 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -25,18 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']"> - <MkSelect - v-model="ugcVisibilityForVisitor" :items="[{ - value: 'all', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, - }, { - value: 'local', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly + ' (' + i18n.ts.recommended + ')', - }, { - value: 'none', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, - }] as const" @update:modelValue="onChange_ugcVisibilityForVisitor" - > + <MkSelect v-model="ugcVisibilityForVisitor" :items="ugcVisibilityForVisitorDef" @update:modelValue="onChange_ugcVisibilityForVisitor"> <template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template> <template #caption> <div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div> @@ -176,6 +165,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -185,7 +175,17 @@ const meta = await misskeyApi('admin/meta'); const enableRegistration = ref(!meta.disableRegistration); const emailRequiredForSignup = ref(meta.emailRequiredForSignup); -const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor); +const { + model: ugcVisibilityForVisitor, + def: ugcVisibilityForVisitorDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, value: 'all' }, + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly, value: 'local' }, + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, value: 'none' }, + ], + initialValue: meta.ugcVisibilityForVisitor, +}); const sensitiveWords = ref(meta.sensitiveWords.join('\n')); const prohibitedWords = ref(meta.prohibitedWords.join('\n')); const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n')); @@ -221,7 +221,7 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } -function onChange_ugcVisibilityForVisitor(value: Misskey.entities.AdminUpdateMetaRequest['ugcVisibilityForVisitor']) { +function onChange_ugcVisibilityForVisitor(value: typeof ugcVisibilityForVisitor.value) { os.apiWithDialog('admin/update-meta', { ugcVisibilityForVisitor: value, }).then(() => { diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 08bdc8d254..cb75be7edd 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -8,10 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_gaps"> <MkPaginationControl :paginator="paginator" canFilter> - <MkSelect v-model="type" style="margin: 0; flex: 1;"> + <MkSelect v-model="type" :items="typeDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.type }}</template> - <option :value="null">{{ i18n.ts.all }}</option> - <option v-for="t in Misskey.moderationLogTypes" :key="t" :value="t">{{ i18n.ts._moderationLogTypes[t] ?? t }}</option> </MkSelect> <MkInput v-model="moderatorId" style="margin: 0; flex: 1;"> @@ -54,12 +52,22 @@ import MkTl from '@/components/MkTl.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import MkButton from '@/components/MkButton.vue'; import MkPaginationControl from '@/components/MkPaginationControl.vue'; import { Paginator } from '@/utility/paginator.js'; -const type = ref<string | null>(null); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: null }, + ...Misskey.moderationLogTypes.map(t => ({ label: i18n.ts._moderationLogTypes[t] ?? t, value: t })), + ], + initialValue: null, +}); const moderatorId = ref(''); const paginator = markRaw(new Paginator('admin/show-moderation-logs', { diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 6c85f11cb1..32a5a6976e 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -26,7 +26,7 @@ initChart(); const chartEl = useTemplateRef('chartEl'); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 7; const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 50f12cbf45..3c737ad32b 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="item _panel sub"> <div class="icon"><i class="ti ti-world-download"></i></div> <div class="body"> - <div class="value"> + <div v-if="federationSubActive != null" class="value"> {{ number(federationSubActive) }} - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> + <MkNumberDiff v-if="federationSubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> </div> <div class="label">Sub</div> </div> @@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="item _panel pub"> <div class="icon"><i class="ti ti-world-upload"></i></div> <div class="body"> - <div class="value"> + <div v-if="federationPubActive != null" class="value"> {{ number(federationPubActive) }} - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> + <MkNumberDiff v-if="federationPubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> </div> <div class="label">Pub</div> </div> diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue index 7b2b142b16..5edc01404c 100644 --- a/packages/frontend/src/pages/admin/overview.heatmap.vue +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_panel" :class="$style.root"> - <MkSelect v-model="src" style="margin: 0 0 12px 0;" small> - <option value="active-users">Active users</option> - <option value="notes">Notes</option> - <option value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> + <MkSelect v-model="src" :items="srcDef" style="margin: 0 0 12px 0;" small> </MkSelect> <MkHeatmap :src="src"/> </div> </template> <script lang="ts" setup> -import { ref } from 'vue'; import MkHeatmap from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; -const src = ref('active-users'); +const { + model: src, + def: srcDef, +} = useMkSelect({ + items: [ + { label: 'Active users', value: 'active-users' }, + { label: 'Notes', value: 'notes' }, + { label: 'AP Requests: inboxReceived', value: 'ap-requests-inbox-received' }, + { label: 'AP Requests: deliverSucceeded', value: 'ap-requests-deliver-succeeded' }, + { label: 'AP Requests: deliverFailed', value: 'ap-requests-deliver-failed' }, + ], + initialValue: 'active-users', +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index ec2b558cee..2e874b3505 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -32,15 +32,17 @@ const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', }); -let chartInstance: Chart; +let chartInstance: Chart | null = null; onMounted(() => { + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'doughnut', data: { labels: props.data.map(x => x.name), datasets: [{ - backgroundColor: props.data.map(x => x.color), + backgroundColor: props.data.map(x => x.color ?? '#000'), borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, @@ -57,9 +59,10 @@ onMounted(() => { }, }, onClick: (ev) => { - const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; - if (hit && props.data[hit.index].onClick) { - props.data[hit.index].onClick(); + if (ev.native == null) return; + const hit = chartInstance!.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick != null) { + props.data[hit.index].onClick!(); } }, plugins: { diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 9b9618c4ac..771b35c09f 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -26,10 +26,10 @@ const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; -function setData(values) { - if (chartInstance == null) return; +function setData(values: number[]) { + if (chartInstance == null || chartInstance.data.labels == null) return; for (const value of values) { chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); @@ -41,8 +41,8 @@ function setData(values) { chartInstance.update(); } -function pushData(value) { - if (chartInstance == null) return; +function pushData(value: number) { + if (chartInstance == null || chartInstance.data.labels == null) return; chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); if (chartInstance.data.datasets[0].data.length > 100) { @@ -67,6 +67,8 @@ const color = '?' as never; onMounted(() => { + if (chartEl.value == null) return; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index e7e139b74d..e57df3744a 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './overview.queue.chart.vue'; -import type { ApQueueDomain } from '@/pages/admin/queue.vue'; +import type { ApQueueDomain } from '@/pages/admin/federation-job-queue.vue'; import number from '@/filters/number.js'; import { useStream } from '@/stream.js'; import { genId } from '@/utility/id.js'; @@ -64,10 +64,10 @@ function onStats(stats: Misskey.entities.QueueStats) { delayed.value = stats[props.domain].delayed; waiting.value = stats[props.domain].waiting; - chartProcess.value.pushData(stats[props.domain].activeSincePrevTick); - chartActive.value.pushData(stats[props.domain].active); - chartDelayed.value.pushData(stats[props.domain].delayed); - chartWaiting.value.pushData(stats[props.domain].waiting); + chartProcess.value?.pushData(stats[props.domain].activeSincePrevTick); + chartActive.value?.pushData(stats[props.domain].active); + chartDelayed.value?.pushData(stats[props.domain].delayed); + chartWaiting.value?.pushData(stats[props.domain].waiting); } function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { @@ -83,10 +83,10 @@ function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { dataWaiting.push(stats[props.domain].waiting); } - chartProcess.value.setData(dataProcess); - chartActive.value.setData(dataActive); - chartDelayed.value.setData(dataDelayed); - chartWaiting.value.setData(dataWaiting); + chartProcess.value?.setData(dataProcess); + chartActive.value?.setData(dataActive); + chartDelayed.value?.setData(dataDelayed); + chartWaiting.value?.setData(dataWaiting); } onMounted(() => { diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index fd8145b308..b0669bc557 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> - <div v-else :class="$style.root"> + <div v-else-if="stats != null" :class="$style.root"> <div class="item _panel users"> <div class="icon"><i class="ti ti-users"></i></div> <div class="body"> <div class="value"> <MkNumber :value="stats.originalUsersCount" style="margin-right: 0.5em;"/> - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> + <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> </div> <div class="label">Users</div> </div> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="body"> <div class="value"> <MkNumber :value="stats.originalNotesCount" style="margin-right: 0.5em;"/> - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> + <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> </div> <div class="label">Notes</div> </div> @@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> + <MkError v-else/> </Transition> </div> </template> @@ -71,8 +72,8 @@ import { customEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); -const usersComparedToThePrevDay = ref<number>(); -const notesComparedToThePrevDay = ref<number>(); +const usersComparedToThePrevDay = ref<number | null>(null); +const notesComparedToThePrevDay = ref<number | null>(null); const onlineUsersCount = ref(0); const fetching = ref(true); @@ -85,11 +86,11 @@ onMounted(async () => { onlineUsersCount.value = _onlineUsersCount; misskeyApiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { - usersComparedToThePrevDay.value = stats.value.originalUsersCount - chart.local.total[1]; + usersComparedToThePrevDay.value = _stats.originalUsersCount - chart.local.total[1]; }); misskeyApiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { - notesComparedToThePrevDay.value = stats.value.originalNotesCount - chart.local.total[1]; + notesComparedToThePrevDay.value = _stats.originalNotesCount - chart.local.total[1]; }); fetching.value = false; diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 2ad5173618..2c550bd9c3 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -95,7 +95,7 @@ const federationPubActiveDiff = ref<number | null>(null); const federationSubActive = ref<number | null>(null); const federationSubActiveDiff = ref<number | null>(null); const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null); -const activeInstances = shallowRef<Misskey.entities.FederationInstance | null>(null); +const activeInstances = shallowRef<Misskey.entities.FederationInstancesResponse | null>(null); const queueStatsConnection = markRaw(useStream().useChannel('queueStats')); const now = new Date(); const filesPagination = { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index e98b4f0129..5f8950f07e 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -30,19 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts._role.descriptionOfDisplayOrder }}</template> </MkInput> - <MkSelect v-model="rolePermission" :readonly="readonly"> + <MkSelect v-model="rolePermission" :items="rolePermissionDef" :readonly="readonly"> <template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template> <template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template> - <option value="normal">{{ i18n.ts.normalUser }}</option> - <option value="moderator">{{ i18n.ts.moderator }}</option> - <option value="administrator">{{ i18n.ts.administrator }}</option> </MkSelect> - <MkSelect v-model="role.target" :readonly="readonly"> + <MkSelect v-model="role.target" :items="[{ label: i18n.ts._role.manual, value: 'manual' }, { label: i18n.ts._role.conditional, value: 'conditional' }]" :readonly="readonly"> <template #label><i class="ti ti-users"></i> {{ i18n.ts._role.assignTarget }}</template> <template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template> - <option value="manual">{{ i18n.ts._role.manual }}</option> - <option value="conditional">{{ i18n.ts._role.conditional }}</option> </MkSelect> <MkFolder v-if="role.target === 'conditional'" defaultOpen> @@ -176,11 +171,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts._role.useBaseValue }}</template> </MkSwitch> - <MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly"> + <MkSelect + v-model="role.policies.chatAvailability.value" + :items="[ + { label: i18n.ts.enabled, value: 'available' }, + { label: i18n.ts.readonly, value: 'readonly' }, + { label: i18n.ts.disabled, value: 'unavailable' }, + ]" + :disabled="role.policies.chatAvailability.useDefault" + :readonly="readonly" + > <template #label>{{ i18n.ts.enable }}</template> - <option value="available">{{ i18n.ts.enabled }}</option> - <option value="readonly">{{ i18n.ts.readonly }}</option> - <option value="unavailable">{{ i18n.ts.disabled }}</option> </MkSelect> <MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> @@ -419,6 +420,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <MkInput v-model="role.policies.maxFileSizeMb.value" :disabled="role.policies.maxFileSizeMb.useDefault" type="number" :readonly="readonly"> <template #suffix>MB</template> + <template #caption> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div> + </template> </MkInput> <MkRange v-model="role.policies.maxFileSizeMb.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> @@ -801,6 +805,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix> + <span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.scheduledNoteLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix> @@ -830,7 +853,7 @@ import { watch, ref, computed } from 'vue'; import { throttle } from 'throttle-debounce'; import * as Misskey from 'misskey-js'; import RolesEditorFormula from './RolesEditorFormula.vue'; -import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; +import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -871,11 +894,17 @@ function updateAvatarDecorationLimit(value: string | number) { role.value.policies.avatarDecorationLimit.value = limited; } -const rolePermission = computed({ +const rolePermissionDef = [ + { label: i18n.ts.normalUser, value: 'normal' }, + { label: i18n.ts.moderator, value: 'moderator' }, + { label: i18n.ts.administrator, value: 'administrator' }, +] as const satisfies MkSelectItem[]; + +const rolePermission = computed<GetMkSelectValueTypesFromDef<typeof rolePermissionDef>>({ get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal', set: (val) => { - role.value.isAdministrator = val === 'administrator'; - role.value.isModerator = val === 'moderator'; + role.value.isAdministrator = (val === 'administrator'); + role.value.isModerator = (val === 'moderator'); }, }); diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index c6c3165828..2e249eee50 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -71,7 +71,7 @@ import { Paginator } from '@/utility/paginator.js'; const router = useRouter(); const props = defineProps<{ - id?: string; + id: string; }>(); const usersPaginator = markRaw(new Paginator('admin/roles/users', { @@ -115,15 +115,15 @@ async function assign() { const { canceled: canceled2, result: period } = await os.select({ title: i18n.ts.period + ': ' + role.name, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }, { - value: 'oneMonth', text: i18n.ts.oneMonth, + value: 'oneMonth', label: i18n.ts.oneMonth, }], default: 'indefinitely', }); diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 5323d042cf..e65a3c5ba8 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -52,11 +52,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])"> <template #label>{{ i18n.ts._role._options.chatAvailability }}</template> <template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template> - <MkSelect v-model="policies.chatAvailability"> + <MkSelect + v-model="policies.chatAvailability" + :items="[ + { label: i18n.ts.enabled, value: 'available' }, + { label: i18n.ts.readonly, value: 'readonly' }, + { label: i18n.ts.disabled, value: 'unavailable' }, + ]" + > <template #label>{{ i18n.ts.enable }}</template> - <option value="available">{{ i18n.ts.enabled }}</option> - <option value="readonly">{{ i18n.ts.readonly }}</option> - <option value="unavailable">{{ i18n.ts.disabled }}</option> </MkSelect> </MkFolder> @@ -151,6 +155,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>{{ policies.maxFileSizeMb }}MB</template> <MkInput v-model="policies.maxFileSizeMb" type="number"> <template #suffix>MB</template> + <template #caption> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div> + </template> </MkInput> </MkFolder> @@ -300,6 +307,13 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix>{{ policies.scheduledNoteLimit }}</template> + <MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0"> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template> @@ -346,6 +360,7 @@ import { definePage } from '@/page.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { useRouter } from '@/router.js'; +import { deepClone } from '@/utility/clone.js'; import MkTextarea from '@/components/MkTextarea.vue'; const router = useRouter(); @@ -353,10 +368,7 @@ const baseRoleQ = ref(''); const roles = await misskeyApi('admin/roles/list'); -const policies = reactive<Record<typeof Misskey.rolePolicies[number], any>>({}); -for (const ROLE_POLICY of Misskey.rolePolicies) { - policies[ROLE_POLICY] = instance.policies[ROLE_POLICY]; -} +const policies = reactive(deepClone(instance.policies)); const avatarDecorationLimit = computed({ get: () => Math.min(16, Math.max(0, policies.avatarDecorationLimit)), @@ -376,6 +388,7 @@ function matchQuery(keywords: string[]): boolean { async function updateBaseRole() { await os.apiWithDialog('admin/roles/update-default-policies', { + //@ts-expect-error misskey-js側の型定義が不十分 policies, }); fetchInstance(true); diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 7cbaeba8c7..2f7ecca521 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -11,26 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton> </div> <div :class="$style.inputs"> - <MkSelect v-model="sort" style="flex: 1;"> + <MkSelect v-model="sort" :items="sortDef" style="flex: 1;"> <template #label>{{ i18n.ts.sort }}</template> - <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> </MkSelect> - <MkSelect v-model="state" style="flex: 1;"> + <MkSelect v-model="state" :items="stateDef" style="flex: 1;"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="available">{{ i18n.ts.normal }}</option> - <option value="admin">{{ i18n.ts.administrator }}</option> - <option value="moderator">{{ i18n.ts.moderator }}</option> - <option value="suspended">{{ i18n.ts.suspend }}</option> </MkSelect> - <MkSelect v-model="origin" style="flex: 1;"> + <MkSelect v-model="origin" :items="originDef" style="flex: 1;"> <template #label>{{ i18n.ts.instance }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> </div> <div :class="$style.inputs"> @@ -67,23 +55,57 @@ import * as os from '@/os.js'; import { lookupUser } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { dateString } from '@/filters/date.js'; import { Paginator } from '@/utility/paginator.js'; type SearchQuery = { - sort?: string; - state?: string; - origin?: string; + sort?: '-createdAt' | '+createdAt' | '-updatedAt' | '+updatedAt'; + state?: 'all' | 'available' | 'admin' | 'moderator' | 'suspended'; + origin?: 'combined' | 'local' | 'remote'; username?: string; hostname?: string; }; const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery; -const sort = ref(storedQuery.sort ?? '+createdAt'); -const state = ref(storedQuery.state ?? 'all'); -const origin = ref(storedQuery.origin ?? 'local'); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`, value: '-createdAt' }, + { label: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`, value: '+createdAt' }, + { label: `${i18n.ts.lastUsed} (${i18n.ts.ascendingOrder})`, value: '-updatedAt' }, + { label: `${i18n.ts.lastUsed} (${i18n.ts.descendingOrder})`, value: '+updatedAt' }, + ], + initialValue: storedQuery.sort ?? '+createdAt', +}); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.normal, value: 'available' }, + { label: i18n.ts.administrator, value: 'admin' }, + { label: i18n.ts.moderator, value: 'moderator' }, + { label: i18n.ts.suspend, value: 'suspended' }, + ], + initialValue: storedQuery.state ?? 'all', +}); +const { + model: origin, + def: originDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: storedQuery.origin ?? 'local', +}); const searchUsername = ref(storedQuery.username ?? ''); const searchHost = ref(storedQuery.hostname ?? ''); const paginator = markRaw(new Paginator('admin/show-users', { diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 7e13d0ab36..83bf7221d0 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <h1>{{ i18n.ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted' && session"> - <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? i18n.ts._auth.alreadyAuthorized : i18n.ts._auth.accepted }}</h1> <p v-if="session.app.callbackUrl"> {{ i18n.ts._auth.callback }} <MkEllipsis/> diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index ddc4e89ef1..a8ce527523 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -101,12 +101,12 @@ async function addRole() { const roles = await misskeyApi('admin/roles/list'); const currentRoleIds = rolesThatCanBeUsedThisDecoration.value.map(x => x.id); - const { canceled, result: role } = await os.select({ - items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + const { canceled, result: roleId } = await os.select({ + items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })), }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; - rolesThatCanBeUsedThisDecoration.value.push(role); + rolesThatCanBeUsedThisDecoration.value.push(roles.find(r => r.id === roleId)!); } async function removeRole(role, ev) { diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue index 652ab04be6..5c773a241b 100644 --- a/packages/frontend/src/pages/chat/home.vue +++ b/packages/frontend/src/pages/chat/home.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <MkPolkadots v-if="tab === 'home'" accented/> + <MkPolkadots v-if="tab === 'home'" accented :height="200" style="margin-bottom: -200px;"/> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <XHome v-if="tab === 'home'"/> <XInvitations v-else-if="tab === 'invitations'"/> @@ -48,7 +48,7 @@ const headerTabs = computed(() => [{ }]); definePage(() => ({ - title: i18n.ts.chat + ' (beta)', + title: i18n.ts.directMessage, icon: 'ti ti-messages', })); </script> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue index 834aa9e033..9accea185e 100644 --- a/packages/frontend/src/pages/chat/message.vue +++ b/packages/frontend/src/pages/chat/message.vue @@ -46,6 +46,6 @@ onMounted(() => { }); definePage({ - title: i18n.ts.chat, + title: i18n.ts.directMessage, }); </script> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index 6443616fe3..ef9205d86e 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -421,7 +421,7 @@ const tab = ref('chat'); const headerTabs = computed(() => room.value ? [{ key: 'chat', - title: i18n.ts.chat, + title: i18n.ts._chat.messages, icon: 'ti ti-messages', }, { key: 'members', @@ -437,7 +437,7 @@ const headerTabs = computed(() => room.value ? [{ icon: 'ti ti-info-circle', }] : [{ key: 'chat', - title: i18n.ts.chat, + title: i18n.ts._chat.messages, icon: 'ti ti-messages', }, { key: 'search', @@ -466,12 +466,12 @@ definePage(computed(() => { }; } else { return { - title: i18n.ts.chat, + title: i18n.ts.directMessage, }; } } else { return { - title: i18n.ts.chat, + title: i18n.ts.directMessage, }; } })); diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue index eb94f23ac9..91d3e0e537 100644 --- a/packages/frontend/src/pages/contact.vue +++ b/packages/frontend/src/pages/contact.vue @@ -28,17 +28,37 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> </template> </MkKeyValue> + <MkFolder @opened="onOpened"> + <template #icon><i class="ti ti-report-search"></i></template> + <template #label>{{ i18n.ts.deviceInfo }}</template> + <template #caption>{{ i18n.ts.deviceInfoDescription }}</template> + <MkLoading v-if="userEnv == null" /> + <MkCode v-else lang="json" :code="JSON.stringify(userEnv, null, 2)" style="max-height: 300px; overflow: auto;"/> + </MkFolder> </div> </div> </PageWithHeader> </template> <script lang="ts" setup> +import { ref } from 'vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { definePage } from '@/page.js'; +import { getUserEnvironment } from '@/utility/get-user-environment.js'; +import type { UserEnvironment } from '@/utility/get-user-environment.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkLink from '@/components/MkLink.vue'; +import MkCode from '@/components/MkCode.vue'; + +const userEnv = ref<UserEnvironment | null>(null); + +async function onOpened() { + if (userEnv.value == null) { + userEnv.value = await getUserEnvironment(); + } +} definePage(() => ({ title: i18n.ts.inquiry, diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue index 5cd68c2c3a..9c0761f0b1 100644 --- a/packages/frontend/src/pages/debug.vue +++ b/packages/frontend/src/pages/debug.vue @@ -11,11 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkResult v-if="resultType === 'notFound'" type="notFound"/> <MkResult v-if="resultType === 'error'" type="error"/> <MkSelect - v-model="resultType" :items="[ - { label: 'empty', value: 'empty' }, - { label: 'notFound', value: 'notFound' }, - { label: 'error', value: 'error' }, - ]" + v-model="resultType" :items="resultTypeDef" ></MkSelect> <MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/> @@ -25,14 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/> <MkSelect - v-model="iconType" :items="[ - { label: 'info', value: 'info' }, - { label: 'question', value: 'question' }, - { label: 'success', value: 'success' }, - { label: 'warn', value: 'warn' }, - { label: 'error', value: 'error' }, - { label: 'waiting', value: 'waiting' }, - ]" + v-model="iconType" :items="iconTypeDef" ></MkSelect> <div class="_buttons"> @@ -56,10 +45,34 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkLink from '@/components/MkLink.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import * as os from '@/os.js'; -const resultType = ref('empty'); -const iconType = ref('info'); +const { + model: resultType, + def: resultTypeDef, +} = useMkSelect({ + items: [ + { label: 'empty', value: 'empty' }, + { label: 'notFound', value: 'notFound' }, + { label: 'error', value: 'error' }, + ], + initialValue: 'empty', +}); +const { + model: iconType, + def: iconTypeDef, +} = useMkSelect({ + items: [ + { label: 'info', value: 'info' }, + { label: 'question', value: 'question' }, + { label: 'success', value: 'success' }, + { label: 'warn', value: 'warn' }, + { label: 'error', value: 'error' }, + { label: 'waiting', value: 'waiting' }, + ], + initialValue: 'info', +}); definePage(() => ({ title: 'DEBUG ROOM', diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index c1a8b992b7..0a69dbdd70 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -23,12 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_woodenFrame" style="text-align: center;"> <div class="_woodenFrameInner"> <div class="_gaps" style="padding: 16px;"> - <MkSelect v-model="gameMode"> - <option value="normal">NORMAL</option> - <option value="square">SQUARE</option> - <option value="yen">YEN</option> - <option value="sweets">SWEETS</option> - <!--<option value="space">SPACE</option>--> + <MkSelect v-model="gameMode" :items="gameModeDef"> </MkSelect> <MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton> </div> @@ -92,11 +87,24 @@ import XGame from './drop-and-fusion.game.vue'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { misskeyApiGet } from '@/utility/misskey-api.js'; -const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal'); +const { + model: gameMode, + def: gameModeDef, +} = useMkSelect({ + items: [ + { label: 'NORMAL', value: 'normal' }, + { label: 'SQUARE', value: 'square' }, + { label: 'YEN', value: 'yen' }, + { label: 'SWEETS', value: 'sweets' }, + //{ label: 'SPACE', value: 'space' }, + ], + initialValue: 'normal', +}); const gameStarted = ref(false); const mute = ref(false); const ranking = ref<Misskey.entities.BubbleGameRankingResponse | null>(null); diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 033e3376a5..ea4863950d 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -135,12 +135,12 @@ async function addRole() { const roles = await misskeyApi('admin/roles/list'); const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id); - const { canceled, result: role } = await os.select({ - items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + const { canceled, result: roleId } = await os.select({ + items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })), }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; - rolesThatCanBeUsedThisEmojiAsReaction.value.push(role); + rolesThatCanBeUsedThisEmojiAsReaction.value.push(roles.find(r => r.id === roleId)!); } async function removeRole(role: Misskey.entities.RoleLite, ev: Event) { diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index abb816a956..3158b384d2 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -5,9 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <MkTab v-model="tab" style="margin-bottom: var(--MI-margin);"> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="polls">{{ i18n.ts.poll }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'notes', label: i18n.ts.notes }, + { key: 'polls', label: i18n.ts.poll }, + ]" + style="margin-bottom: var(--MI-margin);" + > </MkTab> <MkNotesTimeline v-if="tab === 'notes'" :paginator="paginatorForNotes"/> <MkNotesTimeline v-else-if="tab === 'polls'" :paginator="paginatorForPolls"/> @@ -33,5 +38,5 @@ const paginatorForPolls = markRaw(new Paginator('notes/polls/recommendation', { }, })); -const tab = ref('notes'); +const tab = ref<'notes' | 'polls'>('notes'); </script> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 08f9f5e582..4e3fb16b5a 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -5,9 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_spacer" style="--MI_SPACER-w: 1200px;"> - <MkTab v-if="instance.federation !== 'none'" v-model="origin" style="margin-bottom: var(--MI-margin);"> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> + <MkTab + v-if="instance.federation !== 'none'" + v-model="origin" + :tabs="[ + { key: 'local', label: i18n.ts.local }, + { key: 'remote', label: i18n.ts.remote }, + ]" + style="margin-bottom: var(--MI-margin);" + > </MkTab> <div v-if="origin === 'local'"> <template v-if="tag == null"> @@ -77,7 +83,7 @@ const props = defineProps<{ tag?: string; }>(); -const origin = ref('local'); +const origin = ref<'local' | 'remote'>('local'); const tagsLocal = ref<Misskey.entities.Hashtag[]>([]); const tagsRemote = ref<Misskey.entities.Hashtag[]>([]); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 81b9d1cead..b3e8e88c23 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -10,11 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="title"> <template #label>{{ i18n.ts._play.title }}</template> </MkInput> - <MkSelect v-model="visibility"> + <MkSelect v-model="visibility" :items="visibilityDef"> <template #label>{{ i18n.ts.visibility }}</template> <template #caption>{{ i18n.ts._play.visibilityDescription }}</template> - <option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option> - <option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option> </MkSelect> <MkTextarea v-model="summary" :mfmAutocomplete="true" :mfmPreview="true"> <template #label>{{ i18n.ts._play.summary }}</template> @@ -52,6 +50,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { useRouter } from '@/router.js'; const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION} @@ -384,7 +383,16 @@ if (props.id) { const title = ref(flash.value?.title ?? 'New Play'); const summary = ref(flash.value?.summary ?? ''); const permissions = ref([]); // not implemented yet -const visibility = ref<'private' | 'public'>(flash.value?.visibility ?? 'public'); +const { + model: visibility, + def: visibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: flash.value?.visibility ?? 'public', +}); const script = ref(flash.value?.script ?? PRESET_DEFAULT); function selectPreset(ev: MouseEvent) { diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index eab435c002..31a716fb0e 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="body"> <div class="title">{{ post.title }}</div> - <div class="description"><Mfm :text="post.description"/></div> + <div class="description"><Mfm v-if="post.description != null" :text="post.description"/></div> <div class="info"> <i class="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> </div> @@ -93,7 +93,7 @@ const error = ref<any>(null); const otherPostsPaginator = markRaw(new Paginator('users/gallery/posts', { limit: 6, computedParams: computed(() => ({ - userId: post.value.user.id, + userId: post.value!.user.id, })), })); @@ -109,33 +109,38 @@ function fetchPost() { } function copyLink() { + if (!post.value) return; copyToClipboard(`${url}/gallery/${post.value.id}`); } function share() { + if (!post.value) return; navigator.share({ title: post.value.title, - text: post.value.description, + text: post.value.description ?? undefined, url: `${url}/gallery/${post.value.id}`, }); } function shareWithNote() { + if (!post.value) return; os.post({ initialText: `${post.value.title} ${url}/gallery/${post.value.id}`, }); } function like() { + if (!post.value) return; os.apiWithDialog('gallery/posts/like', { postId: props.postId, }).then(() => { - post.value.isLiked = true; - post.value.likedCount++; + post.value!.isLiked = true; + post.value!.likedCount++; }); } async function unlike() { + if (!post.value) return; const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -144,8 +149,8 @@ async function unlike() { os.apiWithDialog('gallery/posts/unlike', { postId: props.postId, }).then(() => { - post.value.isLiked = false; - post.value.likedCount--; + post.value!.isLiked = false; + post.value!.likedCount--; }); } diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 473207fe6e..61a40202c0 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -92,18 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'chart'" class="_gaps_m"> <div> <div :class="$style.selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> - <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> - <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> - <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> - <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> - <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> - <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> - <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> - <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> - <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> - <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;"> </MkSelect> </div> <div> @@ -154,6 +143,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { dateString } from '@/filters/date.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkTextarea from '@/components/MkTextarea.vue'; import { Paginator } from '@/utility/paginator.js'; @@ -163,7 +153,25 @@ const props = defineProps<{ const tab = ref('overview'); -const chartSrc = ref<ChartSrc>('instance-requests'); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._instanceCharts.requests, value: 'instance-requests' }, + { label: i18n.ts._instanceCharts.users, value: 'instance-users' }, + { label: i18n.ts._instanceCharts.usersTotal, value: 'instance-users-total' }, + { label: i18n.ts._instanceCharts.notes, value: 'instance-notes' }, + { label: i18n.ts._instanceCharts.notesTotal, value: 'instance-notes-total' }, + { label: i18n.ts._instanceCharts.ff, value: 'instance-ff' }, + { label: i18n.ts._instanceCharts.ffTotal, value: 'instance-ff-total' }, + { label: i18n.ts._instanceCharts.cacheSize, value: 'instance-drive-usage' }, + { label: i18n.ts._instanceCharts.cacheSizeTotal, value: 'instance-drive-usage-total' }, + { label: i18n.ts._instanceCharts.files, value: 'instance-drive-files' }, + { label: i18n.ts._instanceCharts.filesTotal, value: 'instance-drive-files-total' }, + ], + initialValue: 'instance-requests', +}); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none'); diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index a52b562c7f..efb1186fe5 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton> + <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount != null && list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton> </div> @@ -41,7 +41,7 @@ const props = defineProps<{ listId: string; }>(); -const list = ref<Misskey.entities.UserList | null>(null); +const list = ref<Misskey.entities.UsersListsShowResponse | null>(null); const error = ref<unknown | null>(null); const users = ref<Misskey.entities.UserDetailed[]>([]); @@ -51,8 +51,9 @@ function fetchList(): void { forPublic: true, }).then(_list => { list.value = _list; + if (_list.userIds == null || _list.userIds.length === 0) return; misskeyApi('users/show', { - userIds: list.value.userIds, + userIds: _list.userIds, }).then(_users => { users.value = _users; }); @@ -68,7 +69,7 @@ function like() { }).then(() => { if (list.value == null) return; list.value.isLiked = true; - list.value.likedCount++; + list.value.likedCount = (list.value.likedCount != null ? list.value.likedCount + 1 : 1); }); } @@ -79,7 +80,7 @@ function unlike() { }).then(() => { if (list.value == null) return; list.value.isLiked = false; - list.value.likedCount--; + list.value.likedCount = (list.value.likedCount != null ? Math.max(0, list.value.likedCount - 1) : 0); }); } @@ -88,7 +89,7 @@ async function create() { const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); - if (canceled) return; + if (canceled || name == null) return; await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.value.id }); } diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index abd2a5d8a1..c93ec4272a 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -136,10 +136,10 @@ function fetchNote() { }); } }).catch(err => { - if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') { + if (['fbcc002d-37d9-4944-a6b0-d9e29f2d33ab', '145f88d2-b03d-4087-8143-a78928883c4b'].includes(err.id)) { pleaseLogin({ path: '/', - message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor, + message: err.id === 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab' ? i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor : i18n.ts.signinOrContinueOnRemote, openOnRemote: { type: 'lookup', url: `https://${host}/notes/${props.noteId}`, diff --git a/packages/frontend/src/pages/page-editor/common.ts b/packages/frontend/src/pages/page-editor/common.ts index 420c8fc967..64cd9cde7a 100644 --- a/packages/frontend/src/pages/page-editor/common.ts +++ b/packages/frontend/src/pages/page-editor/common.ts @@ -4,12 +4,13 @@ */ import { i18n } from '@/i18n.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; export function getPageBlockList() { return [ - { value: 'section', text: i18n.ts._pages.blocks.section }, - { value: 'text', text: i18n.ts._pages.blocks.text }, - { value: 'image', text: i18n.ts._pages.blocks.image }, - { value: 'note', text: i18n.ts._pages.blocks.note }, - ]; + { value: 'section', label: i18n.ts._pages.blocks.section }, + { value: 'text', label: i18n.ts._pages.blocks.text }, + { value: 'image', label: i18n.ts._pages.blocks.image }, + { value: 'note', label: i18n.ts._pages.blocks.note }, + ] as const satisfies MkSelectItem[]; } diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index f275ec9517..e596b31b43 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -39,6 +39,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void; + (ev: 'remove'): void; }>(); const id = ref(props.modelValue.note); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index cf5712a8e5..bb0841965f 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -71,7 +71,7 @@ async function add() { title: i18n.ts._pages.chooseBlock, items: getPageBlockList(), }); - if (canceled) return; + if (canceled || type == null) return; const id = genId(); children.value.push({ id, type }); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 4a980ce472..079a28491b 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -27,6 +27,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void; + (ev: 'remove'): void; }>(); let autocomplete: Autocomplete; @@ -42,6 +43,7 @@ watch(text, () => { }); onMounted(() => { + if (inputEl.value == null) return; autocomplete = new Autocomplete(inputEl.value, text); }); diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 9fe03ae981..3dd83b25c5 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <div class="jqqmcavi"> - <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> + <MkButton v-if="pageId && author != null" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton> <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> @@ -24,16 +24,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput v-model="name"> - <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #prefix>{{ url }}/@{{ author?.username ?? '???' }}/pages/</template> <template #label>{{ i18n.ts._pages.url }}</template> </MkInput> <MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch> - <MkSelect v-model="font"> + <MkSelect v-model="font" :items="fontDef"> <template #label>{{ i18n.ts._pages.font }}</template> - <option value="serif">{{ i18n.ts._pages.fontSerif }}</option> - <option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option> </MkSelect> <MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch> @@ -76,6 +74,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; import { mainRouter } from '@/router.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { getPageBlockList } from '@/pages/page-editor/common.js'; const props = defineProps<{ @@ -85,7 +84,7 @@ const props = defineProps<{ }>(); const tab = ref('settings'); -const author = ref($i); +const author = ref<Misskey.entities.User | null>($i); const readonly = ref(false); const page = ref<Misskey.entities.Page | null>(null); const pageId = ref<string | null>(null); @@ -95,7 +94,16 @@ const summary = ref<string | null>(null); const name = ref(Date.now().toString()); const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null); const eyeCatchingImageId = ref<string | null>(null); -const font = ref<'sans-serif' | 'serif'>('sans-serif'); +const { + model: font, + def: fontDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._pages.fontSansSerif, value: 'sans-serif' }, + { label: i18n.ts._pages.fontSerif, value: 'serif' }, + ], + initialValue: 'sans-serif', +}); const content = ref<Misskey.entities.Page['content']>([]); const alignCenter = ref(false); const hideTitleWhenPinned = ref(false); @@ -202,11 +210,10 @@ async function duplicate() { async function add() { const { canceled, result: type } = await os.select({ - type: null, title: i18n.ts._pages.chooseBlock, items: getPageBlockList(), }); - if (canceled) return; + if (canceled || type == null) return; const id = genId(); content.value.push({ id, type }); diff --git a/packages/frontend/src/pages/qr.read.raw-viewer.vue b/packages/frontend/src/pages/qr.read.raw-viewer.vue new file mode 100644 index 0000000000..5a23e2322d --- /dev/null +++ b/packages/frontend/src/pages/qr.read.raw-viewer.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder defaultOpen :withSpacer="false"> + <template #label>{{ data.split('\n')[0] }}</template> + <template #header> + <MkTabs + v-model:tab="tab" + :tabs="[ + { + key: 'mfm', + title: i18n.ts._qr.mfm, + icon: 'ti ti-align-left', + }, + { + key: 'raw', + title: i18n.ts._qr.raw, + icon: 'ti ti-code', + }, + ]" + /> + </template> + + <div v-show="tab === 'mfm'" class="_spacer _gaps"> + <Mfm :text="data" :nyaize="false"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false"/> + </div> + <div v-show="tab === 'raw'" class="_spacer" style="--MI_SPACER-min: 10px; --MI_SPACER-max: 16px;"> + <MkCode :code="data" lang="text"/> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as mfm from 'mfm-js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTabs from '@/components/MkTabs.vue'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm'; +import MkCode from '@/components/MkCode.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + data: string; +}>(); + +const parsed = computed(() => mfm.parse(props.data)); +const urls = computed(() => extractUrlFromMfm(parsed.value)); +const tab = ref<'mfm' | 'raw'>('mfm'); +</script> diff --git a/packages/frontend/src/pages/qr.read.vue b/packages/frontend/src/pages/qr.read.vue new file mode 100644 index 0000000000..251dccd0f0 --- /dev/null +++ b/packages/frontend/src/pages/qr.read.vue @@ -0,0 +1,402 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="$style.root" + :style="{ + '--MI-QrReadViewHeight': 'calc(100cqh - var(--MI-stickyTop, 0px) - var(--MI-stickyBottom, 0px))', + '--MI-QrReadVideoHeight': 'min(calc(var(--MI-QrReadViewHeight) * 0.3), 512px)', + }" +> + <MkStickyContainer> + <template #header> + <div :class="$style.view"> + <video ref="videoEl" :class="$style.video" autoplay muted playsinline></video> + <div ref="overlayEl" :class="$style.overlay"></div> + <div :class="$style.controls"> + <MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton> + + <MkButton v-if="qrStarted" v-tooltip="i18n.ts._qr.stopQr" iconOnly @click="stopQr"><i class="ti ti-player-play"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.startQr" iconOnly danger @click="startQr"><i class="ti ti-player-pause"></i></MkButton> + + <MkButton v-tooltip="i18n.ts._qr.chooseCamera" iconOnly @click="chooseCamera"><i class="ti ti-camera-rotate"></i></MkButton> + + <MkButton v-if="!flashCanToggle" v-tooltip="i18n.ts._qr.cannotToggleFlash" iconOnly disabled><i class="ti ti-bolt"></i></MkButton> + <MkButton v-else-if="!flash" v-tooltip="i18n.ts._qr.turnOnFlash" iconOnly @click="toggleFlash(true)"><i class="ti ti-bolt-off"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.turnOffFlash" iconOnly @click="toggleFlash(false)"><i class="ti ti-bolt-filled"></i></MkButton> + </div> + </div> + </template> + <div + :class="['_spacer', $style.contents]" + :style="{ + '--MI_SPACER-w': '800px' + }" + > + <MkStickyContainer> + <template #header> + <MkTab + v-model="tab" + :tabs="[ + { key: 'users', label: i18n.ts.users }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + ]" + :class="$style.tab" + > + </MkTab> + </template> + <div v-if="tab === 'users'" :class="[$style.users, '_margin']" style="padding-bottom: var(--MI-margin);"> + <MkUserInfo v-for="user in users" :key="user.id" :user="user"/> + </div> + <div v-else-if="tab === 'notes'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkNote v-for="note in notes" :key="note.id" :note="note" :class="$style.note"/> + </div> + <div v-else-if="tab === 'all'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkQrReadRawViewer v-for="result in Array.from(results).reverse()" :key="result" :data="result"/> + </div> + </MkStickyContainer> + </div> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import QrScanner from 'qr-scanner'; +import { onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import type { ApShowResponse } from 'misskey-js/entities.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkNote from '@/components/MkNote.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkQrReadRawViewer from '@/pages/qr.read.raw-viewer.vue'; + +const LIST_RERENDER_INTERVAL = 1500; + +const rootEl = useTemplateRef('rootEl'); +const videoEl = useTemplateRef('videoEl'); +const overlayEl = useTemplateRef('overlayEl'); + +const scannerInstance = shallowRef<QrScanner | null>(null); + +const tab = ref<'users' | 'notes' | 'all'>('users'); + +// higher is recent +const results = ref(new Set<string>()); +// lower is recent +const uris = ref<string[]>([]); +const sources = new Map<string, ApShowResponse | null>(); +const users = ref<(misskey.entities.UserDetailed)[]>([]); +const usersCount = ref(0); +const notes = ref<misskey.entities.Note[]>([]); +const notesCount = ref(0); + +const timer = ref<number | null>(null); + +function updateLists() { + const responses = uris.value.map(uri => sources.get(uri)).filter((r): r is ApShowResponse => !!r); + users.value = responses.filter(r => r.type === 'User').map(r => r.object).filter((u): u is misskey.entities.UserDetailed => !!u); + usersCount.value = users.value.length; + notes.value = responses.filter(r => r.type === 'Note').map(r => r.object).filter((n): n is misskey.entities.Note => !!n); + notesCount.value = notes.value.length; + updateRequired.value = false; +} + +const updateRequired = ref(false); + +watch(uris, () => { + if (timer.value) { + updateRequired.value = true; + return; + } + + updateLists(); + + timer.value = window.setTimeout(() => { + timer.value = null; + if (updateRequired.value) { + updateLists(); + } + }, LIST_RERENDER_INTERVAL) as number; +}); + +watch(tab, () => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + updateLists(); +}); + +async function processResult(result: QrScanner.ScanResult) { + if (!result) return; + const trimmed = result.data.trim(); + + if (!trimmed) return; + + const haveExisted = results.value.has(trimmed); + results.value.add(trimmed); + + try { + new URL(trimmed); + } catch { + if (!haveExisted) { + tab.value = 'all'; + } + return; + } + + if (uris.value[0] !== trimmed) { + // 並べ替え + uris.value = [trimmed, ...uris.value.slice(0, 29).filter(u => u !== trimmed)]; + } + + if (sources.has(trimmed)) return; + // Start fetching user info + sources.set(trimmed, null); + + await misskeyApi('ap/show', { uri: trimmed }) + .then(data => { + if (data.type === 'User') { + sources.set(trimmed, data); + tab.value = 'users'; + } else if (data.type === 'Note') { + sources.set(trimmed, data); + tab.value = 'notes'; + } + updateLists(); + }) + .catch(err => { + tab.value = 'all'; + throw err; + }); +} + +const qrStarted = ref(true); +const flashCanToggle = ref(false); +const flash = ref(false); + +async function upload() { + os.chooseFileFromPc({ multiple: true }).then(files => { + if (files.length === 0) return; + for (const file of files) { + QrScanner.scanImage(file, { returnDetailedScanResult: true }) + .then(result => { + processResult(result); + }) + .catch(err => { + if (err.toString().includes('No QR code found')) { + os.alert({ + type: 'info', + text: i18n.ts._qr.noQrCodeFound, + }); + } else { + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + } + }); + } + }); +} + +async function chooseCamera() { + if (!scannerInstance.value) return; + const cameras = await QrScanner.listCameras(true); + if (cameras.length === 0) { + os.alert({ + type: 'error', + }); + return; + } + + const select = await os.select({ + title: i18n.ts._qr.chooseCamera, + items: cameras.map(camera => ({ + label: camera.label, + value: camera.id, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + await scannerInstance.value.setCamera(select.result); + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); +} + +async function toggleFlash(to = false) { + if (!scannerInstance.value) return; + + flash.value = to; + if (flash.value) { + await scannerInstance.value.turnFlashOn(); + } else { + await scannerInstance.value.turnFlashOff(); + } +} + +async function startQr() { + if (!scannerInstance.value) return; + await scannerInstance.value.start(); + qrStarted.value = true; +} + +function stopQr() { + if (!scannerInstance.value) return; + scannerInstance.value.stop(); + qrStarted.value = false; +} + +onActivated(() => { + startQr; +}); + +onDeactivated(() => { + stopQr; +}); + +const alertLock = ref(false); + +onMounted(() => { + if (!videoEl.value || !overlayEl.value) { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + return; + } + + scannerInstance.value = new QrScanner( + videoEl.value, + processResult, + { + highlightScanRegion: true, + highlightCodeOutline: true, + overlay: overlayEl.value, + calculateScanRegion(video: HTMLVideoElement): QrScanner.ScanRegion { + const aspectRatio = video.videoWidth / video.videoHeight; + const SHORT_SIDE_SIZE_DOWNSCALED = 360; + return { + x: 0, + y: 0, + width: video.videoWidth, + height: video.videoHeight, + downScaledWidth: aspectRatio > 1 ? Math.round(SHORT_SIDE_SIZE_DOWNSCALED * aspectRatio) : SHORT_SIDE_SIZE_DOWNSCALED, + downScaledHeight: aspectRatio > 1 ? SHORT_SIDE_SIZE_DOWNSCALED : Math.round(SHORT_SIDE_SIZE_DOWNSCALED / aspectRatio), + }; + }, + onDecodeError(err) { + if (err.toString().includes('No QR code found')) return; + if (alertLock.value) return; + alertLock.value = true; + os.alert({ + type: 'error', + text: err.toString(), + }).finally(() => { + alertLock.value = false; + }); + }, + }, + ); + + scannerInstance.value.start() + .then(async () => { + qrStarted.value = true; + if (!scannerInstance.value) return; + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); + }) + .catch(err => { + qrStarted.value = false; + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + }); +}); + +onUnmounted(() => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + + scannerInstance.value?.destroy(); +}); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.view { + position: sticky; + top: var(--MI-stickyTop, 0); + z-index: 1; + background: var(--MI_THEME-bg); + background-size: 16px 16px; + width: 100%; + height: var(--MI-QrReadVideoHeight); +} + +.video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.controls { + width: 100%; + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + justify-content: end; + align-items: center; + gap: 10px; +} + +html[data-color-scheme=dark] .view { + --c: rgb(255 255 255 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +html[data-color-scheme=light] .view { + --c: rgb(0 0 0 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +.contents { + padding-top: calc(var(--MI-margin) / 2); +} + +.tab { + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--MI-margin); +} + +.note { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); +} +</style> diff --git a/packages/frontend/src/pages/qr.show.vue b/packages/frontend/src/pages/qr.show.vue new file mode 100644 index 0000000000..28f80e0963 --- /dev/null +++ b/packages/frontend/src/pages/qr.show.vue @@ -0,0 +1,234 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <div :class="[$style.content]"> + <div + ref="qrCodeEl" v-flip :style="{ + 'cursor': canShare ? 'pointer' : 'default', + }" + :class="$style.qr" @click="share" + ></div> + <div v-flip :class="$style.user"> + <MkAvatar :class="$style.avatar" :user="$i" :indicator="false"/> + <div> + <div :class="$style.name"><MkCondensedLine :minScale="2 / 3"><MkUserName :user="$i" :nowrap="true"/></MkCondensedLine></div> + <div><MkCondensedLine :minScale="2 / 3">{{ acct }}</MkCondensedLine></div> + </div> + </div> + <img v-if="deviceMotionPermissionNeeded" v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo" @click="requestDeviceMotion"/> + <img v-else v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import tinycolor from 'tinycolor2'; +import QRCodeStyling from 'qr-code-styling'; +import { computed, ref, shallowRef, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'; +import { url, host } from '@@/js/config.js'; +import type { Directive } from 'vue'; +import { instance } from '@/instance.js'; +import { ensureSignin } from '@/i.js'; +import { userPage, userName } from '@/filters/user.js'; +import misskeysvg from '/client-assets/misskey.svg'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { i18n } from '@/i18n.js'; + +const $i = ensureSignin(); + +const acct = computed(() => `@${$i.username}@${host}`); +const userProfileUrl = computed(() => userPage($i, undefined, true)); +const shareData = computed(() => ({ + title: i18n.tsx._qr.shareTitle({ name: userName($i), acct: acct.value }), + text: i18n.ts._qr.shareText, + url: userProfileUrl.value, +})); +const canShare = computed(() => navigator.canShare && navigator.canShare(shareData.value)); + +const qrCodeEl = useTemplateRef('qrCodeEl'); + +const qrColor = computed(() => tinycolor(instance.themeColor ?? '#86b300')); +const qrHsl = computed(() => qrColor.value.toHsl()); + +function share() { + if (!canShare.value) return; + return navigator.share(shareData.value); +} + +const qrCodeInstance = new QRCodeStyling({ + width: 600, + height: 600, + margin: 42, + type: 'canvas', + data: `${url}/users/${$i.id}`, + image: instance.iconUrl ? getStaticImageUrl(instance.iconUrl) : '/favicon.ico', + qrOptions: { + typeNumber: 0, + mode: 'Byte', + errorCorrectionLevel: 'H', + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.3, + margin: 16, + crossOrigin: 'anonymous', + }, + dotsOptions: { + type: 'dots', + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 18)`).toRgbString(), + }, + cornersDotOptions: { + type: 'dot', + }, + cornersSquareOptions: { + type: 'extra-rounded', + }, + backgroundOptions: { + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 97)`).toRgbString(), + }, +}); + +onMounted(() => { + if (qrCodeEl.value != null) { + qrCodeInstance.append(qrCodeEl.value); + } +}); + +//#region flip +const THRESHOLD = -3; +// @ts-expect-error TS(2339) +const deviceMotionPermissionNeeded = window.DeviceMotionEvent && typeof window.DeviceMotionEvent.requestPermission === 'function'; +const flipEls: Set<Element> = new Set(); +const flip = ref(false); + +function handleOrientationChange(event: DeviceOrientationEvent) { + const isUpsideDown = event.beta ? event.beta < THRESHOLD : false; + flip.value = isUpsideDown; +} + +watch(flip, (newState) => { + flipEls.forEach(el => { + el.classList.toggle('_qrShowFlipFliped', newState); + }); +}); + +function requestDeviceMotion() { + if (!deviceMotionPermissionNeeded) return; + // @ts-expect-error TS(2339) + window.DeviceMotionEvent.requestPermission() + .then((response: string) => { + if (response === 'granted') { + window.addEventListener('deviceorientation', handleOrientationChange); + } + }) + .catch(console.error); +} + +onMounted(() => { + window.addEventListener('deviceorientation', handleOrientationChange); +}); + +onUnmounted(() => { + window.removeEventListener('deviceorientation', handleOrientationChange); +}); + +const vFlip = { + mounted(el: Element) { + flipEls.add(el); + el.classList.add('_qrShowFlip'); + }, + unmounted(el: Element) { + el.classList.remove('_qrShowFlip'); + flipEls.delete(el); + }, +} as Directive; +//#endregion +</script> + +<style lang="scss" module> +$s1: 14px; +$s2: 21px; +$s3: 28px; +$avatarSize: 58px; + +.root { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; +} + +.qr { + position: relative; + margin: 0 auto; + width: 100%; + max-width: 230px; + border-radius: 12px; + overflow: clip; + aspect-ratio: 1; + + > svg, + > canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.user { + display: flex; + flex-direction: column; + margin: $s3 auto; + justify-content: center; + align-items: center; + text-align: center; + overflow: visible; + width: fit-content; + max-width: 100%; +} + +.avatar { + width: $avatarSize; + height: $avatarSize; + margin-bottom: 16px; +} + +.name { + font-weight: bold; + font-size: 110%; +} + +.logo { + width: 100px; + margin: $s3 auto 0; + filter: drop-shadow(0 0 6px #0007); +} +</style> + +<style lang="scss"> +/* + * useCssModuleで$styleを読み込みたかったが、rollupでのunwindが壊れてしまうらしく失敗。 + * グローバルにクラスを定義することでお茶を濁す。 + */ +._qrShowFlip { + transition: rotate .3s linear, scale .3s .15s step-start; +} + +._qrShowFlipFliped { + scale: -1 1; + rotate: x 180deg; +} +</style> diff --git a/packages/frontend/src/pages/qr.vue b/packages/frontend/src/pages/qr.vue new file mode 100644 index 0000000000..2e5629f232 --- /dev/null +++ b/packages/frontend/src/pages/qr.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_pageScrollable"> + <div class="_spacer" :class="$style.main"> + <MkButton v-if="read" :class="$style.button" rounded @click="read = false"><i class="ti ti-qrcode"></i> {{ i18n.ts._qr.showTabTitle }}</MkButton> + <MkButton v-else :class="$style.button" rounded @click="read = true"><i class="ti ti-scan"></i> {{ i18n.ts._qr.readTabTitle }}</MkButton> + + <MkQrRead v-if="read"/> + <MkQrShow v-else/> + </div> + <MkPolkadots v-if="!read" accented revered :height="200" style="position: sticky; bottom: 0; margin-top: -200px;"/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref, shallowRef } from 'vue'; +import MkQrShow from './qr.show.vue'; +import { definePage } from '@/page.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i'; +import MkButton from '@/components/MkButton.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +// router definitionでloginRequiredが設定されているためエラーハンドリングしない +const $i = ensureSignin(); + +const read = ref(false); + +const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue')); + +definePage(() => ({ + title: i18n.ts.qr, + icon: 'ti ti-qrcode', +})); +</script> + +<style lang="scss" module> +.root { + height: 100%; +} + +.main { + min-height: 100%; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +.button { + margin: 0 auto 16px auto; +} +</style> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index 8eb2ab9fd0..a352fe4c00 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <FormSection v-if="keys"> - <template #label>{{ i18n.ts.keys }}</template> + <template #label>{{ i18n.ts._registry.keys }}</template> <div class="_gaps_s"> <FormLink v-for="key in keys" :to="`/registry/value/${props.domain}/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> </div> diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 69429728d0..aae638641a 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -164,7 +164,7 @@ const $i = ensureSignin(); const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; - connection?: Misskey.ChannelConnection<Misskey.Channels['reversiGame']> | null; + connection?: Misskey.IChannelConnection<Misskey.Channels['reversiGame']> | null; }>(); const showBoardLabels = ref<boolean>(false); diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 8392384963..1e01496bbb 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -132,7 +132,7 @@ const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x. const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; - connection: Misskey.ChannelConnection<Misskey.Channels['reversiGame']>; + connection: Misskey.IChannelConnection<Misskey.Channels['reversiGame']>; }>(); const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false }); diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index a447572cc0..b1ba4da247 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -33,7 +33,7 @@ const props = defineProps<{ }>(); const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null); -const connection = shallowRef<Misskey.ChannelConnection | null>(null); +const connection = shallowRef<Misskey.IChannelConnection<Misskey.Channels['reversiGame']> | null>(null); const shareWhenStart = ref(false); watch(() => props.gameId, () => { diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index ca404b43c4..2cc13744b1 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -196,6 +196,7 @@ async function addSecurityKey() { if (auth.canceled) return; const registrationOptions = parseCreationOptionsFromJSON({ + // @ts-expect-error misskey-js側に型がない publicKey: await os.apiWithDialog('i/2fa/register-key', { password: auth.result.password, token: auth.result.token, @@ -226,6 +227,7 @@ async function addSecurityKey() { password: auth.result.password, token: auth.result.token, name: name.result, + // @ts-expect-error misskey-js側に型がない credential: credential.toJSON(), }); } diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 63b3c95233..57192c0fb7 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> - <MkSelect v-model="sortModeSelect"> + <MkSelect v-model="sortModeSelect" :items="sortModeSelectDef"> <template #label>{{ i18n.ts.sort }}</template> - <option v-for="x in sortOptions" :key="x.value" :value="x.value">{{ x.displayName }}</option> </MkSelect> <div v-if="!fetching"> <MkPagination v-slot="{items}" :paginator="paginator"> @@ -60,6 +59,7 @@ import { i18n } from '@/i18n.js'; import bytes from '@/filters/bytes.js'; import { definePage } from '@/page.js'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; @@ -69,15 +69,19 @@ const paginator = markRaw(new Paginator('drive/files', { computedParams: computed(() => ({ sort: sortMode.value })), })); -const sortOptions = [ - { value: 'sizeDesc', displayName: i18n.ts._drivecleaner.orderBySizeDesc }, - { value: 'createdAtAsc', displayName: i18n.ts._drivecleaner.orderByCreatedAtAsc }, -]; - const capacity = ref<number>(0); const usage = ref<number>(0); const fetching = ref(true); -const sortModeSelect = ref('sizeDesc'); +const { + model: sortModeSelect, + def: sortModeSelectDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._drivecleaner.orderBySizeDesc, value: 'sizeDesc' }, + { label: i18n.ts._drivecleaner.orderByCreatedAtAsc, value: 'createdAtAsc' }, + ], + initialValue: 'sizeDesc', +}); fetchDriveInfo(); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index cfa4df18fc..f58ff4c78c 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink @click="chooseUploadFolder()"> <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> + <template #icon><i class="ti ti-folder"></i></template> </FormLink> </SearchMarker> @@ -129,13 +129,37 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="defaultImageCompressionLevel" :items="[ { label: i18n.ts.none, value: 0 }, - { label: i18n.ts.low, value: 1 }, - { label: i18n.ts.medium, value: 2 }, - { label: i18n.ts.high, value: 3 }, + { label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 }, + { label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 }, + { label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 }, ]" > - <template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template> - <template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template> + <template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> + + <SearchMarker :keywords="['video']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.video }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['default', 'video', 'compression']"> + <MkPreferenceContainer k="defaultVideoCompressionLevel"> + <MkSelect + v-model="defaultVideoCompressionLevel" :items="[ + { label: i18n.ts.none, value: 0 }, + { label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 }, + { label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 }, + { label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 }, + ]" + > + <template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -196,6 +220,7 @@ const meterStyle = computed(() => { const keepOriginalFilename = prefer.model('keepOriginalFilename'); const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId'); const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel'); +const defaultVideoCompressionLevel = prefer.model('defaultVideoCompressionLevel'); const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets')); diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue index 5ff5f45a2f..9c70461847 100644 --- a/packages/frontend/src/pages/settings/emoji-palette.vue +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -36,20 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <SearchMarker :keywords="['main', 'palette']"> <MkPreferenceContainer k="emojiPaletteForMain"> - <MkSelect v-model="emojiPaletteForMain"> + <MkSelect v-model="emojiPaletteForMain" :items="emojiPaletteForMainDef"> <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template> - <option key="-" :value="null">({{ i18n.ts.auto }})</option> - <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['reaction', 'palette']"> <MkPreferenceContainer k="emojiPaletteForReaction"> - <MkSelect v-model="emojiPaletteForReaction"> + <MkSelect v-model="emojiPaletteForReaction" :items="emojiPaletteForReactionDef"> <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template> - <option key="-" :value="null">({{ i18n.ts.auto }})</option> - <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -68,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only <option :value="1">{{ i18n.ts.small }}</option> <option :value="2">{{ i18n.ts.medium }}</option> <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + <option :value="5">{{ i18n.ts.large }}++</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -99,12 +97,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['emoji', 'picker', 'style']"> <MkPreferenceContainer k="emojiPickerStyle"> - <MkSelect v-model="emojiPickerStyle"> + <MkSelect + v-model="emojiPickerStyle" :items="[ + { label: i18n.ts.auto, value: 'auto' }, + { label: i18n.ts.popup, value: 'popup' }, + { label: i18n.ts.drawer, value: 'drawer' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template> <template #caption>{{ i18n.ts.needReloadToApply }}</template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -119,8 +120,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch } from 'vue'; -import { genId } from '@/utility/id.js'; import XPalette from './emoji-palette.palette.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import { genId } from '@/utility/id.js'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; @@ -135,7 +137,21 @@ import MkSwitch from '@/components/MkSwitch.vue'; import { emojiPicker } from '@/utility/emoji-picker.js'; const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction'); +const emojiPaletteForReactionDef = computed<MkSelectItem[]>(() => [ + { label: `(${i18n.ts.auto})`, value: null }, + ...prefer.s.emojiPalettes.map(palette => ({ + label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name, + value: palette.id, + })), +]); const emojiPaletteForMain = prefer.model('emojiPaletteForMain'); +const emojiPaletteForMainDef = computed<MkSelectItem[]>(() => [ + { label: `(${i18n.ts.auto})`, value: null }, + ...prefer.s.emojiPalettes.map(palette => ({ + label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name, + value: palette.id, + })), +]); const emojiPickerScale = prefer.model('emojiPickerScale'); const emojiPickerWidth = prefer.model('emojiPickerWidth'); const emojiPickerHeight = prefer.model('emojiPickerHeight'); diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index f7c634b42e..c8cbc0977f 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -86,9 +86,9 @@ async function addItem() { const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ - value: k, text: navbarItemDef[k].title, + value: k, label: navbarItemDef[k].title, })), { - value: '-', text: i18n.ts.divider, + value: '-', label: i18n.ts.divider, }], }); if (canceled || item == null) return; diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue index 0ea415f673..78c3312c27 100644 --- a/packages/frontend/src/pages/settings/notifications.notification-config.vue +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -5,13 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="type"> - <option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option> + <MkSelect v-model="type" :items="typeDef"> </MkSelect> - <MkSelect v-if="type === 'list'" v-model="userListId"> + <MkSelect v-if="type === 'list'" v-model="userListId" :items="userListIdDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in props.userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <div class="_buttons"> @@ -41,9 +39,10 @@ export type NotificationConfig = { <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -66,8 +65,26 @@ const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[numb never: i18n.ts.none, }; -const type = ref(props.value.type); -const userListId = ref(props.value.type === 'list' ? props.value.userListId : null); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: computed(() => (props.configurableTypes ?? notificationConfigTypes).map((t: NotificationConfig['type']) => ({ + label: notificationConfigTypesI18nMap[t], + value: t, + }))), + initialValue: props.value.type, +}); +const { + model: userListId, + def: userListIdDef, +} = useMkSelect({ + items: computed(() => props.userLists.map(list => ({ + label: list.name, + value: list.id, + }))), + initialValue: props.value.type === 'list' ? props.value.userListId : null, +}); function save() { emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value }); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 64d61c0bee..2802d3263e 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -85,7 +85,7 @@ const $i = ensureSignin(); const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; -const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken'] satisfies (typeof notificationTypes[number])[] as string[]; +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'] satisfies (typeof notificationTypes[number])[] as string[]; const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 41b799bead..c4c76884e4 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -102,6 +102,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="enableHapticFeedback"> <template #label>Enable haptic feedback</template> </MkSwitch> + <MkSwitch v-model="enableWebTranslatorApi"> + <template #label>Enable in-browser translator API</template> + </MkSwitch> </div> </MkFolder> </SearchMarker> @@ -182,6 +185,7 @@ const devMode = prefer.model('devMode'); const stackingRouterView = prefer.model('experimental.stackingRouterView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback'); +const enableWebTranslatorApi = prefer.model('experimental.enableWebTranslatorApi'); watch(skipNoteRender, () => { suggestReload(); diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index ba35dd7f43..c622647b4f 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -18,9 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <SearchMarker :keywords="['language']"> - <MkSelect v-model="lang"> + <MkSelect v-model="lang" :items="langs.map(x => ({ label: x[1], value: x[0] }))"> <template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template> - <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> <template #caption> <I18n :src="i18n.ts.i18nInfo" tag="span"> <template #link> @@ -272,22 +271,31 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> <MkPreferenceContainer k="instanceTicker"> - <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> + <MkSelect + v-if="instance.federation !== 'none'" + v-model="instanceTicker" + :items="[ + { label: i18n.ts._instanceTicker.none, value: 'none' }, + { label: i18n.ts._instanceTicker.remote, value: 'remote' }, + { label: i18n.ts._instanceTicker.always, value: 'always' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template> - <option value="none">{{ i18n.ts._instanceTicker.none }}</option> - <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> - <option value="always">{{ i18n.ts._instanceTicker.always }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']"> <MkPreferenceContainer k="nsfw"> - <MkSelect v-model="nsfw"> + <MkSelect + v-model="nsfw" + :items="[ + { label: i18n.ts._displayOfSensitiveMedia.respect, value: 'respect' }, + { label: i18n.ts._displayOfSensitiveMedia.ignore, value: 'ignore' }, + { label: i18n.ts._displayOfSensitiveMedia.force, value: 'force' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template> - <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> - <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> - <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -339,11 +347,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <MkPreferenceContainer k="defaultNoteVisibility"> - <MkSelect v-model="defaultNoteVisibility"> - <option value="public">{{ i18n.ts._visibility.public }}</option> - <option value="home">{{ i18n.ts._visibility.home }}</option> - <option value="followers">{{ i18n.ts._visibility.followers }}</option> - <option value="specified">{{ i18n.ts._visibility.specified }}</option> + <MkSelect + v-model="defaultNoteVisibility" + :items="[ + { label: i18n.ts._visibility.public, value: 'public' }, + { label: i18n.ts._visibility.home, value: 'home' }, + { label: i18n.ts._visibility.followers, value: 'followers' }, + { label: i18n.ts._visibility.specified, value: 'specified' }, + ]" + > </MkSelect> </MkPreferenceContainer> @@ -402,7 +414,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="$i.policies.chatAvailability !== 'unavailable'"> <SearchMarker v-slot="slotProps" :keywords="['chat', 'messaging']"> <MkFolder :defaultOpen="slotProps.isParentOfTarget"> - <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template> <template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template> <div class="_gaps_s"> @@ -528,22 +540,30 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']"> <MkPreferenceContainer k="menuStyle"> - <MkSelect v-model="menuStyle"> + <MkSelect + v-model="menuStyle" + :items="[ + { label: i18n.ts.auto, value: 'auto' }, + { label: i18n.ts.popup, value: 'popup' }, + { label: i18n.ts.drawer, value: 'drawer' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['contextmenu', 'system', 'native']"> <MkPreferenceContainer k="contextMenu"> - <MkSelect v-model="contextMenu"> + <MkSelect + v-model="contextMenu" + :items="[ + { label: i18n.ts._contextMenu.app, value: 'app' }, + { label: i18n.ts._contextMenu.appWithShift, value: 'appWithShift' }, + { label: i18n.ts._contextMenu.native, value: 'native' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template> - <option value="app">{{ i18n.ts._contextMenu.app }}</option> - <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> - <option value="native">{{ i18n.ts._contextMenu.native }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -719,11 +739,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']"> <MkPreferenceContainer k="serverDisconnectedBehavior"> - <MkSelect v-model="serverDisconnectedBehavior"> + <MkSelect + v-model="serverDisconnectedBehavior" + :items="[ + { label: i18n.ts._serverDisconnectedBehavior.reload, value: 'reload' }, + { label: i18n.ts._serverDisconnectedBehavior.dialog, value: 'dialog' }, + { label: i18n.ts._serverDisconnectedBehavior.quiet, value: 'quiet' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template> - <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> - <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> - <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -924,6 +948,7 @@ watch([ chatShowSenderName, useStickyIcons, enableHighQualityImagePlaceholders, + disableShowingAnimatedImages, keepScreenOn, contextMenu, fontSize, @@ -934,6 +959,8 @@ watch([ enablePullToRefresh, reduceAnimation, showAvailableReactionsFirstInNote, + animatedMfm, + advancedMfm, ], () => { suggestReload(); }); @@ -984,16 +1011,15 @@ function removeEmojiIndex(lang: string) { async function setPinnedList() { const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select({ + const { canceled, result: listId } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), }); - if (canceled) return; - if (list == null) return; + if (canceled || listId == null) return; - prefer.commit('pinnedUserLists', [list]); + prefer.commit('pinnedUserLists', [lists.find((x) => x.id === listId)!]); } function removePinnedList() { diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 54a6c0af82..c2e0b3fe41 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -33,20 +33,14 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['following', 'visibility']"> - <MkSelect v-model="followingVisibility" @update:modelValue="save()"> + <MkSelect v-model="followingVisibility" :items="followingVisibilityDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.followingVisibility }}</SearchLabel></template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> </MkSelect> </SearchMarker> <SearchMarker :keywords="['follower', 'visibility']"> - <MkSelect v-model="followersVisibility" @update:modelValue="save()"> + <MkSelect v-model="followersVisibility" :items="followersVisibilityDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.followersVisibility }}</SearchLabel></template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> </MkSelect> </SearchMarker> @@ -80,18 +74,13 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['chat']"> <FormSection> - <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template> <div class="_gaps_m"> <MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> <SearchMarker :keywords="['chat']"> - <MkSelect v-model="chatScope" @update:modelValue="save()"> + <MkSelect v-model="chatScope" :items="chatScopeDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> - <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> - <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> - <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> - <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> - <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> </MkSelect> </SearchMarker> @@ -119,15 +108,24 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</SearchLabel></template> <div class="_gaps_s"> - <MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> - <option :value="null">{{ i18n.ts.none }}</option> - <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> - <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + <MkSelect + v-model="makeNotesFollowersOnlyBefore_type" + :items="[ + { label: i18n.ts.none, value: null }, + { label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' }, + { label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' }, + ]" + > </MkSelect> - <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore_selection"> - <option v-for="preset in makeNotesFollowersOnlyBefore_presets" :value="preset.value">{{ preset.label }}</option> - <option value="custom">{{ i18n.ts.custom }}</option> + <MkSelect + v-if="makeNotesFollowersOnlyBefore_type === 'relative'" + v-model="makeNotesFollowersOnlyBefore_selection" + :items="[ + ...makeNotesFollowersOnlyBefore_presets, + { label: i18n.ts.custom, value: 'custom' }, + ]" + > </MkSelect> <MkInput @@ -140,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput - v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" + v-if="makeNotesFollowersOnlyBefore_type === 'absolute' && makeNotesFollowersOnlyBefore != null" :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" type="date" :manualSave="true" @@ -161,22 +159,23 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <MkSelect - :items="[{ - value: null, - label: i18n.ts.none - }, { - value: 'relative', - label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod - }, { - value: 'absolute', - label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime - }] as const" :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null" + v-model="makeNotesHiddenBefore_type" + :items="[ + { label: i18n.ts.none, value: null }, + { label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' }, + { label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' }, + ]" > </MkSelect> - <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore_selection"> - <option v-for="preset in makeNotesHiddenBefore_presets" :value="preset.value">{{ preset.label }}</option> - <option value="custom">{{ i18n.ts.custom }}</option> + <MkSelect + v-if="makeNotesHiddenBefore_type === 'relative'" + v-model="makeNotesHiddenBefore_selection" + :items="[ + ...makeNotesHiddenBefore_presets, + { label: i18n.ts.custom, value: 'custom' }, + ]" + > </MkSelect> <MkInput @@ -189,7 +188,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput - v-if="makeNotesHiddenBefore_type === 'absolute'" + v-if="makeNotesHiddenBefore_type === 'absolute' && makeNotesHiddenBefore != null" :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" type="date" :manualSave="true" @@ -216,8 +215,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; -import MkFolder from '@/components/MkFolder.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; @@ -225,6 +224,7 @@ import { ensureSignin } from '@/i.js'; import { definePage } from '@/page.js'; import FormSlot from '@/components/form/slot.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; import MkDisableSection from '@/components/MkDisableSection.vue'; @@ -243,18 +243,61 @@ const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null); const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); -const followingVisibility = ref($i.followingVisibility); -const followersVisibility = ref($i.followersVisibility); -const chatScope = ref($i.chatScope); +const { + model: followingVisibility, + def: followingVisibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.followers, value: 'followers' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: $i.followingVisibility, +}); +const { + model: followersVisibility, + def: followersVisibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.followers, value: 'followers' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: $i.followersVisibility, +}); +const { + model: chatScope, + def: chatScopeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._chat._chatAllowedUsers.everyone, value: 'everyone' }, + { label: i18n.ts._chat._chatAllowedUsers.followers, value: 'followers' }, + { label: i18n.ts._chat._chatAllowedUsers.following, value: 'following' }, + { label: i18n.ts._chat._chatAllowedUsers.mutual, value: 'mutual' }, + { label: i18n.ts._chat._chatAllowedUsers.none, value: 'none' }, + ], + initialValue: $i.chatScope, +}); -const makeNotesFollowersOnlyBefore_type = computed(() => { - if (makeNotesFollowersOnlyBefore.value == null) { - return null; - } else if (makeNotesFollowersOnlyBefore.value >= 0) { - return 'absolute'; - } else { - return 'relative'; - } +const makeNotesFollowersOnlyBefore_type = computed({ + get: () => { + if (makeNotesFollowersOnlyBefore.value == null) { + return null; + } else if (makeNotesFollowersOnlyBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } + }, + set(value) { + if (value === 'relative') { + makeNotesFollowersOnlyBefore.value = -604800; + } else if (value === 'absolute') { + makeNotesFollowersOnlyBefore.value = Math.floor(Date.now() / 1000); + } else { + makeNotesFollowersOnlyBefore.value = null; + } + }, }); const makeNotesFollowersOnlyBefore_presets = [ @@ -265,7 +308,7 @@ const makeNotesFollowersOnlyBefore_presets = [ { label: i18n.ts.oneMonth, value: -2592000 }, { label: i18n.ts.threeMonths, value: -7776000 }, { label: i18n.ts.oneYear, value: -31104000 }, -]; +] satisfies MkSelectItem[]; const makeNotesFollowersOnlyBefore_isCustomMode = ref( makeNotesFollowersOnlyBefore.value != null && @@ -288,14 +331,25 @@ const makeNotesFollowersOnlyBefore_customMonths = computed({ }, }); -const makeNotesHiddenBefore_type = computed(() => { - if (makeNotesHiddenBefore.value == null) { - return null; - } else if (makeNotesHiddenBefore.value >= 0) { - return 'absolute'; - } else { - return 'relative'; - } +const makeNotesHiddenBefore_type = computed({ + get: () => { + if (makeNotesHiddenBefore.value == null) { + return null; + } else if (makeNotesHiddenBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } + }, + set(value) { + if (value === 'relative') { + makeNotesHiddenBefore.value = -604800; + } else if (value === 'absolute') { + makeNotesHiddenBefore.value = Math.floor(Date.now() / 1000); + } else { + makeNotesHiddenBefore.value = null; + } + }, }); const makeNotesHiddenBefore_presets = [ @@ -306,7 +360,7 @@ const makeNotesHiddenBefore_presets = [ { label: i18n.ts.oneMonth, value: -2592000 }, { label: i18n.ts.threeMonths, value: -7776000 }, { label: i18n.ts.oneYear, value: -31104000 }, -]; +] satisfies MkSelectItem[]; const makeNotesHiddenBefore_isCustomMode = ref( makeNotesHiddenBefore.value != null && diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 4816a6e33b..89325dee63 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -53,9 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['language', 'locale']"> - <MkSelect v-model="profile.lang"> + <MkSelect v-model="profile.lang" :items="Object.entries(langmap).map(([code, def]) => ({ label: def.nativeName, value: code }))"> <template #label><SearchLabel>{{ i18n.ts.language }}</SearchLabel></template> - <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> </MkSelect> </SearchMarker> @@ -117,13 +116,17 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['reaction']"> - <MkSelect v-model="reactionAcceptance"> + <MkSelect + v-model="reactionAcceptance" + :items="[ + { label: i18n.ts.all, value: null }, + { label: i18n.ts.likeOnlyForRemote, value: 'likeOnlyForRemote' }, + { label: i18n.ts.nonSensitiveOnly, value: 'nonSensitiveOnly' }, + { label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote, value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' }, + { label: i18n.ts.likeOnly, value: 'likeOnly' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.reactionAcceptance }}</SearchLabel></template> - <option :value="null">{{ i18n.ts.all }}</option> - <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> - <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option> - <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option> - <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> </MkSelect> </SearchMarker> @@ -148,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> </SearchMarker> + + <hr> + + <SearchMarker :keywords="['qrcode']"> + <FormLink to="/qr"> + <template #icon><i class="ti ti-qrcode"></i></template> + <SearchLabel>{{ i18n.ts.qr }}</SearchLabel> + </FormLink> + </SearchMarker> </div> </SearchMarker> </template> @@ -161,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; +import FormLink from '@/components/form/link.vue'; import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 7aad43b1d0..31fe9a64db 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="type"> + <MkSelect v-model="type" :items="typeDef"> <template #label>{{ i18n.ts.sound }}</template> - <option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option> </MkSelect> <div v-if="type === '_driveFile_' && driveFileError === true" :class="$style.fileSelectorRoot"> <MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton> @@ -38,28 +37,36 @@ import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; import { selectFile } from '@/utility/drive.js'; +import type { SoundStore } from '@/preferences/def.js'; const props = defineProps<{ - type: SoundType; - fileId?: string; - fileUrl?: string; - volume: number; + def: SoundStore; }>(); const emit = defineEmits<{ (ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void; }>(); -const type = ref<SoundType>(props.type); -const fileId = ref(props.fileId); -const fileUrl = ref(props.fileUrl); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: soundsTypes.map((x) => ({ + label: getSoundTypeName(x), + value: x, + })), + initialValue: props.def.type, +}); +const fileId = ref('fileId' in props.def ? props.def.fileId : undefined); +const fileUrl = ref('fileUrl' in props.def ? props.def.fileUrl : undefined); const fileName = ref<string>(''); const driveFileError = ref(false); const hasChanged = ref(false); -const volume = ref(props.volume); +const volume = ref(props.def.volume); if (type.value === '_driveFile_' && fileId.value) { await misskeyApi('drive/files/show', { diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index ea5b347525..1b851825d6 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> <Suspense> <template #default> - <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> + <XSound :def="sounds[type]" @update="(res) => updated(type, res)"/> </template> <template #fallback> <MkLoading/> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 561d31148f..b69fd2596d 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -5,11 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="statusbar.type" placeholder="Please select"> + <MkSelect v-model="statusbar.type" :items="statusbarTypeDef"> <template #label>{{ i18n.ts.type }}</template> - <option value="rss">RSS</option> - <option v-if="instance.federation !== 'none'" value="federation">Federation</option> - <option value="userList">User list timeline</option> </MkSelect> <MkInput v-model="statusbar.name" manualSave> @@ -63,9 +60,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> <template v-else-if="statusbar.type === 'userList' && userLists != null"> - <MkSelect v-model="statusbar.props.userListId"> + <MkSelect v-model="statusbar.props.userListId" :items="userListsDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> </MkSelect> <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> @@ -86,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { reactive, watch } from 'vue'; +import { reactive, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; @@ -98,13 +94,32 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; import { prefer } from '@/preferences.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { StatusbarStore } from '@/preferences/def.js'; const props = defineProps<{ _id: string; userLists: Misskey.entities.UserList[] | null; }>(); -const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))!); +const statusbar = reactive<StatusbarStore>(deepClone(prefer.s.statusbars.find(x => x.id === props._id)!)); + +const statusbarTypeDef = computed(() => { + const items = [ + { label: 'RSS', value: 'rss' }, + ] satisfies MkSelectItem[]; + if (instance.federation !== 'none') { + items.push({ label: 'Federation', value: 'federation' }); + } + if (props.userLists != null) { + items.push({ label: i18n.ts.userList, value: 'userList' }); + } + return items; +}); + +const userListsDef = computed(() => { + return (props.userLists ?? []).map(x => ({ label: x.name, value: x.id })) satisfies MkSelectItem[]; +}); watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index e972184278..7bb877ec39 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -5,16 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="selectedThemeId"> + <MkSelect v-model="selectedThemeId" :items="selectedThemeIdDef"> <template #label>{{ i18n.ts.theme }}</template> - <optgroup :label="i18n.ts._theme.installedThemes"> - <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="i18n.ts._theme.builtinThemes"> - <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> </MkSelect> - <template v-if="selectedTheme"> + <template v-if="selectedTheme != null"> <MkInput readonly :modelValue="selectedTheme.author"> <template #label>{{ i18n.ts.author }}</template> </MkInput> @@ -43,10 +37,26 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); -const selectedThemeId = ref<string | null>(null); +const { + model: selectedThemeId, + def: selectedThemeIdDef, +} = useMkSelect({ + items: computed<MkSelectItem<string | null>[]>(() => [{ + type: 'group', + label: i18n.ts._theme.installedThemes, + items: installedThemes.value.map(x => ({ label: x.name, value: x.id })), + }, { + type: 'group', + label: i18n.ts._theme.builtinThemes, + items: builtinThemes.value.map(x => ({ label: x.name, value: x.id })), + }]), + initialValue: null, +}); const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index beae1224e4..0129aebe94 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -5,7 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette"> - <div class="_gaps_m"> + <div + class="_gaps_m" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" + > <div v-adaptive-border class="rfqxtzch _panel"> <div class="toggle"> <div class="toggleWrapper"> @@ -58,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="instanceLightTheme.id" /> - <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)"> + <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceLightTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)"> <MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div> </label> @@ -78,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -98,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -129,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="instanceDarkTheme.id" /> - <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)"> + <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceDarkTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)"> <MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div> </label> @@ -149,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -169,7 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -214,7 +218,7 @@ import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkThemePreview from '@/components/MkThemePreview.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js'; +import { getBuiltinThemesRef, getThemesRef, installTheme, parseThemeCode, removeTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -223,6 +227,7 @@ import { uniqueBy } from '@/utility/array.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { checkDragDataType, getDragData, getPlainDragData, setDragData, setPlainDragData } from '@/drag-and-drop.js'; const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); @@ -321,6 +326,38 @@ function onThemeContextmenu(theme: Theme, ev: MouseEvent) { }], ev); } +function onThemeDragstart(ev: DragEvent, theme: Theme) { + if (!ev.dataTransfer) return; + + ev.dataTransfer.effectAllowed = 'copy'; + setPlainDragData(ev, JSON5.stringify(theme, null, '\t')); +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + if (ev.dataTransfer.types[0] === 'text/plain') { + ev.dataTransfer.dropEffect = 'copy'; + } else { + ev.dataTransfer.dropEffect = 'none'; + } + + return false; +} + +async function onDrop(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const code = getPlainDragData(ev); + if (code != null) { + try { + await installTheme(code); + } catch (err) { + // nop + } + } +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 51ac9d66f0..368537ec91 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -112,8 +112,7 @@ async function init() { ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), ...(visibleAccts ? visibleAccts.split(',').map(Misskey.acct.parse) : []), ] - // TypeScriptの指示通りに変換する - .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + // @ts-expect-error payloadの引数側の型が正常に解決されない .map(q => misskeyApi('users/show', q) .then(user => { visibleUsers.value.push(user); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 42455bd18e..7094aca7c0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> <div :key="user.id" class="main _panel"> - <div class="banner-container" :style="style"> - <div ref="bannerEl" class="banner" :style="style"></div> + <div ref="bannerEl" class="banner-container"> + <div class="banner" :style="style"></div> <div class="fade"></div> <div class="title"> <MkUserName class="name" :user="user" :nowrap="true"/> @@ -159,9 +159,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch, ref } from 'vue'; +import { defineAsyncComponent, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { getScrollPosition } from '@@/js/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkAccountMoved from '@/components/MkAccountMoved.vue'; @@ -221,11 +221,10 @@ const emit = defineEmits<{ const router = useRouter(); const user = ref(props.user); -const parallaxAnimationId = ref<null | number>(null); const narrow = ref<null | boolean>(null); -const rootEl = ref<null | HTMLElement>(null); -const bannerEl = ref<null | HTMLElement>(null); -const memoTextareaEl = ref<null | HTMLElement>(null); +const rootEl = useTemplateRef('rootEl'); +const bannerEl = useTemplateRef('bannerEl'); +const memoTextareaEl = useTemplateRef('memoTextareaEl'); const memoDraft = ref(props.user.memo); const isEditingMemo = ref(false); const moderationNote = ref(props.user.moderationNote ?? ''); @@ -257,24 +256,6 @@ function menu(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } -function parallaxLoop() { - parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop); - parallax(); -} - -function parallax() { - const banner = bannerEl.value; - if (banner == null) return; - - const top = getScrollPosition(rootEl.value); - - if (top < 0) return; - - const z = 1.75; // 奥行き(小さいほど奥) - const pos = -(top / z); - banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; -} - function showMemoTextarea() { isEditingMemo.value = true; nextTick(() => { @@ -304,8 +285,38 @@ async function reload() { // TODO } +let bannerParallaxResizeObserver: ResizeObserver | null = null; + +function calcBannerParallax() { + if (!bannerEl.value || !CSS.supports('view-timeline-inset', 'auto 100px')) return; + const elRect = bannerEl.value.getBoundingClientRect(); + const scrollEl = getScrollContainer(bannerEl.value); + const scrollPosition = scrollEl?.scrollTop ?? window.scrollY; + const scrollContainerHeight = scrollEl?.clientHeight ?? window.innerHeight; + const scrollContainerTop = scrollEl?.getBoundingClientRect().top ?? 0; + const top = scrollPosition + elRect.top - scrollContainerTop; + const bottom = scrollContainerHeight - top; + bannerEl.value.style.setProperty('--bannerParallaxInset', `auto ${bottom}px`); +} + +function initCalcBannerParallax() { + const scrollEl = bannerEl.value ? getScrollContainer(bannerEl.value) : null; + if (scrollEl != null && CSS.supports('view-timeline-inset', 'auto 100px')) { + bannerParallaxResizeObserver = new ResizeObserver(() => { + calcBannerParallax(); + }); + bannerParallaxResizeObserver.observe(scrollEl); + } +} + +function disposeBannerParallaxResizeObserver() { + if (bannerParallaxResizeObserver) { + bannerParallaxResizeObserver.disconnect(); + bannerParallaxResizeObserver = null; + } +} + onMounted(() => { - window.requestAnimationFrame(parallaxLoop); narrow.value = rootEl.value!.clientWidth < 1000; if (props.user.birthday) { @@ -319,16 +330,24 @@ onMounted(() => { }); } } + nextTick(() => { + calcBannerParallax(); adjustMemoTextarea(); }); + + initCalcBannerParallax(); }); -onUnmounted(() => { - if (parallaxAnimationId.value) { - window.cancelAnimationFrame(parallaxAnimationId.value); +onActivated(() => { + if (bannerEl.value) { + calcBannerParallax(); + initCalcBannerParallax(); } }); + +onUnmounted(disposeBannerParallaxResizeObserver); +onDeactivated(disposeBannerParallaxResizeObserver); </script> <style lang="scss" scoped> @@ -353,14 +372,23 @@ onUnmounted(() => { overflow: clip; background-size: cover; background-position: center; + view-timeline-name: --bannerParallax; + view-timeline-inset: var(--bannerParallaxInset, auto); + view-timeline-axis: block; > .banner { - height: 100%; + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 300%; background-color: #4c5e6d; - background-size: cover; + background-repeat: repeat-y; background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; + will-change: transform; + animation: bannerParallaxKeyframes linear both; + animation-timeline: --bannerParallax; + animation-range: cover; } > .fade { @@ -716,6 +744,15 @@ onUnmounted(() => { } } } + +@keyframes bannerParallaxKeyframes { + from { + transform: translateY(-50%); + } + to { + transform: translateY(-30%); + } +} </style> <style lang="scss" module> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 5e9e671252..6d74de14a0 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -6,11 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header> - <MkTab v-model="tab" :class="$style.tab"> - <option value="featured">{{ i18n.ts.featured }}</option> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="files">{{ i18n.ts.withFiles }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'featured', label: i18n.ts.featured }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + { key: 'files', label: i18n.ts.withFiles }, + ]" + :class="$style.tab" + > </MkTab> </template> <MkNotesTimeline v-if="tab === 'featured'" :noGap="true" :paginator="featuredPaginator" :pullToRefresh="false" :class="$style.tl"/> @@ -30,7 +35,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string>('all'); +const tab = ref<'featured' | 'notes' | 'all' | 'files'>('all'); const featuredPaginator = markRaw(new Paginator('users/featured-notes', { limit: 10, diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue index 6c9204ae22..8824acb33e 100644 --- a/packages/frontend/src/pages/user/lists.vue +++ b/packages/frontend/src/pages/user/lists.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :paginator="paginator" withControl> <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`"> <div>{{ list.name }}</div> - <MkAvatars :userIds="list.userIds"/> + <MkAvatars v-if="list.userIds != null" :userIds="list.userIds"/> </MkA> </MkPagination> </div> diff --git a/packages/frontend/src/pages/user/notes.vue b/packages/frontend/src/pages/user/notes.vue index b5e600da92..1e6dba73bd 100644 --- a/packages/frontend/src/pages/user/notes.vue +++ b/packages/frontend/src/pages/user/notes.vue @@ -8,11 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <MkStickyContainer> <template #header> - <MkTab v-model="tab" :class="$style.tab"> - <option value="featured">{{ i18n.ts.featured }}</option> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="files">{{ i18n.ts.withFiles }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'featured', label: i18n.ts.featured }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + { key: 'files', label: i18n.ts.withFiles }, + ]" + :class="$style.tab" + > </MkTab> </template> <MkNotesTimeline v-if="tab === 'featured'" :noGap="true" :paginator="featuredPaginator" :class="$style.tl"/> @@ -34,7 +39,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string>('all'); +const tab = ref<'featured' | 'notes' | 'all' | 'files'>('all'); const featuredPaginator = markRaw(new Paginator('users/featured-notes', { limit: 10, diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index e6545bb8e7..f32c991828 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -7,7 +7,7 @@ import { ref } from 'vue'; import { compareVersions } from 'compare-versions'; import { isSafeMode } from '@@/js/config.js'; import * as Misskey from 'misskey-js'; -import type { Parser, Interpreter, values } from '@syuilo/aiscript'; +import type { Parser, Interpreter, values, utils as utils_TypeReferenceOnly } from '@syuilo/aiscript'; import type { FormWithDefault } from '@/utility/form.js'; import { genId } from '@/utility/id.js'; import { store } from '@/store.js'; @@ -82,22 +82,23 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> } const metadata = meta.get(null); - if (metadata == null) { - throw new Error('Metadata not found'); + if (metadata == null || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new Error('Metadata not found or invalid'); } const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { throw new Error('Required property not found'); } return { - name, - version, - author, - description, - permissions, - config, + name: name as string, + version: version as string, + author: author as string, + description: description as string | undefined, + permissions: permissions as string[] | undefined, + config: config as Record<string, any> | undefined, }; } @@ -110,7 +111,7 @@ export async function authorizePlugin(plugin: Plugin) { title: i18n.ts.tokenRequested, information: i18n.ts.pluginTokenRequestedDescription, initialName: plugin.name, - initialPermissions: plugin.permissions, + initialPermissions: plugin.permissions as typeof Misskey.permissions[number][], }, { done: async result => { const { name, permissions } = result; @@ -149,6 +150,7 @@ export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { const plugin = { ...realMeta, + config: realMeta.config ?? {}, installId, active: true, configData: {}, @@ -353,7 +355,9 @@ export function changePluginActive(plugin: Plugin, active: boolean) { async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Promise<Record<string, values.Value>> { const id = opts.plugin.installId; - const { utils, values } = await import('@syuilo/aiscript'); + const ais = await import('@syuilo/aiscript'); + const values = ais.values; + const utils: typeof utils_TypeReferenceOnly = ais.utils; const { createAiScriptEnv } = await import('@/aiscript/api.js'); const config = new Map<string, values.Value>(); @@ -375,7 +379,7 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr utils.assertFunction(handler); addPluginHandler(id, 'post_form_action', { title: title.value, - handler: withContext(ctx => (form, update) => { + handler: (form, update) => withContext(ctx => { ctx.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { if (!key || !value) { return; @@ -391,7 +395,7 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr utils.assertFunction(handler); addPluginHandler(id, 'user_action', { title: title.value, - handler: withContext(ctx => (user) => { + handler: (user) => withContext(ctx => { ctx.execFn(handler, [utils.jsToVal(user)]); }), }); @@ -402,7 +406,7 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr utils.assertFunction(handler); addPluginHandler(id, 'note_action', { title: title.value, - handler: withContext(ctx => (note) => { + handler: (note) => withContext(ctx => { ctx.execFn(handler, [utils.jsToVal(note)]); }), }); @@ -411,8 +415,8 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr 'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => { utils.assertFunction(handler); addPluginHandler(id, 'note_view_interruptor', { - handler: withContext(ctx => (note) => { - return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(note)])); + handler: (note) => withContext(ctx => { + return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(note)])) as Misskey.entities.Note | null; }), }); }), @@ -420,8 +424,8 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr 'Plugin:register:note_post_interruptor': values.FN_NATIVE(([handler]) => { utils.assertFunction(handler); addPluginHandler(id, 'note_post_interruptor', { - handler: withContext(ctx => async (note) => { - return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + handler: (note) => withContext(ctx => { + return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(note)])); }), }); }), @@ -429,8 +433,8 @@ async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Pr 'Plugin:register:page_view_interruptor': values.FN_NATIVE(([handler]) => { utils.assertFunction(handler); addPluginHandler(id, 'page_view_interruptor', { - handler: withContext(ctx => async (page) => { - return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(page)])); + handler: (page) => withContext(ctx => { + return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(page)])) as Misskey.entities.Page; }), }); }), diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index 3054978ae4..8258bbb846 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -25,11 +25,14 @@ export function migrateOldSettings() { }); const plugins = ColdDeviceStorage.get('plugins'); - prefer.commit('plugins', plugins.map(p => ({ - ...p, - installId: (p as any).id, - id: undefined, - }))); + prefer.commit('plugins', plugins.map(p => { + const { id, ...rest } = p; + return { + ...rest, + config: rest.config ?? {}, + installId: id, + }; + })); prefer.commit('deck.profile', deckStore.s.profile); misskeyApi('i/registry/keys', { @@ -115,7 +118,13 @@ export function migrateOldSettings() { prefer.commit('enableCondensedLine', store.s.enableCondensedLine); prefer.commit('keepScreenOn', store.s.keepScreenOn); prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications); - prefer.commit('dataSaver', store.s.dataSaver); + prefer.commit('dataSaver', { + ...prefer.s.dataSaver, + media: store.s.dataSaver.media, + avatar: store.s.dataSaver.avatar, + urlPreviewThumbnail: store.s.dataSaver.urlPreview, + code: store.s.dataSaver.code, + }); prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect); prefer.commit('enableHorizontalSwipe', store.s.enableHorizontalSwipe); prefer.commit('useNativeUiForVideoAudioPlayer', store.s.useNativeUIForVideoAudioPlayer); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 414aa34753..ebd031b240 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -41,6 +41,14 @@ export type StatusbarStore = { props: Record<string, any>; }; +export type DataSaverStore = { + media: boolean; + avatar: boolean; + urlPreviewThumbnail: boolean; + disableUrlPreview: boolean; + code: boolean; +}; + type OmitStrict<T, K extends keyof T> = T extends any ? Pick<T, Exclude<keyof T, K>> : never; // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) @@ -332,7 +340,7 @@ export const PREF_DEF = definePreferences({ urlPreviewThumbnail: false, disableUrlPreview: false, code: false, - } satisfies Record<string, boolean>, + } as DataSaverStore, }, hemisphere: { default: hemisphere as 'N' | 'S', @@ -431,6 +439,9 @@ export const PREF_DEF = definePreferences({ defaultImageCompressionLevel: { default: 2 as 0 | 1 | 2 | 3, }, + defaultVideoCompressionLevel: { + default: 2 as 0 | 1 | 2 | 3, + }, 'sound.masterVolume': { default: 0.5, @@ -505,4 +516,7 @@ export const PREF_DEF = definePreferences({ 'experimental.enableHapticFeedback': { default: false, }, + 'experimental.enableWebTranslatorApi': { + default: false, + }, }); diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index d26d590851..b6d3d55a5f 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -447,16 +447,16 @@ export class PreferencesManager { title: i18n.ts.preferenceSyncConflictTitle, text: i18n.ts.preferenceSyncConflictText, items: [...(mergedValue !== undefined ? [{ - text: i18n.ts.preferenceSyncConflictChoiceMerge, - value: 'merge', + label: i18n.ts.preferenceSyncConflictChoiceMerge, + value: 'merge' as const, }] : []), { - text: i18n.ts.preferenceSyncConflictChoiceServer, - value: 'remote', + label: i18n.ts.preferenceSyncConflictChoiceServer, + value: 'remote' as const, }, { - text: i18n.ts.preferenceSyncConflictChoiceDevice, - value: 'local', + label: i18n.ts.preferenceSyncConflictChoiceDevice, + value: 'local' as const, }, { - text: i18n.ts.preferenceSyncConflictChoiceCancel, + label: i18n.ts.preferenceSyncConflictChoiceCancel, value: null, }], default: mergedValue !== undefined ? 'merge' : 'remote', diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 80949f4971..33d379509a 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -187,7 +187,7 @@ export async function restoreFromCloudBackup() { const select = await os.select({ title: i18n.ts._preferencesBackup.selectBackupToRestore, items: backups.map(backup => ({ - text: backup.name, + label: backup.name, value: backup.name, })), }); diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index e25e0fe161..d59c9d1c6f 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -591,6 +591,10 @@ export const ROUTE_DEF = [{ component: page(() => import('@/pages/reversi/game.vue')), loginRequired: false, }, { + path: '/qr', + component: page(() => import('@/pages/qr.vue')), + loginRequired: true, +}, { path: '/debug', component: page(() => import('@/pages/debug.vue')), loginRequired: false, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 750ca69133..87b2637a64 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -381,7 +381,7 @@ export const store = markRaw(new Pizzax('base', { avatar: false, urlPreview: false, code: false, - } as Record<string, boolean>, + }, }, enableSeasonalScreenEffect: { where: 'device', @@ -483,7 +483,7 @@ export class ColdDeviceStorage { lightTheme, // TODO: 消す(preferに移行済みのため) darkTheme, // TODO: 消す(preferに移行済みのため) syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため) - plugins: [] as Plugin[], // TODO: 消す(preferに移行済みのため) + plugins: [] as (Omit<Plugin, 'installId'> & { id: string })[], // TODO: 消す(preferに移行済みのため) }; public static watchers: Watcher[] = []; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 4c43bf2b3b..2e21587fcb 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </div> <div :class="$style.bottom"> - <button v-if="showWidgetButton" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> + <button v-if="showWidgetButton" v-tooltip.noDelay.right="i18n.ts.widgets" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> <i class="ti ti-apps ti-fw"></i> </button> <button v-if="iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 9f6d8267f7..e2ee4b658e 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -168,7 +168,7 @@ const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ title: i18n.ts._deck.addColumn, items: columnTypes.map(column => ({ - value: column, text: i18n.ts._deck._columns[column], + value: column, label: i18n.ts._deck._columns[column], })), }); if (canceled || column == null) return; diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 0042882728..0423a22ce1 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -51,22 +51,24 @@ watch(soundSetting, v => { async function setAntenna() { const antennas = await misskeyApi('antennas/list'); - const { canceled, result: antenna } = await os.select<MisskeyEntities.Antenna | '_CREATE_'>({ + const { canceled, result: antennaIdOrOperation } = await os.select({ title: i18n.ts.selectAntenna, items: [ - { value: '_CREATE_', text: i18n.ts.createNew }, + { value: '_CREATE_', label: i18n.ts.createNew }, (antennas.length > 0 ? { - sectionTitle: i18n.ts.createdAntennas, + type: 'group' as const, + label: i18n.ts.createdAntennas, items: antennas.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), } : undefined), ], default: props.column.antennaId, }); - if (canceled || antenna == null) return; - if (antenna === '_CREATE_') { + if (canceled || antennaIdOrOperation == null) return; + + if (antennaIdOrOperation === '_CREATE_') { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAntennaEditorDialog.vue').then(x => x.default), {}, { created: (newAntenna: MisskeyEntities.Antenna) => { antennasCache.delete(); @@ -82,6 +84,8 @@ async function setAntenna() { return; } + const antenna = antennas.find(x => x.id === antennaIdOrOperation)!; + updateColumn(props.column.id, { antennaId: antenna.id, timelineNameCache: antenna.name, diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index c02499e2d7..35ca9f5cc6 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -58,14 +58,15 @@ watch(soundSetting, v => { async function setChannel() { const channels = await favoritedChannelsCache.fetch(); - const { canceled, result: chosenChannel } = await os.select({ + const { canceled, result: chosenChannelId } = await os.select({ title: i18n.ts.selectChannel, items: channels.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), default: props.column.channelId, }); - if (canceled || chosenChannel == null) return; + if (canceled || chosenChannelId == null) return; + const chosenChannel = channels.find(x => x.id === chosenChannelId)!; updateColumn(props.column.id, { channelId: chosenChannel.id, timelineNameCache: chosenChannel.name, diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 5c5891ece8..7fb0aba1e1 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -58,22 +58,23 @@ watch(soundSetting, v => { async function setList() { const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select<MisskeyEntities.UserList | '_CREATE_'>({ + const { canceled, result: listIdOrOperation } = await os.select({ title: i18n.ts.selectList, items: [ - { value: '_CREATE_', text: i18n.ts.createNew }, + { value: '_CREATE_', label: i18n.ts.createNew }, (lists.length > 0 ? { - sectionTitle: i18n.ts.createdLists, + type: 'group' as const, + label: i18n.ts.createdLists, items: lists.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), } : undefined), ], default: props.column.listId, }); - if (canceled || list == null) return; + if (canceled || listIdOrOperation == null) return; - if (list === '_CREATE_') { + if (listIdOrOperation === '_CREATE_') { const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); @@ -87,6 +88,8 @@ async function setList() { timelineNameCache: res.name, }); } else { + const list = lists.find(x => x.id === listIdOrOperation)!; + updateColumn(props.column.id, { listId: list.id, timelineNameCache: list.name, diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 0aafeb56d7..beb679169c 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -49,14 +49,15 @@ watch(soundSetting, v => { async function setRole() { const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); - const { canceled, result: role } = await os.select({ + const { canceled, result: roleId } = await os.select({ title: i18n.ts.role, items: roles.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), default: props.column.roleId, }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; + const role = roles.find(x => x.id === roleId)!; updateColumn(props.column.id, { roleId: role.id, timelineNameCache: role.name, diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 37814f0914..afaa08e6d0 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -96,13 +96,13 @@ async function setType() { const { canceled, result: src } = await os.select({ title: i18n.ts.timeline, items: [{ - value: 'home' as const, text: i18n.ts._timelines.home, + value: 'home', label: i18n.ts._timelines.home, }, { - value: 'local' as const, text: i18n.ts._timelines.local, + value: 'local', label: i18n.ts._timelines.local, }, { - value: 'social' as const, text: i18n.ts._timelines.social, + value: 'social', label: i18n.ts._timelines.social, }, { - value: 'global' as const, text: i18n.ts._timelines.global, + value: 'global', label: i18n.ts._timelines.global, }], }); if (canceled) { diff --git a/packages/frontend/src/utility/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts index 7dca18d58f..4fdaf24202 100644 --- a/packages/frontend/src/utility/code-highlighter.ts +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -36,7 +36,7 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise _res = deepClone(theme.codeHighlighter.overrides); } else { const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus; - _res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base); + _res = deepMerge<ThemeRegistration>(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base); } if (_res.name == null) { _res.name = theme.id; diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts index 2b765dc714..cb4a227f67 100644 --- a/packages/frontend/src/utility/form.ts +++ b/packages/frontend/src/utility/form.ts @@ -4,10 +4,11 @@ */ import * as Misskey from 'misskey-js'; +import type { OptionValue } from '@/components/MkSelect.vue'; export type EnumItem = string | { label: string; - value: unknown; + value: OptionValue; }; type Hidden = boolean | ((v: any) => boolean); @@ -130,11 +131,11 @@ type GetItemType<Item extends FormItem> = : Item extends RadioFormItem ? GetRadioItemType<Item> : Item extends RangeFormItem - ? NonNullableIfRequired<InferDefault<RangeFormItem, number>, Item> + ? NonNullableIfRequired<InferDefault<Item, number>, Item> : Item extends EnumFormItem ? GetEnumItemType<Item> : Item extends ArrayFormItem - ? NonNullableIfRequired<InferDefault<ArrayFormItem, unknown[]>, Item> + ? NonNullableIfRequired<InferDefault<Item, unknown[]>, Item> : Item extends ObjectFormItem ? NonNullableIfRequired<InferDefault<Item, Record<string, unknown>>, Item> : Item extends DriveFileFormItem diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index 90de952a91..fc165ea898 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; @@ -27,6 +26,11 @@ import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { globalEvents } from '@/events.js'; +const isInBrowserTranslationAvailable = ( + 'LanguageDetector' in window && + 'Translator' in window +); + export async function getNoteClipMenu(props: { note: Misskey.entities.Note; currentClip?: Misskey.entities.Clip; @@ -285,13 +289,48 @@ export function getNoteMenu(props: { async function translate(): Promise<void> { if (props.translation.value != null) return; - props.translating.value = true; - const res = await misskeyApi('notes/translate', { - noteId: appearNote.id, - targetLang: miLocalStorage.getItem('lang') ?? navigator.language, - }); - props.translating.value = false; - props.translation.value = res; + if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) { + props.translating.value = true; + try { + // @ts-expect-error 実験的なAPIなので型定義がない + const detector = await LanguageDetector.create(); + const langResult = await detector.detect(appearNote.text); + let localStorageLang = miLocalStorage.getItem('lang'); + if (localStorageLang != null) { + localStorageLang = localStorageLang.split('-')[0]; + } + + // 翻訳元と翻訳先の言語が同じ場合はTranslatorがthrowするのでそのまま返す + if (langResult[0]?.detectedLanguage === localStorageLang || langResult[0]?.detectedLanguage === navigator.language) { + props.translation.value = { + sourceLang: langResult[0]?.detectedLanguage ?? 'unknown', + text: appearNote.text, + }; + return; + } + + // @ts-expect-error 実験的なAPIなので型定義がない + const translator = await Translator.create({ + sourceLanguage: langResult[0]?.detectedLanguage, + targetLanguage: localStorageLang ?? navigator.language, + }); + const translated = await translator.translate(appearNote.text); + props.translation.value = { + sourceLang: langResult[0]?.detectedLanguage ?? 'unknown', + text: translated, + }; + } finally { + props.translating.value = false; + } + } else if ($i?.policies.canUseTranslator && instance.translatorAvailable) { + props.translating.value = true; + const res = await misskeyApi('notes/translate', { + noteId: appearNote.id, + targetLang: miLocalStorage.getItem('lang') ?? navigator.language, + }); + props.translating.value = false; + props.translation.value = res; + } } const menuItems: MenuItem[] = []; @@ -349,7 +388,7 @@ export function getNoteMenu(props: { }); } - if ($i.policies.canUseTranslator && instance.translatorAvailable) { + if ((prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable) || ($i.policies.canUseTranslator && instance.translatorAvailable)) { menuItems.push({ icon: 'ti ti-language-hiragana', text: i18n.ts.translate, diff --git a/packages/frontend/src/utility/get-user-environment.ts b/packages/frontend/src/utility/get-user-environment.ts new file mode 100644 index 0000000000..3b8d43fb2c --- /dev/null +++ b/packages/frontend/src/utility/get-user-environment.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type UserEnvironment = { + os: string; + browser: string; + userAgent: string; + screenWidth: number; + screenHeight: number; + viaGetHighEntropyValues: true; +} | { + userAgent: string; + screenWidth: number; + screenHeight: number; + viaGetHighEntropyValues: false; +}; + +export async function getUserEnvironment(): Promise<UserEnvironment> { + if ('userAgentData' in navigator && navigator.userAgentData != null) { + try { + const uaData: any = await navigator.userAgentData.getHighEntropyValues([ + 'fullVersionList', + 'platformVersion', + ]); + + let osVersion = 'v' + uaData.platformVersion; + + if (uaData.platform === 'Windows' && uaData.platformVersion != null) { + // https://learn.microsoft.com/ja-jp/microsoft-edge/web-platform/how-to-detect-win11 + const majorPlatformVersion = parseInt(uaData.platformVersion.split('.')[0]); + if (majorPlatformVersion >= 13) { + osVersion = '11 or later'; + } else if (majorPlatformVersion > 0) { + osVersion = '10'; + } else { + osVersion = '8.1 or earlier'; + } + } + + const browserData = uaData.fullVersionList.find((item) => !/^\s*not.+a.+brand\s*$/i.test(item.brand)); + return { + os: `${uaData.platform} ${osVersion}`, + browser: browserData ? `${browserData.brand} v${browserData.version}` : 'Unknown', + userAgent: navigator.userAgent, + screenWidth: window.innerWidth, + screenHeight: window.innerHeight, + viaGetHighEntropyValues: true, + }; + } catch { + return getViaUa(); + } + } else { + return getViaUa(); + } +} + +function getViaUa(): UserEnvironment { + return { + userAgent: navigator.userAgent, + screenWidth: window.innerWidth, + screenHeight: window.innerHeight, + viaGetHighEntropyValues: false, + }; +} diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index d4407dadec..9b2c53360c 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -37,15 +37,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router const { canceled, result: period } = await os.select({ title: i18n.ts.mutePeriod, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'tenMinutes', text: i18n.ts.tenMinutes, + value: 'tenMinutes', label: i18n.ts.tenMinutes, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }], default: 'indefinitely', }); @@ -215,16 +215,31 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } + if ($i && meId === user.id) { + menuItems.push({ + icon: 'ti ti-qrcode', + text: i18n.ts.qr, + action: () => { + router.push('/qr'); + }, + }); + } + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { menuItems.push({ icon: 'ti ti-search', text: i18n.ts.searchThisUsersNotes, action: () => { - router.push('/search', { - query: { + const query = { username: user.username, - host: user.host ?? undefined, - }, + } as { username: string, host?: string }; + + if (user.host !== null) { + query.host = user.host; + } + + router.push('/search', { + query }); }, }); @@ -289,7 +304,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router caseSensitive: antenna.caseSensitive, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, }); antennasCache.delete(); }, @@ -313,15 +327,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router const { canceled, result: period } = await os.select({ title: i18n.ts.period + ': ' + r.name, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }, { - value: 'oneMonth', text: i18n.ts.oneMonth, + value: 'oneMonth', label: i18n.ts.oneMonth, }], default: 'indefinitely', }); @@ -367,8 +381,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router //} menuItems.push({ type: 'divider' }, { - icon: 'ti ti-mail', - text: i18n.ts.sendMessage, + icon: 'ti ti-pencil-heart', + text: i18n.ts.createUserSpecifiedNote, action: () => { const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; os.post({ specified: user, initialText: `${canonical} ` }); diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts index 66b4d1026c..26c74bfae5 100644 --- a/packages/frontend/src/utility/image-effector/ImageEffector.ts +++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts @@ -3,8 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import QRCodeStyling from 'qr-code-styling'; +import { url, host } from '@@/js/config.js'; import { getProxiedImageUrl } from '../media-proxy.js'; import { initShaderProgram } from '../webgl.js'; +import { ensureSignin } from '@/i.js'; export type ImageEffectorRGB = [r: number, g: number, b: number]; @@ -48,6 +51,7 @@ interface AlignParamDef extends CommonParamDef { default: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; + margin?: number; }; }; @@ -58,7 +62,13 @@ interface SeedParamDef extends CommonParamDef { interface TextureParamDef extends CommonParamDef { type: 'texture'; - default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null; + default: { + type: 'text'; text: string | null; + } | { + type: 'url'; url: string | null; + } | { + type: 'qr'; data: string | null; + } | null; }; interface ColorParamDef extends CommonParamDef { @@ -324,7 +334,11 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a if (_DEV_) console.log(`Baking texture of <${textureKey}>...`); - const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null; + const texture = + v.type === 'text' ? await createTextureFromText(this.gl, v.text) : + v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : + v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) : + null; if (texture == null) continue; this.paramTextures.set(textureKey, texture); @@ -352,7 +366,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) { if (v == null) return ''; - return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : ''; + return ( + v.type === 'text' ? `text:${v.text}` : + v.type === 'url' ? `url:${v.url}` : + v.type === 'qr' ? `qr:${v.data}` : + '' + ); } /* @@ -467,3 +486,53 @@ async function createTextureFromText(gl: WebGL2RenderingContext, text: string | return info; } + +async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> { + const $i = ensureSignin(); + + const qrCodeInstance = new QRCodeStyling({ + width: resolution, + height: resolution, + margin: 42, + type: 'canvas', + data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data, + image: $i.avatarUrl, + qrOptions: { + typeNumber: 0, + mode: 'Byte', + errorCorrectionLevel: 'H', + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.3, + margin: 16, + crossOrigin: 'anonymous', + }, + dotsOptions: { + type: 'dots', + }, + cornersDotOptions: { + type: 'dot', + }, + cornersSquareOptions: { + type: 'extra-rounded', + }, + }); + + const blob = await qrCodeInstance.getRawData('png') as Blob | null; + if (blob == null) return null; + + const image = await window.createImageBitmap(blob); + + const texture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.bindTexture(gl.TEXTURE_2D, null); + + return { + texture, + width: resolution, + height: resolution, + }; +} diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts index 1fa48aea15..2b20cc1f99 100644 --- a/packages/frontend/src/utility/image-effector/fxs.ts +++ b/packages/frontend/src/utility/image-effector/fxs.ts @@ -18,6 +18,9 @@ import { FX_stripe } from './fxs/stripe.js'; import { FX_threshold } from './fxs/threshold.js'; import { FX_zoomLines } from './fxs/zoomLines.js'; import { FX_blockNoise } from './fxs/blockNoise.js'; +import { FX_fill } from './fxs/fill.js'; +import { FX_blur } from './fxs/blur.js'; +import { FX_pixelate } from './fxs/pixelate.js'; import type { ImageEffectorFx } from './ImageEffector.js'; export const FXS = [ @@ -36,4 +39,7 @@ export const FXS = [ FX_chromaticAberration, FX_tearing, FX_blockNoise, + FX_fill, + FX_blur, + FX_pixelate, ] as const satisfies ImageEffectorFx<string, any>[]; diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.ts b/packages/frontend/src/utility/image-effector/fxs/blur.ts new file mode 100644 index 0000000000..fa215fd3e4 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/blur.ts @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform float u_radius; +uniform int u_samples; +out vec4 out_color; + +void main() { + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + if (!isInside) { + out_color = texture(in_texture, in_uv); + return; + } + + vec4 result = vec4(0.0); + float totalSamples = 0.0; + + // Make blur radius resolution-independent by using a percentage of image size + // This ensures consistent visual blur regardless of image resolution + float referenceSize = min(in_resolution.x, in_resolution.y); + float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15) + vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize; + + // Calculate how many samples to take in each direction + // This determines the grid density, not the blur extent + int sampleRadius = int(sqrt(float(u_samples)) / 2.0); + + // Sample in a grid pattern within the specified radius + for (int x = -sampleRadius; x <= sampleRadius; x++) { + for (int y = -sampleRadius; y <= sampleRadius; y++) { + // Normalize the grid position to [-1, 1] range + float normalizedX = float(x) / float(sampleRadius); + float normalizedY = float(y) / float(sampleRadius); + + // Scale by radius to get the actual sampling offset + vec2 offset = vec2(normalizedX, normalizedY) * blurOffset; + vec2 sampleUV = in_uv + offset; + + // Only sample if within texture bounds + if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) { + result += texture(in_texture, sampleUV); + totalSamples += 1.0; + } + } + } + + out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); +} +`; + +export const FX_blur = defineImageEffectorFx({ + id: 'blur', + name: i18n.ts._imageEffector._fxs.blur, + shader, + uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const, + params: { + offsetX: { + label: i18n.ts._imageEffector._fxProps.offset + ' X', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + radius: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 3.0, + min: 0.0, + max: 10.0, + step: 0.5, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2); + gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2); + gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0); + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.radius, params.radius); + gl.uniform1i(u.samples, 256); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.ts b/packages/frontend/src/utility/image-effector/fxs/fill.ts new file mode 100644 index 0000000000..35dee594e3 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/fill.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + //float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + //float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + out_color = isInside ? vec4( + mix(in_color.r, u_color.r, u_opacity), + mix(in_color.g, u_color.g, u_opacity), + mix(in_color.b, u_color.b, u_opacity), + in_color.a + ) : in_color; +} +`; + +export const FX_fill = defineImageEffectorFx({ + id: 'fill', + name: i18n.ts._imageEffector._fxs.fill, + shader, + uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const, + params: { + offsetX: { + label: i18n.ts._imageEffector._fxProps.offset + ' X', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + color: { + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', + default: [1, 1, 1], + }, + opacity: { + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, + main: ({ gl, u, params }) => { + gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2); + gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2); + gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0); + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); + gl.uniform1f(u.opacity, params.opacity); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts b/packages/frontend/src/utility/image-effector/fxs/pixelate.ts new file mode 100644 index 0000000000..d9a5f454f3 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/pixelate.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform int u_samples; +uniform float u_strength; +out vec4 out_color; + +// TODO: pixelateの中心を画像中心ではなく範囲の中心にする +// TODO: 画像のアスペクト比に関わらず各画素は正方形にする + +void main() { + if (u_strength <= 0.0) { + out_color = texture(in_texture, in_uv); + return; + } + + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + if (!isInside) { + out_color = texture(in_texture, in_uv); + return; + } + + float dx = u_strength / 1.0; + float dy = u_strength / 1.0; + vec2 new_uv = vec2( + (dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)), + (dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5)) + ) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0)); + + vec4 result = vec4(0.0); + float totalSamples = 0.0; + + // TODO: より多くのサンプリング + result += texture(in_texture, new_uv); + totalSamples += 1.0; + + out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); +} +`; + +export const FX_pixelate = defineImageEffectorFx({ + id: 'pixelate', + name: i18n.ts._imageEffector._fxs.pixelate, + shader, + uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const, + params: { + offsetX: { + label: i18n.ts._imageEffector._fxProps.offset + ' X', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.2, + min: 0.0, + max: 0.5, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2); + gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2); + gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0); + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.strength, params.strength * params.strength); + gl.uniform1i(u.samples, 256); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts index 9b79e2bf94..f79acb44b0 100644 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts +++ b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts @@ -23,6 +23,7 @@ uniform float u_opacity; uniform bool u_repeat; uniform int u_alignX; // 0: left, 1: center, 2: right uniform int u_alignY; // 0: top, 1: center, 2: bottom +uniform float u_alignMargin; uniform int u_fitMode; // 0: contain, 1: cover out vec4 out_color; @@ -51,6 +52,9 @@ void main() { float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5; float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5; + x_offset += (u_alignX == 0 ? 1.0 : u_alignX == 2 ? -1.0 : 0.0) * u_alignMargin; + y_offset += (u_alignY == 0 ? 1.0 : u_alignY == 2 ? -1.0 : 0.0) * u_alignMargin; + float angle = -(u_angle * PI); vec2 center = vec2(x_offset, y_offset); //vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio); @@ -86,7 +90,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ id: 'watermarkPlacement', name: '(internal)', shader, - uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const, + uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'alignMargin', 'fitMode'] as const, params: { cover: { type: 'boolean', @@ -112,7 +116,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ }, align: { type: 'align', - default: { x: 'right', y: 'bottom' }, + default: { x: 'right', y: 'bottom', margin: 0 }, }, opacity: { type: 'number', @@ -143,6 +147,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ gl.uniform1i(u.repeat, params.repeat ? 1 : 0); gl.uniform1i(u.alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1); gl.uniform1i(u.alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1); + gl.uniform1f(u.alignMargin, params.align.margin ?? 0); gl.uniform1i(u.fitMode, params.cover ? 1 : 0); }, }); diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts index 2e16ebea3b..4ea28658dd 100644 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts +++ b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts @@ -4,11 +4,14 @@ */ import { defineImageEffectorFx } from '../ImageEffector.js'; +import { GLSL_LIB_SNOISE } from '@/utility/webgl.js'; import { i18n } from '@/i18n.js'; const shader = `#version 300 es precision mediump float; +${GLSL_LIB_SNOISE} + in vec2 in_uv; uniform sampler2D in_texture; uniform vec2 in_resolution; @@ -22,10 +25,22 @@ out vec4 out_color; void main() { vec4 in_color = texture(in_texture, in_uv); - float angle = atan(-u_pos.y + (in_uv.y), -u_pos.x + (in_uv.x)); - float t = (1.0 + sin(angle * u_frequency)) / 2.0; + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)); + vec2 uv = centeredUv; + + float seed = 1.0; + float time = 0.0; + + vec2 noiseUV = (uv - u_pos) / distance((uv - u_pos), vec2(0.0)); + float noiseX = (noiseUV.x + seed) * u_frequency; + float noiseY = (noiseUV.y + seed) * u_frequency; + float noise = (1.0 + snoise(vec3(noiseX, noiseY, time))) / 2.0; + + float t = noise; if (u_thresholdEnabled) t = t < u_threshold ? 1.0 : 0.0; - float d = distance(in_uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0)); + + // TODO: マスクの形自体も揺らぎを与える + float d = distance(uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0)); float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0))); out_color = vec4( mix(in_color.r, u_black ? 0.0 : 1.0, t * mask), @@ -61,9 +76,9 @@ export const FX_zoomLines = defineImageEffectorFx({ frequency: { label: i18n.ts._imageEffector._fxProps.frequency, type: 'number', - default: 30.0, - min: 1.0, - max: 200.0, + default: 5.0, + min: 0.0, + max: 15.0, step: 0.1, }, smoothing: { @@ -75,7 +90,7 @@ export const FX_zoomLines = defineImageEffectorFx({ threshold: { label: i18n.ts._imageEffector._fxProps.zoomLinesThreshold, type: 'number', - default: 0.2, + default: 0.5, min: 0.0, max: 1.0, step: 0.01, @@ -95,8 +110,8 @@ export const FX_zoomLines = defineImageEffectorFx({ }, }, main: ({ gl, u, params }) => { - gl.uniform2f(u.pos, (1.0 + params.x) / 2.0, (1.0 + params.y) / 2.0); - gl.uniform1f(u.frequency, params.frequency); + gl.uniform2f(u.pos, params.x / 2, params.y / 2); + gl.uniform1f(u.frequency, params.frequency * params.frequency); // thresholdの調整が有効な間はsmoothingが利用できない gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1); gl.uniform1f(u.threshold, params.threshold); diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts index 75807b30c4..b3525f158f 100644 --- a/packages/frontend/src/utility/watermark.ts +++ b/packages/frontend/src/utility/watermark.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js'; import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js'; import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js'; import { FX_checker } from '@/utility/image-effector/fxs/checker.js'; -import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; const WATERMARK_FXS = [ @@ -17,6 +17,8 @@ const WATERMARK_FXS = [ FX_checker, ] as const satisfies ImageEffectorFx<string, any>[]; +type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; }; + export type WatermarkPreset = { id: string; name: string; @@ -27,7 +29,7 @@ export type WatermarkPreset = { repeat: boolean; scale: number; angle: number; - align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + align: Align; opacity: number; } | { id: string; @@ -38,7 +40,14 @@ export type WatermarkPreset = { repeat: boolean; scale: number; angle: number; - align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + align: Align; + opacity: number; + } | { + id: string; + type: 'qr'; + data: string; + scale: number; + align: Align; opacity: number; } | { id: string; @@ -125,6 +134,23 @@ export class WatermarkRenderer { }, }, }; + } else if (layer.type === 'qr') { + return { + fxId: 'watermarkPlacement', + id: layer.id, + params: { + repeat: false, + scale: layer.scale, + align: layer.align, + angle: 0, + opacity: layer.opacity, + cover: false, + watermark: { + type: 'qr', + data: layer.data, + }, + }, + }; } else if (layer.type === 'stripe') { return { fxId: 'stripe', @@ -164,7 +190,7 @@ export class WatermarkRenderer { }, }; } else { - throw new Error(`Unknown layer type`); + throw new Error(`Unrecognized layer type: ${(layer as any).type}`); } }); } diff --git a/packages/frontend/src/utility/webgl.ts b/packages/frontend/src/utility/webgl.ts index ae595b605c..dee2103ecf 100644 --- a/packages/frontend/src/utility/webgl.ts +++ b/packages/frontend/src/utility/webgl.ts @@ -38,3 +38,91 @@ export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string, return shaderProgram; } + +export const GLSL_LIB_SNOISE = ` +// Description : Array and textureless GLSL 2D/3D/4D simplex +// noise functions. +// Author : Ian McEwan, Ashima Arts. +// Maintainer : stegu +// Lastmod : 20201014 (stegu) +// License : Copyright (C) 2011 Ashima Arts. All rights reserved. +// Distributed under the MIT License. See LICENSE file. +// https://github.com/ashima/webgl-noise +// https://github.com/stegu/webgl-noise + +vec3 mod289(vec3 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec4 mod289(vec4 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec4 permute(vec4 x) { + return mod289(((x * 34.0) + 10.0) * x); +} + +vec4 taylorInvSqrt(vec4 r) { + return 1.79284291400159 - 0.85373472095314 * r; +} + +float snoise(vec3 v) { + const vec2 C = vec2(1.0/6.0, 1.0/3.0); + const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); + + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - D.yyy; + + i = mod289(i); + vec4 p = permute(permute(permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + float n_ = 0.142857142857; + vec3 ns = n_ * D.wyz - D.xzx; + + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0)); + + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + + vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} +`; diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue index 41c6126c72..e708343b3a 100644 --- a/packages/frontend/src/widgets/WidgetActivity.chart.vue +++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { onMounted, ref } from 'vue'; const props = defineProps<{ activity: { total: number; @@ -94,6 +94,10 @@ function render() { pointsTotal.value = activity.map((d, i) => `${(i * zoom.value) + pos.value},${(1 - (d.total / peak)) * viewBoxY.value}`).join(' '); } } + +onMounted(() => { + render(); +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index 12c0a66c5c..f2321ca9fa 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -38,12 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, watch } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { useLowresTime, TIME_UPDATE_INTERVAL } from '@/composables/use-lowres-time.js'; const name = 'calendar'; @@ -65,6 +65,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); +const fNow = useLowresTime(); const year = ref(0); const month = ref(0); const day = ref(0); @@ -73,8 +74,14 @@ const yearP = ref(0); const monthP = ref(0); const dayP = ref(0); const isHoliday = ref(false); -const tick = () => { - const now = new Date(); + +const nextDay = new Date(); +nextDay.setHours(24, 0, 0, 0); +let nextDayMidnightTime = nextDay.getTime(); +let nextDayTimer: number | null = null; + +function update(time: number) { + const now = new Date(time); const nd = now.getDate(); const nm = now.getMonth(); const ny = now.getFullYear(); @@ -104,11 +111,28 @@ const tick = () => { yearP.value = yearNumer / yearDenom * 100; isHoliday.value = now.getDay() === 0 || now.getDay() === 6; -}; +} + +watch(fNow, (to) => { + update(to); + + // 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする + if (nextDayMidnightTime - to <= TIME_UPDATE_INTERVAL) { + if (nextDayTimer != null) { + window.clearTimeout(nextDayTimer); + nextDayTimer = null; + } + + nextDayTimer = window.setTimeout(() => { + update(nextDayMidnightTime); + nextDayTimer = null; + }, nextDayMidnightTime - to); + } +}, { immediate: true }); -useInterval(tick, 1000, { - immediate: true, - afterMounted: false, +watch(day, () => { + nextDay.setHours(24, 0, 0, 0); + nextDayMidnightTime = nextDay.getTime(); }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 8e5dc9e8d3..240210c1fb 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <p v-if="widgetProps.folderId == null"> {{ i18n.ts.folder }} </p> - <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.ts['no-image'] }}</p> + <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.ts.nothing }}</p> <div ref="slideA" class="slide a"></div> <div ref="slideB" class="slide b"></div> </div> diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index d87ea5ade2..9e914fa648 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -67,15 +67,15 @@ const fetching = ref(true); async function chooseList() { const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select({ + const { canceled, result: listId } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), default: widgetProps.listId, }); - if (canceled || list == null) return; - + if (canceled || listId == null) return; + const list = lists.find(x => x.id === listId)!; widgetProps.listId = list.id; save(); fetch(); |