diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-08-31 08:42:43 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-31 08:42:43 +0000 |
| commit | ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b (patch) | |
| tree | 2c7aef5ba1626009377faf96941a57411dd619e5 /packages/frontend/src/components | |
| parent | Merge pull request #16244 from misskey-dev/develop (diff) | |
| parent | Release: 2025.8.0 (diff) | |
| download | misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.tar.gz misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.tar.bz2 misskey-ec21336d45e6e3bb26a0225fc0aa57ac98d5be4b.zip | |
Merge pull request #16335 from misskey-dev/develop
Release: 2025.8.0
Diffstat (limited to 'packages/frontend/src/components')
102 files changed, 798 insertions, 455 deletions
diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index b62096bbe9..2eb17b1b9e 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -2,14 +2,13 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; -import type { StoryObj } from '@storybook/vue3'; + +import { action } from 'storybook/actions'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index b907b5b25a..fff29262f1 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 70766634ce..c786e9fe9f 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum', }]" > - <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }"> + <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg ?? '' }"> <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img"> </div> </div> @@ -61,8 +61,8 @@ import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utili const props = withDefaults(defineProps<{ user: Misskey.entities.User; - withLocked: boolean; - withDescription: boolean; + withLocked?: boolean; + withDescription?: boolean; }>(), { withLocked: true, withDescription: true, @@ -71,7 +71,7 @@ const props = withDefaults(defineProps<{ const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null); const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x))); -function fetch() { +function _fetch_() { misskeyApi('users/achievements', { userId: props.user.id }).then(res => { achievements.value = []; for (const t of ACHIEVEMENT_TYPES) { @@ -84,11 +84,11 @@ function fetch() { function clickHere() { claimAchievement('clickedClickHere'); - fetch(); + _fetch_(); } onMounted(() => { - fetch(); + _fetch_(); }); </script> diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index e57fbcdee3..19a21f6e24 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -44,7 +44,7 @@ function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); const shaderProgram = gl.createProgram(); - if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null; + if (vertexShader == null || fragmentShader == null) return null; gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); @@ -71,8 +71,10 @@ onMounted(() => { canvas.width = width; canvas.height = height; - const gl = canvas.getContext('webgl', { premultipliedAlpha: true }); - if (gl == null) return; + const maybeGl = canvas.getContext('webgl', { premultipliedAlpha: true }); + if (maybeGl == null) return; + + const gl = maybeGl; gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -229,8 +231,8 @@ onMounted(() => { gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); if (isChromatic()) { - gl!.uniform1f(u_time, 0); - gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + gl.uniform1f(u_time, 0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } else { function render(timeStamp: number) { let sizeChanged = false; @@ -249,8 +251,8 @@ onMounted(() => { gl.viewport(0, 0, width, height); } - gl!.uniform1f(u_time, timeStamp); - gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); + gl.uniform1f(u_time, timeStamp); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); handle = window.requestAnimationFrame(render); } @@ -263,6 +265,8 @@ onUnmounted(() => { if (handle) { window.cancelAnimationFrame(handle); } + + // TODO: WebGLリソースの解放 }); </script> diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index 627cb0c4ff..743bdda032 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts index 4d921a4c48..5c4b05481a 100644 --- a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts index 5878b52fb9..1a70cb745c 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 64ccb708aa..15aab8daed 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index 0a569b3beb..4420cc4f05 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a77ebd6ac5..b729128a21 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { nextTick, onMounted, useTemplateRef } from 'vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -45,7 +46,7 @@ const props = defineProps<{ inline?: boolean; link?: boolean; to?: string; - linkBehavior?: null | 'window' | 'browser'; + linkBehavior?: MkABehavior; autofocus?: boolean; wait?: boolean; danger?: boolean; diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index 4304c2e2b7..095805ba95 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts index 47ca864dc0..33a61c8f7a 100644 --- a/packages/frontend/src/components/MkChannelList.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -3,14 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkChannelList from './MkChannelList.vue'; +import type { StoryObj } from '@storybook/vue3'; +import { Paginator } from '@/utility/paginator.js'; export const Default = { render(args) { return { @@ -33,10 +32,7 @@ export const Default = { }; }, args: { - pagination: { - endpoint: 'channels/search', - limit: 10, - }, + paginator: new Paginator('channels/search', {}), }, parameters: { chromatic: { diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 4d67bba70d..c54081ad42 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -589,7 +589,10 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -611,7 +614,10 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -626,7 +632,10 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -641,7 +650,10 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -664,7 +676,10 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -680,7 +695,10 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); + const host = props.args?.host; + if (host == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -695,7 +713,10 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/notes', { userId: userId, limit: props.limit, span: props.span }); return { series: [...(props.args?.withoutAll ? [] : [{ name: 'All', @@ -727,7 +748,10 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserPvChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/pv', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Unique PV (user)', @@ -754,7 +778,10 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -769,7 +796,10 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -784,7 +814,10 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); + const userId = props.args?.user?.id; + if (userId == null) return { series: [] }; + + const raw = await misskeyApiGet('charts/user/drive', { userId: userId, limit: props.limit, span: props.span }); return { bytes: true, series: [{ diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts index 8268adc36f..74fdff6fdd 100644 --- a/packages/frontend/src/components/MkChatHistories.stories.impl.ts +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -4,7 +4,7 @@ */ import { http, HttpResponse } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { chatMessage } from '../../.storybook/fakes'; import MkChatHistories from './MkChatHistories.vue'; import type { StoryObj } from '@storybook/vue3'; diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index 6e1eb13d61..f9012742cb 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,9 +2,9 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; diff --git a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts index c76b6fd08e..24b8e9119b 100644 --- a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import MkCodeEditor from './MkCodeEditor.vue'; const code = `for (let i, 100) { <: if (i % 15 == 0) "FizzBuzz" diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts index 3df92ca858..f8ec58bbcc 100644 --- a/packages/frontend/src/components/MkColorInput.stories.impl.ts +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import MkColorInput from './MkColorInput.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index 78cb4120de..bd6733f9a8 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 7f592fba79..6c07eac47a 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, useTemplateRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; @@ -55,17 +55,19 @@ const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; const loading = ref(true); -const ok = async () => { - const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { - const croppedImage = await cropper?.getCropperImage(); - const croppedSection = await cropper?.getCropperSelection(); +async function ok() { + const promise = new Promise<Blob>(async (res) => { + if (cropper == null) throw new Error('Cropper is not initialized'); + + const croppedImage = await cropper.getCropperImage()!; + const croppedSection = await cropper.getCropperSelection()!; // 拡大率を計算し、(ほぼ)元の大きさに戻す const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth; const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate; - const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); - croppedCanvas?.toBlob(blob => { + const croppedCanvas = await croppedSection.$toCanvas({ width: widthToRender }); + croppedCanvas.toBlob(blob => { if (!blob) return; res(blob); }); @@ -74,25 +76,27 @@ const ok = async () => { const f = await promise; emit('ok', f); - dialogEl.value!.close(); -}; + if (dialogEl.value != null) dialogEl.value.close(); +} -const cancel = () => { +function cancel() { emit('cancel'); - dialogEl.value!.close(); -}; + if (dialogEl.value != null) dialogEl.value.close(); +} -const onImageLoad = () => { +function onImageLoad() { loading.value = false; if (cropper) { cropper.getCropperImage()!.$center('contain'); cropper.getCropperSelection()!.$center(); } -}; +} onMounted(() => { - cropper = new Cropper(imgEl.value!, { + if (imgEl.value == null) return; // TSを黙らすため + + cropper = new Cropper(imgEl.value, { }); const computedStyle = getComputedStyle(window.document.documentElement); @@ -104,16 +108,22 @@ onMounted(() => { selection.outlined = true; window.setTimeout(() => { - cropper!.getCropperImage()!.$center('contain'); + if (cropper == null) return; + cropper.getCropperImage()!.$center('contain'); selection.$center(); }, 100); // モーダルオープンアニメーションが終わったあとで再度調整 window.setTimeout(() => { - cropper!.getCropperImage()!.$center('contain'); + if (cropper == null) return; + cropper.getCropperImage()!.$center('contain'); selection.$center(); }, 500); }); + +onUnmounted(() => { + URL.revokeObjectURL(imgUrl); +}); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkCwButton.stories.impl.ts b/packages/frontend/src/components/MkCwButton.stories.impl.ts index bbe5f4eddb..de38b98c4b 100644 --- a/packages/frontend/src/components/MkCwButton.stories.impl.ts +++ b/packages/frontend/src/components/MkCwButton.stories.impl.ts @@ -6,7 +6,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ import type { StoryObj } from '@storybook/vue3'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, within } from '@storybook/test'; import { file } from '../../.storybook/fakes.js'; import MkCwButton from './MkCwButton.vue'; diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts index 57c7916049..c168d31cce 100644 --- a/packages/frontend/src/components/MkDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts index 71d0c20c63..bd1b74281d 100644 --- a/packages/frontend/src/components/MkDonation.stories.impl.ts +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkDonation from './MkDonation.vue'; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 933383775c..9981ee77ac 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index e6c7c2f645..6fa8d2253f 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts index 4394eebfda..00930af380 100644 --- a/packages/frontend/src/components/MkDrive.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 9fe1c7ef21..9f1364aec4 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -145,18 +145,19 @@ import { claimAchievement } from '@/utility/achievements.js'; import { prefer } from '@/preferences.js'; import { chooseFileFromPcAndUpload, selectDriveFolder } from '@/utility/drive.js'; import { store } from '@/store.js'; -import { isSeparatorNeeded, getSeparatorInfo, makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import { makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; import { globalEvents, useGlobalEvent } from '@/events.js'; import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; const props = withDefaults(defineProps<{ - initialFolder?: Misskey.entities.DriveFolder['id'] | null; + initialFolder?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id'] | null; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; }>(), { + initialFolder: null, multiple: false, select: null, }); @@ -293,7 +294,7 @@ function onDragleave() { draghover.value = false; } -function onDrop(ev: DragEvent) { +function onDrop(ev: DragEvent): void | boolean { draghover.value = false; if (!ev.dataTransfer) return; @@ -363,7 +364,7 @@ function onDrop(ev: DragEvent) { //#endregion } -function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) { +function onUploadRequested(files: File[], folder?: Misskey.entities.DriveFolder | null) { os.launchUploader(files, { folderId: folder?.id ?? null, }); diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 88afdef114..3933421fc0 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :forceBlurhash="forceBlurhash" /> <img - v-else-if="isThumbnailAvailable" + v-else-if="isThumbnailAvailable && file.thumbnailUrl != null" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" diff --git a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue index 2ebab1088f..d5b6b0cbec 100644 --- a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue @@ -39,13 +39,13 @@ withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFolder[]): void; + (ev: 'done', r?: (Misskey.entities.DriveFolder | null)[]): void; (ev: 'closed'): void; }>(); const dialog = useTemplateRef('dialog'); -const selected = ref<Misskey.entities.DriveFolder[]>([]); +const selected = ref<(Misskey.entities.DriveFolder | null)[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(v: Misskey.entities.DriveFolder[]) { +function onChangeSelection(v: (Misskey.entities.DriveFolder | null)[]) { selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts index bf4158a2c8..cc934040f5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 68da098439..6904c417ce 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -141,6 +141,7 @@ import { $i } from '@/i.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { prefer } from '@/preferences.js'; import { useRouter } from '@/router.js'; +import { haptic } from '@/utility/haptic.js'; const router = useRouter(); @@ -151,7 +152,7 @@ const props = withDefaults(defineProps<{ asDrawer?: boolean; asWindow?: boolean; asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; }>(), { showPinned: true, }); @@ -431,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, const key = getKey(emoji); emit('chosen', key); + haptic(); + // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { let recents = store.s.recentlyUsedEmojis; @@ -495,7 +498,7 @@ function done(query?: string): boolean | void { function settings() { emit('esc'); - router.push('settings/emoji-palette'); + router.push('/settings/emoji-palette'); } onMounted(() => { @@ -585,6 +588,14 @@ defineExpose({ grid-template-columns: var(--columns); font-size: 30px; + > .config { + aspect-ratio: 1 / 1; + width: auto; + height: auto; + min-width: 0; + font-size: 14px; + } + > .item { aspect-ratio: 1 / 1; width: auto; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 1627dc8760..0ff4e8f38d 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -44,11 +44,11 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; choseAndClose?: boolean; }>(), { manualShowing: null, diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index a2247d844b..c9d18ee731 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo> - <div v-if="isPlugin" class="_gaps_s"> + <div v-if="extension.type === 'plugin'" class="_gaps_s"> <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-info-circle"></i></template> <template #label>{{ i18n.ts.metadata }}</template> @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCode :code="extension.raw"/> </MkFolder> </div> - <div v-else-if="isTheme" class="_gaps_s"> + <div v-else-if="extension.type === 'theme'" class="_gaps_s"> <MkFolder :defaultOpen="true"> <template #icon><i class="ti ti-info-circle"></i></template> <template #label>{{ i18n.ts.metadata }}</template> @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSplit> <MkKeyValue> <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + <template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template> </MkKeyValue> </div> </MkFolder> diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index b65f610986..c7361a19c6 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -46,6 +46,7 @@ import { claimAchievement } from '@/utility/achievements.js'; import { pleaseLogin } from '@/utility/please-login.js'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { haptic } from '@/utility/haptic.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -84,6 +85,8 @@ async function onClick() { wait.value = true; + haptic(); + try { if (isFollowing.value) { const { canceled } = await os.confirm({ diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index 96214a9542..eb559e611c 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div v-if="form.modified.value" :class="$style.root"> <div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div> <div style="margin-left: auto;" class="_buttons"> <MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton> @@ -16,16 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; import MkButton from './MkButton.vue'; +import type { useForm } from '@/composables/use-form.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - form: { - modifiedCount: { - value: number; - }; - discard: () => void; - save: () => void; - }; + form: ReturnType<typeof useForm>; canSaving?: boolean; }>(), { canSaving: true, diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue index d8466fa7ca..f734325039 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -14,73 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <div :class="$style.root" class="_gaps"> - <div v-for="[k, v] in Object.entries(fx.params)" :key="k"> - <MkSwitch - v-if="v.type === 'boolean'" - v-model="layer.params[k]" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkSwitch> - <MkRange - v-else-if="v.type === 'number'" - v-model="layer.params[k]" - continuousUpdate - :min="v.min" - :max="v.max" - :step="v.step" - :textConverter="fx.params[k].toViewValue" - @thumbDoubleClicked="() => { - if (fx.params[k].default != null) { - layer.params[k] = fx.params[k].default; - } else { - layer.params[k] = v.min; - } - }" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkRange> - <MkRadios - v-else-if="v.type === 'number:enum'" - v-model="layer.params[k]" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - <option v-for="item in v.enum" :value="item.value">{{ item.label }}</option> - </MkRadios> - <div v-else-if="v.type === 'seed'"> - <MkRange - v-model="layer.params[k]" - continuousUpdate - type="number" - :min="0" - :max="10000" - :step="1" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkRange> - </div> - <MkInput - v-else-if="v.type === 'color'" - :modelValue="getHex(layer.params[k])" - type="color" - @update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }" - > - <template #label>{{ fx.params[k].label ?? k }}</template> - </MkInput> - </div> - </div> + <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" /> </MkFolder> </template> <script setup lang="ts"> import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; -import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkRange from '@/components/MkRange.vue'; +import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue'; import { FXS } from '@/utility/image-effector/fxs.js'; const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); @@ -94,28 +36,4 @@ const emit = defineEmits<{ (e: 'swapUp'): void; (e: 'swapDown'): void; }>(); - -function getHex(c: [number, number, number]) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; -} - -function getRgb(hex: string | number): [number, number, number] | null { - if ( - typeof hex === 'number' || - typeof hex !== 'string' || - !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) - ) { - return null; - } - - const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); - if (m == null) return [0, 0, 0]; - return m.map(x => parseInt(x, 16) / 255) as [number, number, number]; -} </script> - -<style module> -.root { - -} -</style> diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue new file mode 100644 index 0000000000..d7ab620132 --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue @@ -0,0 +1,95 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-for="v, k in paramDefs" :key="k"> + <MkSwitch + v-if="v.type === 'boolean'" + v-model="params[k]"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkSwitch> + <MkRange + v-else-if="v.type === 'number'" + v-model="params[k]" + continuousUpdate + :min="v.min" + :max="v.max" + :step="v.step" + :textConverter="v.toViewValue" + @thumbDoubleClicked="() => { + params[k] = v.default; + }" + > + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkRange> + <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + <option v-for="item in v.enum" :value="item.value"> + <i v-if="item.icon" :class="item.icon"></i> + <template v-else>{{ item.label }}</template> + </option> + </MkRadios> + <div v-else-if="v.type === 'seed'"> + <MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkRange> + </div> + <MkInput v-else-if="v.type === 'color'" :modelValue="getHex(params[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params[k] = c; }"> + <template #label>{{ v.label ?? k }}</template> + <template v-if="v.caption != null" #caption>{{ v.caption }}</template> + </MkInput> + </div> + <div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure"> + {{ i18n.ts._imageEffector.nothingToConfigure }} + </div> +</div> +</template> + +<script setup lang="ts"> +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import { i18n } from '@/i18n.js'; +import type { ImageEffectorRGB, ImageEffectorFxParamDefs } from '@/utility/image-effector/ImageEffector.js'; + +defineProps<{ + paramDefs: ImageEffectorFxParamDefs; +}>(); + +const params = defineModel<Record<string, any>>({ required: true }); + +function getHex(c: ImageEffectorRGB) { + return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; +} + +function getRgb(hex: string | number): ImageEffectorRGB | null { + if ( + typeof hex === 'number' || + typeof hex !== 'string' || + !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) + ) { + return null; + } + + const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); + if (m == null) return [0, 0, 0]; + return m.map(x => parseInt(x, 16) / 255) as ImageEffectorRGB; +} +</script> + +<style module> +.nothingToConfigure { + opacity: 0.7; + text-align: center; + font-size: 14px; + padding: 0 10px; +} +</style> diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts index 339e6d10f3..7da705a23f 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { file } from '../../.storybook/fakes.js'; import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 361aeff4d0..983a0932c3 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -52,15 +52,20 @@ import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; +// テスト環境で Web Worker インスタンスは作成できない +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const isTest = (import.meta.env.MODE === 'test' || window.Cypress != null); + const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { - // テスト環境で Web Worker インスタンスは作成できない - if (import.meta.env.MODE === 'test') { + if (isTest) { const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); return; } + const testWorker = new TestWebGL2(); testWorker.addEventListener('message', event => { if (event.data.result) { @@ -189,7 +194,7 @@ function drawAvg() { } async function draw() { - if (import.meta.env.MODE === 'test' && props.hash == null) return; + if (isTest && props.hash == null) return; drawAvg(); diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 584afff55c..d8725ade0b 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -34,9 +34,10 @@ import { deviceKind } from '@/utility/device-kind.js'; import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; anchor?: { x: string; y: string; }; }>(), { + anchorElement: null, anchor: () => ({ x: 'right', y: 'center' }), }); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 309ef727da..163f172f57 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -17,11 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@@/js/config.js'; +import { maybeMakeRelative } from '@@/js/url.js'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import { useTooltip } from '@/composables/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/utility/url-preview.js'; -import type { MkABehavior } from '@/components/global/MkA.vue'; -import { maybeMakeRelative } from '@@/js/url.js'; const props = withDefaults(defineProps<{ url: string; @@ -39,10 +39,12 @@ const el = ref<HTMLElement | { $el: HTMLElement }>(); if (isEnabledUrlPreview.value) { useTooltip(el, (showing) => { + const anchorElement = el.value instanceof HTMLElement ? el.value : el.value?.$el; + if (anchorElement == null) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el.value instanceof HTMLElement ? el.value : el.value?.$el, + anchorElement: anchorElement, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index b7052ad918..e3bb39549f 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > - <button v-if="hide" :class="$style.hidden" @click="show"> + <button v-if="hide" :class="$style.hidden" @click="reveal"> <div :class="$style.hiddenTextWrapper"> <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> @@ -157,7 +157,7 @@ const audioEl = useTemplateRef('audioEl'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); -async function show() { +async function reveal() { if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index f23cf507fb..7730e01a9f 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> - <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="show"> + <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> @@ -37,7 +37,7 @@ const props = defineProps<{ const hide = ref(true); -async function show() { +async function reveal() { if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 1e5eb06a31..99ea606a11 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -96,10 +96,10 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages) ? props.image.url : prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) - : props.image.thumbnailUrl, + : props.image.thumbnailUrl!, ); -async function onclick(ev: MouseEvent) { +async function reveal(ev: MouseEvent) { if (!props.controls) { return; } diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 4a1100c324..bfc8179e13 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -94,6 +94,8 @@ async function calcAspectRatio() { onMounted(() => { calcAspectRatio(); + if (gallery.value == null) return; // TSを黙らすため + lightbox = new PhotoSwipeLightbox({ dataSource: props.mediaList .filter(media => { diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 81a5ab27c7..b0f7a909d3 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > - <button v-if="hide" :class="$style.hidden" @click="show"> + <button v-if="hide" :class="$style.hidden" @click="reveal"> <div :class="$style.hiddenTextWrapper"> <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> @@ -178,7 +178,7 @@ function hasFocus() { // eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); -async function show() { +async function reveal() { if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index f7cd72b6c6..37c3a3f5e3 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -16,7 +16,7 @@ import type { MenuItem } from '@/types/menu.js'; const props = defineProps<{ items: MenuItem[]; - targetElement: HTMLElement; + anchorElement: HTMLElement; rootElement: HTMLElement; width?: number; }>(); @@ -36,10 +36,10 @@ const SCROLLBAR_THICKNESS = 16; function setPosition() { if (el.value == null) return; const rootRect = props.rootElement.getBoundingClientRect(); - const parentRect = props.targetElement.getBoundingClientRect(); + const parentRect = props.anchorElement.getBoundingClientRect(); const myRect = el.value.getBoundingClientRect(); - let left = props.targetElement.offsetWidth; + let left = props.anchorElement.offsetWidth; let top = (parentRect.top - rootRect.top) - 8; if (rootRect.left + left + myRect.width >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = -myRect.width; @@ -59,7 +59,7 @@ function onChildClosed(actioned?: boolean) { } } -watch(() => props.targetElement, () => { +watch(() => props.anchorElement, () => { setPosition(); }); diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index fbae4f0d8a..6c8fac934c 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -208,7 +208,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/> + <XChild ref="child" :items="childMenu" :anchorElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/> </div> </div> </template> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 06686ddfc0..660d5a26be 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -91,7 +91,7 @@ const emit = defineEmits<{ (ev: 'opened'): void; (ev: 'click'): void; (ev: 'esc'): void; - (ev: 'close'): void; + (ev: 'close'): void; // TODO: (refactor) closing に改名する (ev: 'closed'): void; }>(); @@ -148,7 +148,6 @@ function close(opts: { useSendAnimation?: boolean } = {}) { useSendAnime.value = true; } - // eslint-disable-next-line vue/no-mutating-props if (props.anchorElement) props.anchorElement.style.pointerEvents = 'auto'; showing.value = false; emit('close'); @@ -319,7 +318,6 @@ const alignObserver = new ResizeObserver((entries, observer) => { onMounted(() => { watch(() => props.anchorElement, async () => { if (props.anchorElement) { - // eslint-disable-next-line vue/no-mutating-props props.anchorElement.style.pointerEvents = 'none'; } fixed.value = (type.value === 'drawer') || (getFixedContainer(props.anchorElement) != null); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0605030d5b..729bded03c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" tabindex="0" > - <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> @@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="isEnabledUrlPreview"> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> </div> - <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> @@ -265,24 +265,22 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul let note = deepClone(props.note); -// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある -// https://github.com/aiscript-dev/aiscript/issues/937 -//// plugin -//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -//if (noteViewInterruptors.length > 0) { -// let result: Misskey.entities.Note | null = deepClone(note); -// for (const interruptor of noteViewInterruptors) { -// try { -// result = await interruptor.handler(result!) as Misskey.entities.Note | null; -// } catch (err) { -// console.error(err); -// } -// } -// note = result as Misskey.entities.Note; -//} +// plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +if (noteViewInterruptors.length > 0) { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = interruptor.handler(result!) as Misskey.entities.Note | null; + } catch (err) { + console.error(err); + } + } + note = result as Misskey.entities.Note; +} const isRenote = Misskey.note.isPureRenote(note); -const appearNote = getAppearNote(note); +const appearNote = getAppearNote(note) ?? note; const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ note: appearNote, parentNote: note, @@ -431,7 +429,7 @@ if (!props.mock) { showing, users, count: appearNote.renoteCount, - targetElement: renoteButton.value, + anchorElement: renoteButton.value, }, { closed: () => dispose(), }); @@ -454,7 +452,7 @@ if (!props.mock) { reaction: '❤️', users, count: $appearNote.reactionCount, - targetElement: reactButton.value!, + anchorElement: reactButton.value!, }, { closed: () => dispose(), }); @@ -462,14 +460,12 @@ if (!props.mock) { } } -function renote(viaKeyboard = false) { +function renote() { pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); - os.popupMenu(menu, renoteButton.value, { - viaKeyboard, - }); + os.popupMenu(menu, renoteButton.value); subscribeManuallyToNoteCapture(); } @@ -658,7 +654,7 @@ function showRenoteMenu(): void { getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), - ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, + ...(($i?.isModerator || $i?.isAdmin) ? [getUnrenote()] : []), ], renoteTime.value); } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index fb37bb1ae6..48fd9908bd 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> </div> - <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> @@ -287,23 +287,22 @@ const inChannel = inject('inChannel', null); let note = deepClone(props.note); -// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある -//// plugin -//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -//if (noteViewInterruptors.length > 0) { -// let result: Misskey.entities.Note | null = deepClone(note); -// for (const interruptor of noteViewInterruptors) { -// try { -// result = await interruptor.handler(result!) as Misskey.entities.Note | null; -// } catch (err) { -// console.error(err); -// } -// } -// note = result as Misskey.entities.Note; -//} +// plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +if (noteViewInterruptors.length > 0) { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = interruptor.handler(result!) as Misskey.entities.Note | null; + } catch (err) { + console.error(err); + } + } + note = result as Misskey.entities.Note; +} const isRenote = Misskey.note.isPureRenote(note); -const appearNote = getAppearNote(note); +const appearNote = getAppearNote(note) ?? note; const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({ note: appearNote, parentNote: note, @@ -393,6 +392,9 @@ const reactionsPaginator = markRaw(new Paginator('notes/reactions', { })); useTooltip(renoteButton, async (showing) => { + const anchorElement = renoteButton.value; + if (anchorElement == null) return; + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.id, limit: 11, @@ -406,7 +408,7 @@ useTooltip(renoteButton, async (showing) => { showing, users, count: appearNote.renoteCount, - targetElement: renoteButton.value, + anchorElement: anchorElement, }, { closed: () => dispose(), }); @@ -429,7 +431,7 @@ if (appearNote.reactionAcceptance === 'likeOnly') { reaction: '❤️', users, count: $appearNote.reactionCount, - targetElement: reactButton.value!, + anchorElement: reactButton.value!, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index e684cf2a30..ed0b3ad555 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <MkAvatar :class="$style.avatar" :user="note.user" link preview/> +<div v-if="note" :class="$style.root"> + <MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="note.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> @@ -19,6 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> +<div v-else :class="$style.deleted"> + {{ i18n.ts.deletedNote }} +</div> </template> <script lang="ts" setup> @@ -27,9 +30,11 @@ import * as Misskey from 'misskey-js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note | null; }>(); const showContent = ref(false); @@ -50,9 +55,12 @@ const showContent = ref(false); width: 34px; height: 34px; border-radius: 8px; - position: sticky !important; - top: calc(16px + var(--MI-stickyTop, 0px)); - left: 0; + + &.useSticky { + position: sticky !important; + top: calc(16px + var(--MI-stickyTop, 0px)); + left: 0; + } } .main { @@ -101,4 +109,14 @@ const showContent = ref(false); height: 48px; } } + +.deleted { + text-align: center; + padding: 8px !important; + margin: 8px 8px 0 8px; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); + border-radius: 8px; +} </style> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 4fd1c210cb..3f5cc51938 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -4,7 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="!muted" :class="[$style.root, { [$style.children]: depth > 1 }]"> +<div v-if="note == null" :class="$style.deleted"> + {{ i18n.ts.deletedNote }} +</div> +<div v-else-if="!muted" :class="[$style.root, { [$style.children]: depth > 1 }]"> <div :class="$style.main"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> @@ -53,7 +56,7 @@ import { userPage } from '@/filters/user.js'; import { checkWordMute } from '@/utility/check-word-mute.js'; const props = withDefaults(defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note | null; detail?: boolean; // how many notes are in between this one and the note being viewed in detail @@ -62,12 +65,12 @@ const props = withDefaults(defineProps<{ depth: 1, }); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); +const muted = ref(props.note && $i ? checkWordMute(props.note, $i, $i.mutedWords) : false); const showContent = ref(false); const replies = ref<Misskey.entities.Note[]>([]); -if (props.detail) { +if (props.detail && props.note) { misskeyApi('notes/children', { noteId: props.note.id, limit: 5, @@ -160,4 +163,14 @@ if (props.detail) { margin: 8px 8px 0 8px; border-radius: 8px; } + +.deleted { + text-align: center; + padding: 8px !important; + margin: 8px 8px 0 8px; + --color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); + border-radius: 8px; +} </style> diff --git a/packages/frontend/src/components/MkNotesTimeline.vue b/packages/frontend/src/components/MkNotesTimeline.vue index 83af7db26f..d94cf3924c 100644 --- a/packages/frontend/src/components/MkNotesTimeline.vue +++ b/packages/frontend/src/components/MkNotesTimeline.vue @@ -4,21 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPagination :paginator="paginator" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl"> +<MkPagination :paginator="paginator" :direction="direction" :autoLoad="autoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl"> <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"> <template v-for="(note, i) in notes" :key="note.id"> - <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> - <div :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <div + v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i - 1].createdAt, note.createdAt)" + :data-scroll-anchor="note.id" + :class="{ '_gaps': !noGap }" + > + <div :class="[$style.date, { [$style.noGap]: noGap }]"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i - 1].createdAt, note.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i - 1].createdAt, note.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + <div v-if="note._shouldInsertAd_" :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> </div> - <div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> + <div v-else-if="note._shouldInsertAd_" :class="{ '_gaps': !noGap }" :data-scroll-anchor="note.id"> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> <div :class="$style.ad"> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> @@ -43,11 +50,14 @@ import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-sep const props = withDefaults(defineProps<{ paginator: T; noGap?: boolean; + + direction?: 'up' | 'down' | 'both'; autoLoad?: boolean; pullToRefresh?: boolean; withControl?: boolean; }>(), { autoLoad: true, + direction: 'down', pullToRefresh: true, withControl: true, }); @@ -103,7 +113,10 @@ defineExpose({ opacity: 0.75; padding: 8px 8px; margin: 0 auto; - border-bottom: solid 0.5px var(--MI_THEME-divider); + + &.noGap { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } } .ad:empty { diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 1310ea6a77..d21e09a984 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -23,8 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div :class="$style.root" class="_forceShrinkSpacer"> - <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount" :router="windowRouter"/> - <RouterView v-else :key="reloadCount" :router="windowRouter"/> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount.toString() + ':stacking'" :router="windowRouter"/> + <RouterView v-else :key="reloadCount.toString() + ':non-stacking'" :router="windowRouter"/> </div> </MkWindow> </template> @@ -58,20 +58,15 @@ const windowRouter = createRouter(props.initialPath); const pageMetadata = ref<null | PageMetadata>(null); const windowEl = useTemplateRef('windowEl'); -const history = ref<{ path: string; }[]>([{ +const _history_ = ref<{ path: string; }[]>([{ path: windowRouter.getCurrentFullPath(), }]); const buttonsLeft = computed(() => { - const buttons: Record<string, unknown>[] = []; - - if (history.value.length > 1) { - buttons.push({ - icon: 'ti ti-arrow-left', - onClick: back, - }); - } - - return buttons; + return _history_.value.length > 1 ? [{ + icon: 'ti ti-arrow-left', + title: i18n.ts.goBack, + onClick: back, + }] : []; }); const buttonsRight = computed(() => { const buttons = [{ @@ -97,12 +92,12 @@ function getSearchMarker(path: string) { const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath)); windowRouter.addListener('push', ctx => { - history.value.push({ path: ctx.fullPath }); + _history_.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('replace', ctx => { - history.value.pop(); - history.value.push({ path: ctx.fullPath }); + _history_.value.pop(); + _history_.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('change', ctx => { @@ -150,8 +145,8 @@ const contextmenu = computed(() => ([{ }])); function back() { - history.value.pop(); - windowRouter.replace(history.value.at(-1)!.path); + _history_.value.pop(); + windowRouter.replaceByPath(_history_.value.at(-1)!.path); } function reload() { @@ -163,7 +158,7 @@ function close() { } function expand() { - mainRouter.push(windowRouter.getCurrentFullPath(), 'forcePage'); + mainRouter.pushByPath(windowRouter.getCurrentFullPath(), 'forcePage'); windowEl.value?.close(); } diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 8ca1c80e84..4ea62f2812 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else key="_root_" class="_gaps"> - <slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> - <div v-if="paginator.order.value === 'oldest'"> - <MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer()"> + <div v-if="direction === 'up' || direction === 'both'" v-show="upButtonVisible"> + <MkButton v-if="!upButtonLoading" :class="$style.more" primary rounded @click="upButtonClick"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else/> </div> - <div v-else v-show="paginator.canFetchOlder.value"> - <MkButton v-if="!paginator.fetchingOlder.value" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder()"> + <slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> + <div v-if="direction === 'down' || direction === 'both'" v-show="downButtonVisible"> + <MkButton v-if="!downButtonLoading" :class="$style.more" primary rounded @click="downButtonClick"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else/> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup generic="T extends IPaginator"> import { isLink } from '@@/js/is-link.js'; -import { onMounted, watch, unref } from 'vue'; +import { onMounted, computed, watch, unref } from 'vue'; import type { UnwrapRef } from 'vue'; import type { IPaginator } from '@/utility/paginator.js'; import MkButton from '@/components/MkButton.vue'; @@ -58,11 +58,20 @@ import * as os from '@/os.js'; const props = withDefaults(defineProps<{ paginator: T; + + // ページネーションを進める方向 + // up: 上方向 + // down: 下方向 (default) + // both: 双方向 + // NOTE: この方向はページネーションの方向であって、アイテムの並び順ではない + direction?: 'up' | 'down' | 'both'; + autoLoad?: boolean; pullToRefresh?: boolean; withControl?: boolean; }>(), { autoLoad: true, + direction: 'down', pullToRefresh: true, withControl: false, }); @@ -93,6 +102,36 @@ if (props.paginator.computedParams) { }, { immediate: false, deep: true }); } +const upButtonVisible = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.canFetchOlder.value : props.paginator.canFetchNewer.value; +}); +const upButtonLoading = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.fetchingOlder.value : props.paginator.fetchingNewer.value; +}); + +function upButtonClick() { + if (props.paginator.order.value === 'oldest') { + props.paginator.fetchOlder(); + } else { + props.paginator.fetchNewer(); + } +} + +const downButtonVisible = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.canFetchNewer.value : props.paginator.canFetchOlder.value; +}); +const downButtonLoading = computed(() => { + return props.paginator.order.value === 'oldest' ? props.paginator.fetchingNewer.value : props.paginator.fetchingOlder.value; +}); + +function downButtonClick() { + if (props.paginator.order.value === 'oldest') { + props.paginator.fetchNewer(); + } else { + props.paginator.fetchOlder(); + } +} + defineSlots<{ empty: () => void; default: (props: { items: UnwrapRef<T['items']> }) => void; diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 22fe189a63..174c923bcf 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </section> <section v-else-if="expiration === 'after'"> - <MkInput v-model="after" small type="number" min="1" class="input"> + <MkInput v-model="after" small type="number" :min="1" class="input"> <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> <MkSelect v-model="unit" small> diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue index 002950cdf1..739f55125b 100644 --- a/packages/frontend/src/components/MkPositionSelector.vue +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -44,6 +44,11 @@ const y = defineModel<string>('y', { default: 'center' }); height: 32px; background: var(--MI_THEME-panel); border-radius: 4px; + transition: background 0.1s ease; + + &:not(.active):hover { + background: var(--MI_THEME-buttonHoverBg); + } &.active { background: var(--MI_THEME-accentedBg); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 174a73e0fd..56683b8f8c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -907,6 +907,11 @@ async function post(ev?: MouseEvent) { if (uploader.items.value.some(x => x.uploaded == null)) { await uploadFiles(); + + // アップロード失敗したものがあったら中止 + if (uploader.items.value.some(x => x.uploaded == null)) { + return; + } } let postData = { @@ -954,7 +959,16 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; + const storedAccount = storedAccounts.find(x => x.id === postAccount.value?.id); + if (storedAccount && storedAccount.token != null) { + token = storedAccount.token; + } else { + await os.alert({ + type: 'error', + text: 'cannot find the token of the selected account.', + }); + return; + } } posting.value = true; diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 1f7796bd83..bf332e706e 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -57,7 +57,7 @@ async function _close() { modal.value?.close(); } -function onEsc(ev: KeyboardEvent) { +function onEsc() { _close(); } diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index c792ff3488..89aca5d29b 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -27,6 +27,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; import { i18n } from '@/i18n.js'; import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; +import { haptic } from '@/utility/haptic.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -203,6 +204,8 @@ function moving(event: MouseEvent | TouchEvent) { pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; + + if (isPulledEnough.value) haptic(); } /** diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 9c37eb5e72..c651d3a3f5 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -78,7 +78,7 @@ function subscribe() { // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters return promiseDialog(registration.value.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), + applicationServerKey: urlBase64ToBase64(instance.swPublickey), }) .then(async subscription => { pushSubscription.value = subscription; @@ -131,22 +131,16 @@ function encode(buffer: ArrayBuffer | null) { } /** - * Convert the URL safe base64 string to a Uint8Array + * Convert the URL safe base64 string to a base64 string * @param base64String base64 string */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { +function urlBase64ToBase64(base64String: string): string { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; + return base64; } if (navigator.serviceWorker == null) { diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 67a9094cad..c0acfa8c60 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -167,7 +167,7 @@ function onMouseenter() { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl.value ?? undefined, + anchorElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); @@ -191,7 +191,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl.value ?? undefined, + anchorElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 7d62456e03..2bfdfa7599 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -28,7 +28,7 @@ if (props.withTooltip) { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), { showing, reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'), - targetElement: elRef.value.$el, + anchorElement: elRef.value.$el, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue index 77ca841ad0..971ebc060b 100644 --- a/packages/frontend/src/components/MkReactionTooltip.vue +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> <MkReactionIcon :reaction="reaction" :class="$style.icon" :noStyle="true"/> <div :class="$style.name">{{ reaction.replace('@.', '') }}</div> @@ -20,7 +20,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; defineProps<{ showing: boolean; reaction: string; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index d24e0b15bf..1c785f0fd1 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="340" @closed="emit('closed')"> <div :class="$style.root"> <div :class="$style.reaction"> <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/> @@ -33,7 +33,7 @@ defineProps<{ reaction: string; users: Misskey.entities.UserLite[]; count: number; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 7d76dffa5a..d96f0e2420 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -38,6 +38,7 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { noteEvents } from '@/composables/use-note-capture.js'; import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ noteId: Misskey.entities.Note['id']; @@ -57,18 +58,22 @@ const emit = defineEmits<{ const buttonEl = useTemplateRef('buttonEl'); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); -const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); const canToggle = computed(() => { + const emoji = customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction); + // TODO - //return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); - return !props.reaction.match(/@\w/) && $i && emoji.value; + //return !props.reaction.match(/@\w/) && $i && emoji && checkReactionPermissions($i, props.note, emoji); + return !props.reaction.match(/@\w/) && $i && emoji; }); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.'); async function toggleReaction() { if (!canToggle.value) return; + if ($i == null) return; + + const me = $i; const oldReaction = props.myReaction; if (oldReaction) { @@ -80,6 +85,7 @@ async function toggleReaction() { if (oldReaction !== props.reaction) { sound.playMisskeySfx('reaction'); + haptic(); } if (mock) { @@ -91,7 +97,7 @@ async function toggleReaction() { noteId: props.noteId, }).then(() => { noteEvents.emit(`unreacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: oldReaction, }); if (oldReaction !== props.reaction) { @@ -99,10 +105,12 @@ async function toggleReaction() { noteId: props.noteId, reaction: props.reaction, }).then(() => { + const emoji = customEmojisMap.get(emojiName.value); + if (emoji == null) return; noteEvents.emit(`reacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: props.reaction, - emoji: emoji.value, + emoji: emoji, }); }); } @@ -118,6 +126,7 @@ async function toggleReaction() { } sound.playMisskeySfx('reaction'); + haptic(); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); @@ -128,10 +137,13 @@ async function toggleReaction() { noteId: props.noteId, reaction: props.reaction, }).then(() => { + const emoji = customEmojisMap.get(emojiName.value); + if (emoji == null) return; + noteEvents.emit(`reacted:${props.noteId}`, { - userId: $i!.id, + userId: me.id, reaction: props.reaction, - emoji: emoji.value, + emoji: emoji, }); }); // TODO: 上位コンポーネントでやる @@ -214,6 +226,8 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { + if (buttonEl.value == null) return; + const reactions = await misskeyApiGet('notes/reactions', { noteId: props.noteId, type: props.reaction, @@ -228,7 +242,7 @@ if (!mock) { reaction: props.reaction, users, count: props.count, - targetElement: buttonEl.value, + anchorElement: buttonEl.value, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index fc7ba50fb3..f1cc98def4 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -105,9 +105,7 @@ async function addRole() { .map(r => ({ text: r.name, value: r })); const { canceled, result: role } = await os.select({ items }); - if (canceled) { - return; - } + if (canceled || role == null) return; selectedRoleIds.value.push(role.id); } diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 485d163ac4..9cbaf676c7 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -39,13 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } 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'; - +<script lang="ts"> type ItemOption = { type?: 'option'; value: string | number | null; @@ -60,11 +54,32 @@ type ItemGroup = { export type MkSelectItem = ItemOption | ItemGroup; +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 + : never; +</script> + +<script lang="ts" setup generic="T extends MkSelectItem[]"> +import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } 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: string | number | null; + modelValue: ValuesOfItems<T>; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -73,11 +88,11 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; - items?: MkSelectItem[]; + items?: T; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: string | number | null): void; + (ev: 'update:modelValue', value: ValuesOfItems<T>): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue index 65e0d6d9de..1d2dfed297 100644 --- a/packages/frontend/src/components/MkServerSetupWizard.vue +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-planet"></i></template> <div class="_gaps_s"> - <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}</div> + <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}<br><MkLink target="_blank" url="https://wikipedia.org/wiki/Fediverse">{{ i18n.ts.learnMore }}</MkLink></div> <MkRadios v-model="q_federation" :vertical="true"> <option value="yes">{{ i18n.ts.yes }}</option> @@ -63,6 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRadios> <MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo> + + <MkSwitch v-if="q_federation === 'yes'" v-model="q_remoteContentsCleaning"> + <template #label>{{ i18n.ts._serverSetupWizard.remoteContentsCleaning }}</template> + <template #caption>{{ i18n.ts._serverSetupWizard.remoteContentsCleaning_description }}</template> + </MkSwitch> </div> </MkFolder> @@ -111,6 +116,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ serverSettings.federation === 'none' ? i18n.ts.no : i18n.ts.all }}</div> </div> <div> + <div><b>{{ i18n.ts._serverSettings.remoteNotesCleaning }}:</b></div> + <div>{{ serverSettings.enableRemoteNotesCleaning ? i18n.ts.yes : i18n.ts.no }}</div> + </div> + <div> <div><b>FTT:</b></div> <div>{{ serverSettings.enableFanoutTimeline ? i18n.ts.yes : i18n.ts.no }}</div> </div> @@ -124,6 +133,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div> + <div><b>{{ i18n.ts._serverSettings.entrancePageStyle }}:</b></div> + <div>{{ serverSettings.clientOptions.entrancePageStyle }}</div> + </div> + + <div> <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div> <div>{{ defaultPolicies.rateLimitFactor }}</div> </div> @@ -185,7 +199,9 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkLink from '@/components/MkLink.vue'; const emit = defineEmits<{ (ev: 'finished'): void; @@ -200,6 +216,7 @@ const q_name = ref(''); const q_use = ref('single'); const q_scale = ref('small'); const q_federation = ref('yes'); +const q_remoteContentsCleaning = ref(true); const q_adminName = ref(''); const q_adminEmail = ref(''); @@ -217,9 +234,13 @@ const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => { emailRequiredForSignup: q_use.value === 'open', enableIpLogging: q_use.value === 'open', federation: q_federation.value === 'yes' ? 'all' : 'none', + enableRemoteNotesCleaning: q_remoteContentsCleaning.value, enableFanoutTimeline: true, enableFanoutTimelineDbFallback: q_use.value === 'single', enableReactionsBuffering, + clientOptions: { + entrancePageStyle: q_use.value === 'open' ? 'classic' : 'simple', + }, }; }); diff --git a/packages/frontend/src/components/MkServerSetupWizardDialog.vue b/packages/frontend/src/components/MkServerSetupWizardDialog.vue new file mode 100644 index 0000000000..ea2c5dd47f --- /dev/null +++ b/packages/frontend/src/components/MkServerSetupWizardDialog.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="windowEl" + :withOkButton="false" + :okButtonDisabled="false" + :width="500" + :height="600" + @close="onCloseModalWindow" + @closed="emit('closed')" +> + <template #header>Server setup wizard</template> + <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;"> + <Suspense> + <template #default> + <MkServerSetupWizard @finished="onWizardFinished"/> + </template> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { useTemplateRef } from 'vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkServerSetupWizard from '@/components/MkServerSetupWizard.vue'; + +const emit = defineEmits<{ + (ev: 'closed'), +}>(); + +const windowEl = useTemplateRef('windowEl'); + +function onWizardFinished() { + windowEl.value?.close(); +} + +function onCloseModalWindow() { + windowEl.value?.close(); +} +</script> + +<style module lang="scss"> +.root { + max-height: 410px; + height: 410px; + display: flex; + flex-direction: column; +} +</style> diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue index cd003a39df..3b3b6a0b73 100644 --- a/packages/frontend/src/components/MkSignin.password.vue +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> - <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha"/> + <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha" :sitekey="null"/> </div> <MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index 3e50bdefd2..bc6ebf0918 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -32,9 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-for="(note, i) in paginator.items.value" :key="note.id"> <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id"> <div :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> <MkNote :class="$style.note" :note="note" :withHardMute="true"/> </div> @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/> </template> </component> - <button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> <MkLoading v-else :inline="true"/> </button> @@ -297,76 +297,97 @@ function prepend(note: Misskey.entities.Note & MisskeyEntity) { } } -let connection: Misskey.IChannelConnection | null = null; -let connection2: Misskey.IChannelConnection | null = null; - const stream = store.s.realtimeMode ? useStream() : null; +const connections = { + antenna: null as Misskey.IChannelConnection<Misskey.Channels['antenna']> | null, + homeTimeline: null as Misskey.IChannelConnection<Misskey.Channels['homeTimeline']> | null, + localTimeline: null as Misskey.IChannelConnection<Misskey.Channels['localTimeline']> | null, + hybridTimeline: null as Misskey.IChannelConnection<Misskey.Channels['hybridTimeline']> | null, + globalTimeline: null as Misskey.IChannelConnection<Misskey.Channels['globalTimeline']> | null, + main: null as Misskey.IChannelConnection<Misskey.Channels['main']> | null, + userList: null as Misskey.IChannelConnection<Misskey.Channels['userList']> | null, + channel: null as Misskey.IChannelConnection<Misskey.Channels['channel']> | null, + roleTimeline: null as Misskey.IChannelConnection<Misskey.Channels['roleTimeline']> | null, +}; + function connectChannel() { if (stream == null) return; if (props.src === 'antenna') { if (props.antenna == null) return; - connection = stream.useChannel('antenna', { + connections.antenna = stream.useChannel('antenna', { antennaId: props.antenna, }); + connections.antenna.on('note', prepend); } else if (props.src === 'home') { - connection = stream.useChannel('homeTimeline', { + connections.homeTimeline = stream.useChannel('homeTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }); - connection2 = stream.useChannel('main'); + connections.main = stream.useChannel('main'); + connections.homeTimeline.on('note', prepend); } else if (props.src === 'local') { - connection = stream.useChannel('localTimeline', { + connections.localTimeline = stream.useChannel('localTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); + connections.localTimeline.on('note', prepend); } else if (props.src === 'social') { - connection = stream.useChannel('hybridTimeline', { + connections.hybridTimeline = stream.useChannel('hybridTimeline', { withRenotes: props.withRenotes, withReplies: props.withReplies, withFiles: props.onlyFiles ? true : undefined, }); + connections.hybridTimeline.on('note', prepend); } else if (props.src === 'global') { - connection = stream.useChannel('globalTimeline', { + connections.globalTimeline = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }); + connections.globalTimeline.on('note', prepend); } else if (props.src === 'mentions') { - connection = stream.useChannel('main'); - connection.on('mention', prepend); + connections.main = stream.useChannel('main'); + connections.main.on('mention', prepend); } else if (props.src === 'directs') { const onNote = note => { if (note.visibility === 'specified') { prepend(note); } }; - connection = stream.useChannel('main'); - connection.on('mention', onNote); + connections.main = stream.useChannel('main'); + connections.main.on('mention', onNote); } else if (props.src === 'list') { if (props.list == null) return; - connection = stream.useChannel('userList', { + connections.userList = stream.useChannel('userList', { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); + connections.userList.on('note', prepend); } else if (props.src === 'channel') { if (props.channel == null) return; - connection = stream.useChannel('channel', { + connections.channel = stream.useChannel('channel', { channelId: props.channel, }); + connections.channel.on('note', prepend); } else if (props.src === 'role') { if (props.role == null) return; - connection = stream.useChannel('roleTimeline', { + connections.roleTimeline = stream.useChannel('roleTimeline', { roleId: props.role, }); + connections.roleTimeline.on('note', prepend); } - if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); } function disconnectChannel() { - if (connection) connection.dispose(); - if (connection2) connection2.dispose(); + for (const key in connections) { + const conn = connections[key as keyof typeof connections]; + if (conn != null) { + conn.dispose(); + connections[key as keyof typeof connections] = null; + } + } } if (store.s.realtimeMode) { diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue index 4617e659c8..15e8e2105f 100644 --- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only > <div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item"> <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date"> - <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.prevText }}</span> <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> - <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span> + <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.nextText }} <i class="ti ti-chevron-down"></i></span> </div> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type) && 'note' in notification" :class="$style.content" :note="notification.note" :withHardMute="true"/> <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/> </div> </component> - <button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> + <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div> <MkLoading v-else/> </button> @@ -59,7 +59,7 @@ import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-sep import { Paginator } from '@/utility/paginator.js'; const props = defineProps<{ - excludeTypes?: typeof notificationTypes[number][]; + excludeTypes?: typeof notificationTypes[number][] | null; }>(); const rootEl = useTemplateRef('rootEl'); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 3f8d92a61d..dbc673333c 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -52,9 +52,9 @@ SPDX-License-Identifier: AGPL-3.0-only {{ item.label }} </template> <template v-else> - <span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span> + <span style="opacity: 0.7; font-size: 90%; word-break: break-word;">{{ item.parentLabels.join(' > ') }}</span> <br> - <span>{{ item.label }}</span> + <span style="word-break: break-word;">{{ item.label }}</span> </template> </span> </MkA> @@ -95,7 +95,7 @@ export type SuperMenuDef = { <script lang="ts" setup> import { useTemplateRef, ref, watch, nextTick, computed } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; -import type { SearchIndexItem } from '@/utility/settings-search-index.js'; +import type { SearchIndexItem } from '@/utility/inapp-search.js'; import MkInput from '@/components/MkInput.vue'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; @@ -165,12 +165,28 @@ watch(rawSearchQuery, (value) => { }); }; - for (const item of searchIndexItemById.values()) { - if ( - compareStringIncludes(item.label, value) || - item.keywords.some((x) => compareStringIncludes(x, value)) - ) { + // label, keywords, texts の順に優先して表示 + + let items = Array.from(searchIndexItemById.values()); + + for (const item of items) { + if (compareStringIncludes(item.label, value)) { + addSearchResult(item); + items = items.filter(i => i.id !== item.id); + } + } + + for (const item of items) { + if (item.keywords.some((x) => compareStringIncludes(x, value))) { + addSearchResult(item); + items = items.filter(i => i.id !== item.id); + } + } + + for (const item of items) { + if (item.texts.some((x) => compareStringIncludes(x, value))) { addSearchResult(item); + items = items.filter(i => i.id !== item.id); } } } @@ -186,7 +202,7 @@ function searchOnKeyDown(ev: KeyboardEvent) { if (ev.key === 'Enter' && searchSelectedIndex.value != null) { ev.preventDefault(); - router.push(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); + router.pushByPath(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); } else if (ev.key === 'ArrowDown') { ev.preventDefault(); const current = searchSelectedIndex.value ?? -1; diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 92359b773a..9a2bea3616 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { toRefs } from 'vue'; import type { Ref } from 'vue'; import XButton from '@/components/MkSwitch.button.vue'; +import { haptic } from '@/utility/haptic.js'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -48,6 +49,8 @@ const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); emit('change', !checked.value); + + haptic(); }; </script> diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue index 5c4a67b026..57fb6548ba 100644 --- a/packages/frontend/src/components/MkTabs.vue +++ b/packages/frontend/src/components/MkTabs.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.tabs"> +<div :class="[$style.tabs, { [$style.centered]: props.centered }]"> <div :class="$style.tabsInner"> <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="tabHighlightEl" - :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation, [$style.tabHighlightUpper]: tabHighlightUpper }]" ></div> </div> </template> @@ -39,17 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only export type Tab = { key: string; onClick?: (ev: MouseEvent) => void; -} & ( - | { - iconOnly?: false; - title: string; - icon?: string; - } - | { - iconOnly: true; - icon: string; - } -); + iconOnly?: boolean; + title: string; + icon?: string; +}; </script> <script lang="ts" setup> @@ -59,6 +52,8 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; + centered?: boolean; + tabHighlightUpper?: boolean; }>(), { tabs: () => ([] as Tab[]), }); @@ -169,6 +164,16 @@ onUnmounted(() => { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; + + &.centered { + text-align: center; + } +} + +@container (max-width: 450px) { + .tabs { + font-size: 80%; + } } .tabsInner { @@ -227,5 +232,10 @@ onUnmounted(() => { &.animate { transition: width 0.15s ease, left 0.15s ease; } + + &.tabHighlightUpper { + top: 0; + bottom: auto; + } } </style> diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts index ac932c8342..9e8c2d8917 100644 --- a/packages/frontend/src/components/MkTagItem.stories.impl.ts +++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkTagItem from './MkTagItem.vue'; diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 3fe80f4ab4..aa041c88e5 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -31,7 +31,7 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showing: boolean; - targetElement?: HTMLElement; + anchorElement?: HTMLElement; x?: number; y?: number; text?: string; @@ -58,7 +58,7 @@ const zIndex = os.claimZIndex('high'); function setPosition() { if (el.value == null) return; const data = calcPopupPosition(el.value, { - anchorElement: props.targetElement, + anchorElement: props.anchorElement, direction: props.direction, align: 'center', innerMargin: props.innerMargin, diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 79ab464cb0..eba8e5472c 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -4,10 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> +<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> + <div v-if="isBeta" :class="$style.beta">{{ i18n.ts.thankYouForTestingBeta }}</div> <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> <MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton> </div> @@ -25,6 +26,8 @@ import { confetti } from '@/utility/confetti.js'; const modal = useTemplateRef('modal'); +const isBeta = version.includes('-beta') || version.includes('-alpha') || version.includes('-rc'); + function whatIsNew() { modal.value?.close(); window.open(`https://misskey-hub.net/docs/releases/#_${version.replace(/\./g, '')}`, '_blank'); @@ -58,6 +61,10 @@ onMounted(() => { margin: 1em 0; } +.beta { + margin: 1em 0; +} + .gotIt { margin: 8px 0 0 0; } diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index fd36d6a82b..09558b0319 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -20,7 +20,7 @@ import { prefer } from '@/preferences.js'; const props = defineProps<{ showing: boolean; url: string; - source: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ @@ -32,9 +32,9 @@ const top = ref(0); const left = ref(0); onMounted(() => { - const rect = props.source.getBoundingClientRect(); - const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX; - const y = rect.top + props.source.offsetHeight + window.scrollY; + const rect = props.anchorElement.getBoundingClientRect(); + const x = Math.max((rect.left + (props.anchorElement.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX; + const y = rect.top + props.anchorElement.offsetHeight + window.scrollY; top.value = y; left.value = x; diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index 0cb7f22e93..d0bfebc463 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="250" @closed="emit('closed')"> <div :class="$style.root"> <div v-for="u in users" :key="u.id" :class="$style.user"> <MkAvatar :class="$style.avatar" :user="u"/> @@ -23,7 +23,7 @@ defineProps<{ showing: boolean; users: Misskey.entities.UserLite[]; count: number; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 3801195da6..88b934bb58 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -53,7 +53,7 @@ const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; localOnly: boolean; - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; isReplyVisibilitySpecified?: boolean; }>(), { }); diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index a809e9040d..50520b5d9d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div v-if="stats" :class="$style.stats"> + <div v-if="stats && instance.clientOptions.showActivitiesForVisitor !== false" :class="$style.stats"> <div :class="[$style.statsItem, $style.panel]"> <div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div> <div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div> @@ -40,13 +40,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div> </div> </div> - <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> + <div v-if="instance.policies.ltlAvailable && instance.clientOptions.showTimelineForVisitor !== false" :class="[$style.tl, $style.panel]"> <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> <div :class="$style.tlBody"> <MkStreamingNotesTimeline src="local"/> </div> </div> - <div :class="$style.panel"> + <div v-if="instance.clientOptions.showActivitiesForVisitor !== false" :class="$style.panel"> <XActiveUsersChart/> </div> </div> @@ -55,12 +55,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { instanceName } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -68,13 +69,14 @@ import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; import { openInstanceMenu } from '@/ui/_common_/common.js'; -import type { MenuItem } from '@/types/menu.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); -misskeyApi('stats', {}).then((res) => { - stats.value = res; -}); +if (instance.clientOptions.showActivitiesForVisitor !== false) { + misskeyApi('stats', {}).then((res) => { + stats.value = res; + }); +} function signin() { const { dispose } = os.popup(XSigninDialog, { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index f606b0b001..08a018ea9b 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -51,6 +51,7 @@ export type DefaultStoredWidget = { <script lang="ts" setup> import { defineAsyncComponent, ref, computed } from 'vue'; +import { isLink } from '@@/js/is-link.js'; import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; @@ -58,7 +59,6 @@ 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 { isLink } from '@@/js/is-link.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -81,7 +81,7 @@ const emit = defineEmits<{ (ev: 'updateWidgets', widgets: Widget[]): void; (ev: 'addWidget', widget: Widget): void; (ev: 'removeWidget', widget: Widget): void; - (ev: 'updateWidget', widget: Partial<Widget>): void; + (ev: 'updateWidget', widget: { id: Widget['id']; data: Widget['data']; }): void; (ev: 'exit'): void; }>(); @@ -104,7 +104,7 @@ const addWidget = () => { const removeWidget = (widget) => { emit('removeWidget', widget); }; -const updateWidget = (id, data) => { +const updateWidget = (id: Widget['id'], data: Widget['data']) => { emit('updateWidget', { id, data }); }; diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 821f07510b..3b23acf612 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading/> </div> <div v-else-if="resolved"> - <slot :result="result"></slot> + <slot :result="result as T"></slot> </div> <div v-else> <div :class="$style.error"> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 4004db5b12..ae1b4549ec 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -64,7 +64,7 @@ function onContextmenu(ev) { icon: 'ti ti-player-eject', text: i18n.ts.showInPage, action: () => { - router.push(props.to, 'forcePage'); + router.pushByPath(props.to, 'forcePage'); }, }, { type: 'divider' }, { icon: 'ti ti-external-link', @@ -99,6 +99,6 @@ function nav(ev: MouseEvent) { return openWindow(); } - router.push(props.to, ev.ctrlKey ? 'forcePage' : null); + router.pushByPath(props.to, ev.ctrlKey ? 'forcePage' : null); } </script> diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index c5a928b5cf..07e06a6897 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -2,11 +2,10 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ + import { expect, userEvent, waitFor, within } from '@storybook/test'; -import type { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; const common = { @@ -68,7 +67,7 @@ const common = { await expect(imgAgain).toBeInTheDocument(); }, args: { - prefer: [], + preferForms: [], specify: { id: 'someadid', ratio: 1, diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 2f55700b47..c592079f03 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -52,7 +52,7 @@ import { prefer } from '@/preferences.js'; type Ad = (typeof instance)['ads'][number]; const props = defineProps<{ - preferForms: string[]; + preferForms?: string[]; specify?: Ad; }>(); @@ -71,7 +71,7 @@ const choseAd = (): Ad | null => { ratio: 0, } : ad); - let ads = allAds.filter(ad => props.preferForms.includes(ad.place)); + let ads = props.preferForms ? allAds.filter(ad => props.preferForms!.includes(ad.place)) : allAds; if (ads.length === 0) { ads = allAds.filter(ad => ad.place === 'square'); diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 8a9cc5286a..c2548cc7be 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -84,7 +84,6 @@ const bound = computed(() => props.link : {}); const url = computed(() => { - if (props.user.avatarUrl == null) return null; if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); return props.user.avatarUrl; }); diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index e150493a18..497cdfb3d5 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, waitFor } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 15938d0495..0ac6304054 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - + import { waitFor } from '@storybook/test'; import MkPageHeader from './MkPageHeader.vue'; import type { StoryObj } from '@storybook/vue3'; @@ -59,6 +59,7 @@ export const Icon = { { ...OneTab.args.tabs[0], icon: 'ti ti-home', + title: 'Home', }, ], }, @@ -71,6 +72,7 @@ export const IconOnly = { { key: Icon.args.tabs[0].key, icon: Icon.args.tabs[0].icon, + title: Icon.args.tabs[0].title, iconOnly: true, }, ], diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index f2173b2e22..a1b57f30d9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -39,17 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only export type Tab = { key: string; onClick?: (ev: MouseEvent) => void; -} & ( - | { - iconOnly?: false; - title: string; - icon?: string; - } - | { - iconOnly: true; - icon: string; - } -); + iconOnly?: boolean; + title: string; + icon?: string; +}; </script> <script lang="ts" setup> @@ -59,7 +52,7 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; - rootEl?: HTMLElement; + rootEl?: HTMLElement | null; }>(), { tabs: () => ([] as Tab[]), }); diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 542c3d8d12..2f4de840db 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -52,6 +52,7 @@ export type PageHeaderProps = { actions?: PageHeaderItem[] | null; thin?: boolean; hideTitle?: boolean; + canOmitTitle?: boolean; displayMyAvatar?: boolean; }; </script> @@ -77,7 +78,7 @@ const emit = defineEmits<{ const injectedPageMetadata = inject(DI.pageMetadata, ref(null)); const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); -const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); +const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle || (props.canOmitTitle && props.tabs.length > 0)); const thin_ = props.thin || inject('shouldHeaderThin', false); const el = useTemplateRef('el'); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 914c495d7a..159af6f11e 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -62,7 +62,7 @@ if (props.showUrlPreview && isEnabledUrlPreview.value) { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el.value instanceof HTMLElement ? el.value : el.value?.$el, + anchorElement: el.value instanceof HTMLElement ? el.value : el.value?.$el, }, { closed: () => dispose(), }); diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index d90afb652e..d368dee88a 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -6,14 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> + <template #header> + <MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/> + <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"> <slot></slot> </MkSwiper> <slot v-else></slot> </div> - <template #footer><slot name="footer"></slot></template> + <template #footer> + <slot name="footer"></slot> + <div v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" :class="$style.footerTabs"> + <MkTabs v-model:tab="tab" :tabs="props.tabs" :centered="true" :tabHighlightUpper="true"/> + </div> + </template> </MkStickyContainer> </div> </template> @@ -26,6 +34,7 @@ import { useScrollPositionKeeper } from '@/composables/use-scroll-position-keepe import MkSwiper from '@/components/MkSwiper.vue'; import { useRouter } from '@/router.js'; import { prefer } from '@/preferences.js'; +import MkTabs from '@/components/MkTabs.vue'; const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; @@ -40,6 +49,11 @@ const pageHeaderProps = computed(() => { return rest; }); +const pageHeaderPropsWithoutTabs = computed(() => { + const { reversed, tabs, ...rest } = props; + return rest; +}); + const tab = defineModel<string>('tab'); const rootEl = useTemplateRef('rootEl'); @@ -68,4 +82,11 @@ defineExpose({ .body, .swiper { min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); } + +.footerTabs { + background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-top: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 27f7b18559..d42beb531d 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -30,19 +30,21 @@ const props = defineProps<{ router?: Router; }>(); -const router = props.router ?? inject(DI.router); +const _router = props.router ?? inject(DI.router); -if (router == null) { +if (_router == null) { throw new Error('no router provided'); } +const router = _router; + const viewId = randomId(); provide(DI.viewId, viewId); const currentDepth = inject(DI.routerCurrentDepth, 0); provide(DI.routerCurrentDepth, currentDepth + 1); -const current = router.current!; +const current = router.current; const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); let currentRoutePath = current.route.path; @@ -52,14 +54,10 @@ router.useListener('change', ({ resolved }) => { if (resolved == null || 'redirect' in resolved.route) return; if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return; - function _() { - currentPageComponent.value = resolved.route.component; - currentPageProps.value = resolved.props; - key.value = router.getCurrentFullPath(); - currentRoutePath = resolved.route.path; - } - - _(); + currentPageComponent.value = resolved.route.component; + currentPageProps.value = resolved.props; + key.value = router.getCurrentFullPath(); + currentRoutePath = resolved.route.path; }); </script> diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchText.vue index 27a284faf0..27a284faf0 100644 --- a/packages/frontend/src/components/global/SearchKeyword.vue +++ b/packages/frontend/src/components/global/SearchText.vue diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index c95c74aef3..4c56767608 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -76,7 +76,7 @@ function mount() { function back() { const prev = tabs.value[tabs.value.length - 2]; tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)]; - router.replace(prev.fullPath); + router?.replaceByPath(prev.fullPath); } router.useListener('change', ({ resolved }) => { @@ -87,7 +87,7 @@ router.useListener('change', ({ resolved }) => { const fullPath = router.getCurrentFullPath(); if (tabs.value.some(tab => tab.routePath === routePath && deepEqual(resolved.props, tab.props))) { - const newTabs = []; + const newTabs = [] as typeof tabs.value; for (const tab of tabs.value) { newTabs.push(tab); diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue index fd289c6cd9..6cd4f9ec1c 100644 --- a/packages/frontend/src/components/grid/MkCellTooltip.vue +++ b/packages/frontend/src/components/grid/MkCellTooltip.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')"> +<MkTooltip ref="tooltip" :showing="showing" :anchorElement="anchorElement" :maxWidth="250" @closed="emit('closed')"> <div :class="$style.root"> {{ content }} </div> @@ -18,7 +18,7 @@ import MkTooltip from '@/components/MkTooltip.vue'; defineProps<{ showing: boolean; content: string; - targetElement: HTMLElement; + anchorElement: HTMLElement; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 444509e6b3..0f326b14ca 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -300,7 +300,7 @@ useTooltip(rootEl, (showing) => { const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), { showing, content, - targetElement: rootEl.value!, + anchorElement: rootEl.value!, }, { closed: () => { result.dispose(); diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts index f85bf146e8..5ed8465299 100644 --- a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts +++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { ref } from 'vue'; import { commonHandlers } from '../../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 19766e8575..6b1b80695f 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -31,7 +31,7 @@ import PageWithHeader from './global/PageWithHeader.vue'; import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; import SearchLabel from './global/SearchLabel.vue'; -import SearchKeyword from './global/SearchKeyword.vue'; +import SearchText from './global/SearchText.vue'; import SearchIcon from './global/SearchIcon.vue'; import type { App } from 'vue'; @@ -71,7 +71,7 @@ export const components = { PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, SearchLabel: SearchLabel, - SearchKeyword: SearchKeyword, + SearchText: SearchText, SearchIcon: SearchIcon, }; @@ -105,7 +105,7 @@ declare module '@vue/runtime-core' { PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; SearchLabel: typeof SearchLabel; - SearchKeyword: typeof SearchKeyword; + SearchText: typeof SearchText; SearchIcon: typeof SearchIcon; } } |