summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-10-08 13:18:08 +0000
committerGitHub <noreply@github.com>2025-10-08 13:18:08 +0000
commit56cc89b521e8ca0d302230d123c3924e4461556d (patch)
tree242411d50ffd1ed7096f95ecdafe91b482628a46 /packages/frontend/src
parentMerge pull request #16521 from misskey-dev/develop (diff)
parentRelease: 2025.10.0 (diff)
downloadmisskey-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')
-rw-r--r--packages/frontend/src/boot/common.ts26
-rw-r--r--packages/frontend/src/components/MkAnimBg.vue76
-rw-r--r--packages/frontend/src/components/MkAntennaEditor.vue43
-rw-r--r--packages/frontend/src/components/MkAsUi.vue18
-rw-r--r--packages/frontend/src/components/MkAvatars.vue2
-rw-r--r--packages/frontend/src/components/MkCode.core.vue42
-rw-r--r--packages/frontend/src/components/MkCode.vue49
-rw-r--r--packages/frontend/src/components/MkDialog.vue34
-rw-r--r--packages/frontend/src/components/MkDrive.vue10
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue19
-rw-r--r--packages/frontend/src/components/MkEmojiPicker.vue14
-rw-r--r--packages/frontend/src/components/MkFolder.vue17
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue24
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.vue168
-rw-r--r--packages/frontend/src/components/MkInstanceStats.vue126
-rw-r--r--packages/frontend/src/components/MkNoteDraftsDialog.vue261
-rw-r--r--packages/frontend/src/components/MkNotification.vue23
-rw-r--r--packages/frontend/src/components/MkPaginationControl.vue14
-rw-r--r--packages/frontend/src/components/MkPolkadots.vue15
-rw-r--r--packages/frontend/src/components/MkPoll.vue37
-rw-r--r--packages/frontend/src/components/MkPollEditor.vue38
-rw-r--r--packages/frontend/src/components/MkPositionSelector.vue18
-rw-r--r--packages/frontend/src/components/MkPostForm.vue142
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue1
-rw-r--r--packages/frontend/src/components/MkPushNotificationAllowButton.vue9
-rw-r--r--packages/frontend/src/components/MkRemoteEmojiEditDialog.vue2
-rw-r--r--packages/frontend/src/components/MkRolePreview.vue6
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.vue8
-rw-r--r--packages/frontend/src/components/MkSelect.vue182
-rw-r--r--packages/frontend/src/components/MkTab.vue111
-rw-r--r--packages/frontend/src/components/MkTabs.vue86
-rw-r--r--packages/frontend/src/components/MkUploaderItems.vue13
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue5
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue73
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue48
-rw-r--r--packages/frontend/src/components/MkWidgets.vue14
-rw-r--r--packages/frontend/src/components/form/link.vue55
-rw-r--r--packages/frontend/src/components/global/MkAd.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue70
-rw-r--r--packages/frontend/src/components/global/MkTime.vue30
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue8
-rw-r--r--packages/frontend/src/composables/use-lowres-time.ts34
-rw-r--r--packages/frontend/src/composables/use-mkselect.ts38
-rw-r--r--packages/frontend/src/composables/use-uploader.ts123
-rw-r--r--packages/frontend/src/drag-and-drop.ts20
-rw-r--r--packages/frontend/src/events.ts2
-rw-r--r--packages/frontend/src/lib/pizzax.ts2
-rw-r--r--packages/frontend/src/navbar.ts8
-rw-r--r--packages/frontend/src/os.ts56
-rw-r--r--packages/frontend/src/pages/about.emojis.vue42
-rw-r--r--packages/frontend/src/pages/about.federation.vue89
-rw-r--r--packages/frontend/src/pages/admin-user.vue48
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue51
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue42
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue18
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue52
-rw-r--r--packages/frontend/src/pages/admin/ads.vue42
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue16
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue16
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.register.vue20
-rw-r--r--packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue8
-rw-r--r--packages/frontend/src/pages/admin/federation.vue63
-rw-r--r--packages/frontend/src/pages/admin/files.vue18
-rw-r--r--packages/frontend/src/pages/admin/invites.vue39
-rw-r--r--packages/frontend/src/pages/admin/job-queue.vue2
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue28
-rw-r--r--packages/frontend/src/pages/admin/modlog.vue16
-rw-r--r--packages/frontend/src/pages/admin/overview.active-users.vue2
-rw-r--r--packages/frontend/src/pages/admin/overview.federation.vue8
-rw-r--r--packages/frontend/src/pages/admin/overview.heatmap.vue23
-rw-r--r--packages/frontend/src/pages/admin/overview.pie.vue13
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.chart.vue12
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.vue18
-rw-r--r--packages/frontend/src/pages/admin/overview.stats.vue15
-rw-r--r--packages/frontend/src/pages/admin/overview.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue59
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue12
-rw-r--r--packages/frontend/src/pages/admin/roles.vue29
-rw-r--r--packages/frontend/src/pages/admin/users.vue64
-rw-r--r--packages/frontend/src/pages/auth.vue2
-rw-r--r--packages/frontend/src/pages/avatar-decoration-edit-dialog.vue8
-rw-r--r--packages/frontend/src/pages/chat/home.vue4
-rw-r--r--packages/frontend/src/pages/chat/message.vue2
-rw-r--r--packages/frontend/src/pages/chat/room.vue8
-rw-r--r--packages/frontend/src/pages/contact.vue20
-rw-r--r--packages/frontend/src/pages/debug.vue43
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue22
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue8
-rw-r--r--packages/frontend/src/pages/explore.featured.vue13
-rw-r--r--packages/frontend/src/pages/explore.users.vue14
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue16
-rw-r--r--packages/frontend/src/pages/gallery/post.vue19
-rw-r--r--packages/frontend/src/pages/instance-info.vue34
-rw-r--r--packages/frontend/src/pages/list.vue13
-rw-r--r--packages/frontend/src/pages/note.vue4
-rw-r--r--packages/frontend/src/pages/page-editor/common.ts11
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue1
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue2
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue2
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.vue25
-rw-r--r--packages/frontend/src/pages/qr.read.raw-viewer.vue54
-rw-r--r--packages/frontend/src/pages/qr.read.vue402
-rw-r--r--packages/frontend/src/pages/qr.show.vue234
-rw-r--r--packages/frontend/src/pages/qr.vue57
-rw-r--r--packages/frontend/src/pages/registry.keys.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.setting.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.vue2
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue2
-rw-r--r--packages/frontend/src/pages/settings/drive-cleaner.vue20
-rw-r--r--packages/frontend/src/pages/settings/drive.vue37
-rw-r--r--packages/frontend/src/pages/settings/emoji-palette.vue38
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue4
-rw-r--r--packages/frontend/src/pages/settings/notifications.notification-config.vue31
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue2
-rw-r--r--packages/frontend/src/pages/settings/other.vue4
-rw-r--r--packages/frontend/src/pages/settings/preferences.vue92
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue172
-rw-r--r--packages/frontend/src/pages/settings/profile.vue29
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue27
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue2
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue31
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue28
-rw-r--r--packages/frontend/src/pages/settings/theme.vue53
-rw-r--r--packages/frontend/src/pages/share.vue3
-rw-r--r--packages/frontend/src/pages/user/home.vue105
-rw-r--r--packages/frontend/src/pages/user/index.timeline.vue17
-rw-r--r--packages/frontend/src/pages/user/lists.vue2
-rw-r--r--packages/frontend/src/pages/user/notes.vue17
-rw-r--r--packages/frontend/src/plugin.ts44
-rw-r--r--packages/frontend/src/pref-migrate.ts21
-rw-r--r--packages/frontend/src/preferences/def.ts16
-rw-r--r--packages/frontend/src/preferences/manager.ts14
-rw-r--r--packages/frontend/src/preferences/utility.ts2
-rw-r--r--packages/frontend/src/router.definition.ts4
-rw-r--r--packages/frontend/src/store.ts4
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue2
-rw-r--r--packages/frontend/src/ui/deck.vue2
-rw-r--r--packages/frontend/src/ui/deck/antenna-column.vue16
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue7
-rw-r--r--packages/frontend/src/ui/deck/list-column.vue15
-rw-r--r--packages/frontend/src/ui/deck/role-timeline-column.vue7
-rw-r--r--packages/frontend/src/ui/deck/tl-column.vue8
-rw-r--r--packages/frontend/src/utility/code-highlighter.ts2
-rw-r--r--packages/frontend/src/utility/form.ts7
-rw-r--r--packages/frontend/src/utility/get-note-menu.ts57
-rw-r--r--packages/frontend/src/utility/get-user-environment.ts66
-rw-r--r--packages/frontend/src/utility/get-user-menu.ts48
-rw-r--r--packages/frontend/src/utility/image-effector/ImageEffector.ts75
-rw-r--r--packages/frontend/src/utility/image-effector/fxs.ts6
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/blur.ts157
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/fill.ts135
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/pixelate.ts147
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts9
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/zoomLines.ts33
-rw-r--r--packages/frontend/src/utility/watermark.ts34
-rw-r--r--packages/frontend/src/utility/webgl.ts88
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.chart.vue6
-rw-r--r--packages/frontend/src/widgets/WidgetCalendar.vue40
-rw-r--r--packages/frontend/src/widgets/WidgetSlideshow.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetUserList.vue8
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();