diff options
Diffstat (limited to 'packages/frontend/src/components')
125 files changed, 3627 insertions, 659 deletions
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 8ec3ec0505..ab7bafc47a 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -153,7 +153,7 @@ onMounted(() => { background: linear-gradient(0deg, #ffee20, #eb7018); } - &:before { + &::before { content: ""; display: block; position: absolute; @@ -173,7 +173,7 @@ onMounted(() => { background: linear-gradient(0deg, #e1e1e1, #7c7c7c); } - &:before { + &::before { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts new file mode 100644 index 0000000000..1749e07a4e --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -0,0 +1,62 @@ +/* + * 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 { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkAntennaEditor from './MkAntennaEditor.vue'; +export const Default = { + render(args) { + return { + components: { + MkAntennaEditor, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + created: action('created'), + updated: action('updated'), + deleted: action('deleted'), + }; + }, + }, + template: '<MkAntennaEditor v-bind="props" v-on="events" />', + }; + }, + args: { + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/antennas/create', async ({ request }) => { + action('POST /api/antennas/create')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/update', async ({ request }) => { + action('POST /api/antennas/update')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/delete', async ({ request }) => { + action('POST /api/antennas/delete')(await request.json()); + return HttpResponse.json(); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAntennaEditor>; diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue new file mode 100644 index 0000000000..cb7ee3d6ca --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -0,0 +1,175 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkSpacer :contentMax="700"> + <div> + <div class="_gaps_m"> + <MkInput v-model="name"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkSelect v-model="src"> + <template #label>{{ i18n.ts.antennaSource }}</template> + <option value="all">{{ i18n.ts._antennaSources.all }}</option> + <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> + <option value="users">{{ i18n.ts._antennaSources.users }}</option> + <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> + <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option> + </MkSelect> + <MkSelect v-if="src === 'list'" v-model="userListId"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> + </MkSelect> + <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> + <template #label>{{ i18n.ts.users }}</template> + <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> + </MkTextarea> + <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch> + <MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch> + <MkTextarea v-model="keywords"> + <template #label>{{ i18n.ts.antennaKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="excludeKeywords"> + <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> + <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> + </div> + <div :class="$style.actions"> + <div class="_buttons"> + <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { watch, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { deepMerge } from '@/scripts/merge.js'; +import type { DeepPartial } from '@/scripts/merge.js'; + +type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { + id?: string; + createdAt?: string; + updatedAt?: string; +}; + +const props = defineProps<{ + antenna?: DeepPartial<PartialAllowedAntenna>; +}>(); + +const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, { + name: '', + src: 'all', + userListId: null, + users: [], + keywords: [], + excludeKeywords: [], + excludeBots: false, + withReplies: false, + caseSensitive: false, + localOnly: false, + withFile: false, + isActive: true, + hasUnreadNote: false, + notify: false, +}); + +const emit = defineEmits<{ + (ev: 'created', newAntenna: Misskey.entities.Antenna): void, + (ev: 'updated', editedAntenna: Misskey.entities.Antenna): void, + (ev: 'deleted'): void, +}>(); + +const name = ref<string>(initialAntenna.name); +const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src); +const userListId = ref<string | null>(initialAntenna.userListId); +const users = ref<string>(initialAntenna.users.join('\n')); +const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); +const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); +const caseSensitive = ref<boolean>(initialAntenna.caseSensitive); +const localOnly = ref<boolean>(initialAntenna.localOnly); +const excludeBots = ref<boolean>(initialAntenna.excludeBots); +const withReplies = ref<boolean>(initialAntenna.withReplies); +const withFile = ref<boolean>(initialAntenna.withFile); +const userLists = ref<Misskey.entities.UserList[] | null>(null); + +watch(() => src.value, async () => { + if (src.value === 'list' && userLists.value === null) { + userLists.value = await misskeyApi('users/lists/list'); + } +}); + +async function saveAntenna() { + const antennaData = { + name: name.value, + src: src.value, + userListId: userListId.value, + excludeBots: excludeBots.value, + withReplies: withReplies.value, + withFile: withFile.value, + caseSensitive: caseSensitive.value, + localOnly: localOnly.value, + users: users.value.trim().split('\n').map(x => x.trim()), + keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')), + }; + + if (initialAntenna.id == null) { + const res = await os.apiWithDialog('antennas/create', antennaData); + emit('created', res); + } else { + const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id }); + emit('updated', res); + } +} + +async function deleteAntenna() { + if (initialAntenna.id == null) return; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }), + }); + if (canceled) return; + + await misskeyApi('antennas/delete', { + antennaId: initialAntenna.id, + }); + + os.success(); + emit('deleted'); +} + +function addUser() { + os.selectUser({ includeSelf: true }).then(user => { + users.value = users.value.trim(); + users.value += '\n@' + Misskey.acct.toString(user as any); + users.value = users.value.trim(); + }); +} +</script> + +<style lang="scss" module> +.actions { + margin-top: 16px; + padding: 24px 0; + border-top: solid 0.5px var(--divider); +} +</style> diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts new file mode 100644 index 0000000000..1c6ca83b47 --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -0,0 +1,63 @@ +/* + * 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 { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkAntennaEditorDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + created: action('created'), + updated: action('updated'), + deleted: action('deleted'), + closed: action('closed'), + }; + }, + }, + template: '<MkAntennaEditorDialog v-bind="props" v-on="events" />', + }; + }, + args: { + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/antennas/create', async ({ request }) => { + action('POST /api/antennas/create')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/update', async ({ request }) => { + action('POST /api/antennas/update')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/delete', async ({ request }) => { + action('POST /api/antennas/delete')(await request.json()); + return HttpResponse.json(); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAntennaEditorDialog>; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue new file mode 100644 index 0000000000..6d815d29f3 --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue @@ -0,0 +1,63 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :withOkButton="false" + :width="500" + :height="550" + @close="close()" + @closed="emit('closed')" +> + <template #header>{{ antenna == null ? i18n.ts.createAntenna : i18n.ts.editAntenna }}</template> + <XAntennaEditor + :antenna="antenna" + @created="onAntennaCreated" + @updated="onAntennaUpdated" + @deleted="onAntennaDeleted" + /> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { shallowRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import XAntennaEditor from '@/components/MkAntennaEditor.vue'; +import { i18n } from '@/i18n.js'; + +defineProps<{ + antenna?: Misskey.entities.Antenna; +}>(); + +const emit = defineEmits<{ + (ev: 'created', newAntenna: Misskey.entities.Antenna): void, + (ev: 'updated', editedAntenna: Misskey.entities.Antenna): void, + (ev: 'deleted'): void, + (ev: 'closed'): void, +}>(); + +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); + +function onAntennaCreated(newAntenna: Misskey.entities.Antenna) { + emit('created', newAntenna); + dialog.value?.close(); +} + +function onAntennaUpdated(editedAntenna: Misskey.entities.Antenna) { + emit('updated', editedAntenna); + dialog.value?.close(); +} + +function onAntennaDeleted() { + emit('deleted'); + dialog.value?.close(); +} + +function close() { + dialog.value?.close(); +} +</script> diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index c8d2797e16..f968fc5861 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :type="type" :name="name" :value="value" + :disabled="disabled" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -55,6 +56,7 @@ const props = defineProps<{ asLike?: boolean; name?: string; value?: string; + disabled?: boolean; }>(); const emit = defineEmits<{ @@ -248,7 +250,6 @@ function onMousedown(evt: MouseEvent): void { } &:focus-visible { - outline: solid 2px var(--focus); outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c64bb47e77..c5b6e0caed 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -104,7 +104,6 @@ async function requestRender() { }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { const { default: Widget } = await import('@mcaptcha/vanilla-glue'); - // @ts-expect-error avoid typecheck error new Widget({ siteKey: { instanceUrl: new URL(props.instanceUrl), diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts new file mode 100644 index 0000000000..b9770670dc --- /dev/null +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, within } from '@storybook/test'; +import { channel } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkChannelFollowButton from './MkChannelFollowButton.vue'; +import { i18n } from '@/i18n.js'; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const Default = { + render(args) { + return { + components: { + MkChannelFollowButton, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChannelFollowButton v-bind="props" />', + }; + }, + args: { + channel: channel(), + full: true, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); + await expect(buttonElement).toHaveTextContent(i18n.ts.follow); + await userEvent.click(buttonElement); + await sleep(1000); + await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow); + await userEvent.click(buttonElement); + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/channels/follow', async ({ request }) => { + action('POST /api/channels/follow')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/channels/unfollow', async ({ request }) => { + action('POST /api/channels/unfollow')(await request.json()); + return HttpResponse.json({}); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkChannelFollowButton>; diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 22566628a8..6dace43fde 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - channel: Record<string, any>; + channel: Misskey.entities.Channel; full?: boolean; }>(), { full: false, }); -const isFollowing = ref<boolean>(props.channel.isFollowing); +const isFollowing = ref(props.channel.isFollowing); const wait = ref(false); async function onClick() { @@ -86,17 +87,7 @@ async function onClick() { } &:focus-visible { - &:after { - content: ""; - pointer-events: none; - position: absolute; - top: -5px; - right: -5px; - bottom: -5px; - left: -5px; - border: 2px solid var(--focus); - border-radius: var(--radius-xl); - } + outline-offset: 2px; } &:hover { diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts new file mode 100644 index 0000000000..f69b20c049 --- /dev/null +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { channel } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkChannelList from './MkChannelList.vue'; +export const Default = { + render(args) { + return { + components: { + MkChannelList, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChannelList v-bind="props" />', + }; + }, + args: { + pagination: { + endpoint: 'channels/search', + limit: 10, + }, + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/channels/search', async ({ request, params }) => { + action('POST /api/channels/search')(await request.json()); + return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [ + channel(), + channel('lastchannel', 'Last Channel', null), + ]); + }), + ], + }, + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>', + }), + ], +} satisfies StoryObj<typeof MkChannelList>; diff --git a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts new file mode 100644 index 0000000000..de0193c78f --- /dev/null +++ b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { channel } from '../../.storybook/fakes.js'; +import MkChannelPreview from './MkChannelPreview.vue'; +export const Default = { + render(args) { + return { + components: { + MkChannelPreview, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChannelPreview v-bind="props" />', + }; + }, + args: { + channel: channel(), + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>', + }), + ], +} satisfies StoryObj<typeof MkChannelPreview>; diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 036e54e6d8..a50416befc 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div style="position: relative;"> - <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt"> + <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt"> <div class="banner" :style="bannerStyle"> <div class="fade"></div> <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> @@ -80,6 +80,7 @@ const bannerStyle = computed(() => { <style lang="scss" scoped> .eftoefju { display: block; + position: relative; overflow: hidden; width: 100%; @@ -87,6 +88,22 @@ const bannerStyle = computed(() => { text-decoration: none; } + &:focus-within { + outline: none; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 0 0 2px var(--focus); + } + } + > .banner { position: relative; width: 100%; diff --git a/packages/frontend/src/components/MkChart.stories.impl.ts b/packages/frontend/src/components/MkChart.stories.impl.ts new file mode 100644 index 0000000000..1bcb9c30d8 --- /dev/null +++ b/packages/frontend/src/components/MkChart.stories.impl.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import { getChartResolver } from '../../.storybook/charts.js'; +import MkChart from './MkChart.vue'; + +const Base = { + render(args) { + return { + components: { + MkChart, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChart v-bind="props" />', + }; + }, + args: { + src: 'federation', + span: 'hour', + nowForChromatic: 1716263640000, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.get('/api/charts/federation', getChartResolver( + ['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'], + )), + http.get('/api/charts/notes', getChartResolver( + ['local.total', 'remote.total'], + { accumulate: true }, + )), + http.get('/api/charts/drive', getChartResolver( + ['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'], + { mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } }, + )), + ], + }, + }, +} satisfies StoryObj<typeof MkChart>; +export const FederationChart = { + ...Base, + args: { + ...Base.args, + src: 'federation', + }, +} satisfies StoryObj<typeof MkChart>; +export const NotesTotalChart = { + ...Base, + args: { + ...Base.args, + src: 'notes-total', + }, +} satisfies StoryObj<typeof MkChart>; +export const DriveChart = { + ...Base, + args: { + ...Base.args, + src: 'drive', + }, +} satisfies StoryObj<typeof MkChart>; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 04b6d2f29c..4b24562249 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only id-denylist violation when setting it. This is causing about 60+ lint issues. As this is part of Chart.js's API it makes sense to disable the check here. */ -import { onMounted, ref, shallowRef, watch, PropType } from 'vue'; +import { onMounted, ref, shallowRef, watch } from 'vue'; import { Chart } from 'chart.js'; +import * as Misskey from 'misskey-js'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; @@ -34,44 +35,63 @@ import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); -const props = defineProps({ - src: { - type: String, - required: true, - }, - args: { - type: Object, - required: false, - }, - limit: { - type: Number, - required: false, - default: 90, - }, - span: { - type: String as PropType<'hour' | 'day'>, - required: true, - }, - detailed: { - type: Boolean, - required: false, - default: false, - }, - stacked: { - type: Boolean, - required: false, - default: false, - }, - bar: { - type: Boolean, - required: false, - default: false, - }, - aspectRatio: { - type: Number, - required: false, - default: null, - }, +type ChartSrc = + | 'federation' + | 'ap-request' + | 'users' + | 'users-total' + | 'active-users' + | 'notes' + | 'local-notes' + | 'remote-notes' + | 'notes-total' + | 'drive' + | 'drive-files' + | 'instance-requests' + | 'instance-users' + | 'instance-users-total' + | 'instance-notes' + | 'instance-notes-total' + | 'instance-ff' + | 'instance-ff-total' + | 'instance-drive-usage' + | 'instance-drive-usage-total' + | 'instance-drive-files' + | 'instance-drive-files-total' + | 'per-user-notes' + | 'per-user-pv' + | 'per-user-following' + | 'per-user-followers' + | 'per-user-drive' + +const props = withDefaults(defineProps<{ + src: ChartSrc; + args?: { + host?: string; + user?: Misskey.entities.UserLite; + withoutAll?: boolean; + }; + limit?: number; + span: 'hour' | 'day'; + detailed?: boolean; + stacked?: boolean; + bar?: boolean; + aspectRatio?: number | null; + nowForChromatic?: number; +}>(), { + args: undefined, + limit: 90, + detailed: false, + stacked: false, + bar: false, + aspectRatio: null, + + /** + * @desc Overwrites current date to fix background lines of chart. + * @ignore Only used for Chromatic. Don't use this for production. + * @see https://github.com/misskey-dev/misskey/pull/13830#issuecomment-2155886151 + */ + nowForChromatic: undefined, }); const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); @@ -94,7 +114,8 @@ const getColor = (i) => { return colorSets[i % colorSets.length]; }; -const now = new Date(); +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const now = props.nowForChromatic != null ? new Date(props.nowForChromatic) : new Date(); let chartInstance: Chart | null = null; let chartData: { series: { diff --git a/packages/frontend/src/components/MkChartLegend.stories.impl.ts b/packages/frontend/src/components/MkChartLegend.stories.impl.ts new file mode 100644 index 0000000000..06146e20e4 --- /dev/null +++ b/packages/frontend/src/components/MkChartLegend.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkChartLegend from './MkChartLegend.vue'; +void MkChartLegend; diff --git a/packages/frontend/src/components/MkChartTooltip.stories.impl.ts b/packages/frontend/src/components/MkChartTooltip.stories.impl.ts new file mode 100644 index 0000000000..289a9e9f27 --- /dev/null +++ b/packages/frontend/src/components/MkChartTooltip.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkChartTooltip from './MkChartTooltip.vue'; +void MkChartTooltip; diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts new file mode 100644 index 0000000000..36313f965d --- /dev/null +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, within } from '@storybook/test'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkClickerGame from './MkClickerGame.vue'; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const Default = { + render(args) { + return { + components: { + MkClickerGame, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkClickerGame v-bind="props" />', + }; + }, + async play({ canvasElement }) { + await sleep(1000); + const canvas = within(canvasElement); + const count = canvas.getByTestId('count'); + await expect(count).toHaveTextContent('0'); + const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); + await userEvent.click(buttonElement); + await expect(count).toHaveTextContent('1'); + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/i/registry/get', async ({ request }) => { + action('POST /api/i/registry/get')(await request.json()); + return HttpResponse.json({ + error: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a', + }, + }, { + status: 400, + }); + }), + http.post('/api/i/registry/set', async ({ request }) => { + action('POST /api/i/registry/set')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + http.post('/api/i/claim-achievement', async ({ request }) => { + action('POST /api/i/claim-achievement')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkClickerGame>; diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 23046bf345..00506fb735 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <div v-if="game.ready" :class="$style.game"> <div :class="$style.cps" class="">{{ number(cps) }}cps</div> - <div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div> + <div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div> <button v-click-anime class="_button" @click="onClick"> <img src="/client-assets/cookie.png" :class="$style.img"> </button> @@ -35,7 +35,9 @@ const prevCookies = ref(0); function onClick(ev: MouseEvent) { const x = ev.clientX; const y = ev.clientY; - os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); + const { dispose } = os.popup(MkPlusOneEffect, { x, y }, { + end: () => dispose(), + }); saveData.value!.cookies++; saveData.value!.totalCookies++; diff --git a/packages/frontend/src/components/MkClipPreview.stories.impl.ts b/packages/frontend/src/components/MkClipPreview.stories.impl.ts new file mode 100644 index 0000000000..62503fb98a --- /dev/null +++ b/packages/frontend/src/components/MkClipPreview.stories.impl.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { clip } from '../../.storybook/fakes.js'; +import MkClipPreview from './MkClipPreview.vue'; +export const Default = { + render(args) { + return { + components: { + MkClipPreview, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkClipPreview v-bind="props" />', + }; + }, + args: { + clip: clip(), + noUserInfo: false, + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>', + }), + ], +} satisfies StoryObj<typeof MkClipPreview>; diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 6299a28e9f..dd550733cb 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -12,10 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div> <div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div> </div> - <div :class="$style.divider"></div> - <div> - <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> - </div> + <template v-if="!props.noUserInfo"> + <div :class="$style.divider"></div> + <div> + <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> + </template> </div> </MkA> </template> @@ -27,9 +29,12 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import number from '@/filters/number.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ clip: Misskey.entities.Clip; -}>(); + noUserInfo?: boolean; +}>(), { + noUserInfo: false, +}); const remaining = computed(() => { return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown; @@ -40,6 +45,14 @@ const remaining = computed(() => { .link { display: block; + &:focus-visible { + outline: none; + + .root { + box-shadow: inset 0 0 0 2px var(--focus); + } + } + &:hover { text-decoration: none; color: var(--accent); diff --git a/packages/frontend/src/components/MkCode.core.stories.impl.ts b/packages/frontend/src/components/MkCode.core.stories.impl.ts new file mode 100644 index 0000000000..91990fffd5 --- /dev/null +++ b/packages/frontend/src/components/MkCode.core.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkCode_core from './MkCode.core.vue'; +void MkCode_core; diff --git a/packages/frontend/src/components/MkCode.stories.impl.ts b/packages/frontend/src/components/MkCode.stories.impl.ts new file mode 100644 index 0000000000..b7e53e8e35 --- /dev/null +++ b/packages/frontend/src/components/MkCode.stories.impl.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import MkCode from './MkCode.vue'; +const code = `for (let i, 100) { + <: if (i % 15 == 0) "FizzBuzz" + elif (i % 3 == 0) "Fizz" + elif (i % 5 == 0) "Buzz" + else i +}`; +export const Default = { + render(args) { + return { + components: { + MkCode, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkCode v-bind="props" />', + }; + }, + args: { + code, + lang: 'is', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCode>; diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index e99bd0f50c..2475e3dc89 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -30,7 +30,7 @@ import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ code: string; diff --git a/packages/frontend/src/components/MkCodeEditor.stories.impl.ts b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts new file mode 100644 index 0000000000..5c410c4886 --- /dev/null +++ b/packages/frontend/src/components/MkCodeEditor.stories.impl.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { action } from '@storybook/addon-actions'; +import MkCodeEditor from './MkCodeEditor.vue'; +const code = `for (let i, 100) { + <: if (i % 15 == 0) "FizzBuzz" + elif (i % 3 == 0) "Fizz" + elif (i % 5 == 0) "Buzz" + else i +}`; +export const Default = { + render(args) { + return { + components: { + MkCodeEditor, + }, + data() { + return { + code, + }; + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'change': action('change'), + 'keydown': action('keydown'), + 'enter': action('enter'), + 'update:modelValue': action('update:modelValue'), + }; + }, + }, + template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />', + }; + }, + args: { + lang: 'aiscript', + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>', + }), + ], +} satisfies StoryObj<typeof MkCodeEditor>; diff --git a/packages/frontend/src/components/MkCodeInline.stories.impl.ts b/packages/frontend/src/components/MkCodeInline.stories.impl.ts new file mode 100644 index 0000000000..51d4d106ff --- /dev/null +++ b/packages/frontend/src/components/MkCodeInline.stories.impl.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import MkCodeInline from './MkCodeInline.vue'; +export const Default = { + render(args) { + return { + components: { + MkCodeInline, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkCodeInline v-bind="props"/>', + }; + }, + args: { + code: '<: "Hello, world!"', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCodeInline>; diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts new file mode 100644 index 0000000000..61383e2cae --- /dev/null +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { action } from '@storybook/addon-actions'; +import MkColorInput from './MkColorInput.vue'; +export const Default = { + render(args) { + return { + components: { + MkColorInput, + }, + data() { + return { + color: '#cccccc', + }; + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'update:modelValue': action('update:modelValue'), + }; + }, + }, + template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />', + }; + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>', + }), + ], +} satisfies StoryObj<typeof MkColorInput>; diff --git a/packages/frontend/src/components/MkContainer.stories.impl.ts b/packages/frontend/src/components/MkContainer.stories.impl.ts new file mode 100644 index 0000000000..72a7659521 --- /dev/null +++ b/packages/frontend/src/components/MkContainer.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkContainer from './MkContainer.vue'; +void MkContainer; diff --git a/packages/frontend/src/components/MkContextMenu.stories.impl.ts b/packages/frontend/src/components/MkContextMenu.stories.impl.ts new file mode 100644 index 0000000000..1ff0f51bd4 --- /dev/null +++ b/packages/frontend/src/components/MkContextMenu.stories.impl.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { userEvent, within } from '@storybook/test'; +import MkContextMenu from './MkContextMenu.vue'; +import * as os from '@/os.js'; +export const Empty = { + render(args) { + return { + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + methods: { + onContextmenu(ev: MouseEvent) { + os.contextMenu(args.items, ev); + }, + }, + template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>', + }; + }, + args: { + items: [], + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const target = canvas.getByText('Right Click Here'); + await userEvent.pointer({ keys: '[MouseRight>]', target }); + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkContextMenu>; +export const SomeTabs = { + ...Empty, + args: { + items: [ + { + text: 'Home', + icon: 'ti ti-home', + action() {}, + }, + ], + }, +} satisfies StoryObj<typeof MkContextMenu>; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index a807742bb9..8ea8fa6cf3 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> - <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> + <MkMenu :items="items" :align="'left'" @close="emit('closed')"/> </div> </Transition> </template> diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts new file mode 100644 index 0000000000..ce13093975 --- /dev/null +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { file } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkCropperDialog from './MkCropperDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkCropperDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'ok': action('ok'), + 'cancel': action('cancel'), + 'closed': action('closed'), + }; + }, + }, + template: '<MkCropperDialog v-bind="props" v-on="events" />', + }; + }, + args: { + file: file(), + aspectRatio: NaN, + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.get('/proxy/image.webp', async ({ request }) => { + const url = new URL(request.url).searchParams.get('url'); + if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') { + const image = await (await fetch('client-assets/fedi.jpg')).blob(); + return new HttpResponse(image, { + headers: { + 'Content-Type': 'image/jpeg', + }, + }); + } else { + return new HttpResponse(null, { status: 404 }); + } + }), + http.post('/api/drive/files/create', async ({ request }) => { + action('POST /api/drive/files/create')(await request.formData()); + return HttpResponse.json(file()); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkCropperDialog>; diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts new file mode 100644 index 0000000000..8a05e06311 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.stories.impl.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { emojiDetailed } from '../../.storybook/fakes.js'; +import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkCustomEmojiDetailedDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkCustomEmojiDetailedDialog v-bind="props" />', + }; + }, + args: { + emoji: emojiDetailed(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>; diff --git a/packages/frontend/src/components/MkCwButton.stories.impl.ts b/packages/frontend/src/components/MkCwButton.stories.impl.ts new file mode 100644 index 0000000000..5d6ea56da9 --- /dev/null +++ b/packages/frontend/src/components/MkCwButton.stories.impl.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, within } from '@storybook/test'; +import { file } from '../../.storybook/fakes.js'; +import MkCwButton from './MkCwButton.vue'; +import { i18n } from '@/i18n.js'; + +export const Default = { + render(args) { + return { + components: { + MkCwButton, + }, + data() { + return { + showContent: false, + }; + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'update:modelValue': action('update:modelValue'), + }; + }, + }, + template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />', + }; + }, + args: { + text: 'Some CW content', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); + await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show); + await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 })); + await userEvent.click(buttonElement); + await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide); + await userEvent.click(buttonElement); + }, + parameters: { + chromatic: { + // NOTE: テストが終わるまで待つ + delay: 5000, + }, + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCwButton>; +export const IncludesTextAndDriveFile = { + ...Default, + args: { + text: 'Some CW content', + files: [file()], + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); + await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 })); + await expect(buttonElement).toHaveTextContent(' / '); + await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 })); + }, +} satisfies StoryObj<typeof MkCwButton>; diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index a2cb3185f4..b5f6e78b6c 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -45,11 +45,11 @@ function toggle() { .label { margin-left: 4px; - &:before { + &::before { content: '('; } - &:after { + &::after { content: ')'; } } diff --git a/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts b/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts new file mode 100644 index 0000000000..0e5635754c --- /dev/null +++ b/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDateSeparatedList from './MkDateSeparatedList.vue'; +void MkDateSeparatedList; diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 4431722dae..9976cd00c9 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -130,7 +130,7 @@ export default defineComponent({ el.style.left = ''; } - // eslint-disable-next-line vue/no-setup-props-destructure + // eslint-disable-next-line vue/no-setup-props-reactivity-loss const classes = { [$style['date-separated-list']]: true, [$style['date-separated-list-nogap']]: props.noGap, diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts new file mode 100644 index 0000000000..2d8d3661f2 --- /dev/null +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { StoryObj } from '@storybook/vue3'; +import { i18n } from '@/i18n.js'; +import MkDialog from './MkDialog.vue'; +const Base = { + render(args) { + return { + components: { + MkDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + done: action('done'), + closed: action('closed'), + }; + }, + }, + template: '<MkDialog v-bind="props" v-on="events" />', + }; + }, + args: { + text: 'Hello, world!', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Success = { + ...Base, + args: { + ...Base.args, + type: 'success', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Error = { + ...Base, + args: { + ...Base.args, + type: 'error', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Warning = { + ...Base, + args: { + ...Base.args, + type: 'warning', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Info = { + ...Base, + args: { + ...Base.args, + type: 'info', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Question = { + ...Base, + args: { + ...Base.args, + type: 'question', + }, +} satisfies StoryObj<typeof MkDialog>; +export const Waiting = { + ...Base, + args: { + ...Base.args, + type: 'waiting', + }, +} satisfies StoryObj<typeof MkDialog>; +export const DialogWithActions = { + ...Question, + args: { + ...Question.args, + text: i18n.ts.areYouSure, + actions: [ + { + text: i18n.ts.yes, + primary: true, + callback() { + action('YES')(); + }, + }, + { + text: i18n.ts.no, + callback() { + action('NO')(); + }, + }, + ], + }, +} satisfies StoryObj<typeof MkDialog>; +export const DialogWithDangerActions = { + ...Warning, + args: { + ...Warning.args, + text: i18n.ts.resetAreYouSure, + actions: [ + { + text: i18n.ts.yes, + danger: true, + primary: true, + callback() { + action('YES')(); + }, + }, + { + text: i18n.ts.no, + callback() { + action('NO')(); + }, + }, + ], + }, +} satisfies StoryObj<typeof MkDialog>; +export const DialogWithInput = { + ...Question, + args: { + ...Question.args, + title: 'Hello, world!', + text: undefined, + input: { + placeholder: i18n.ts.inputMessageHere, + type: 'text', + default: null, + minLength: 2, + maxLength: 3, + }, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 0, min: 2 })); + const okButton = canvas.getByRole('button', { name: i18n.ts.ok }); + await expect(okButton).toBeDisabled(); + const input = canvas.getByRole<HTMLInputElement>('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'M')); + await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 1, min: 2 })); + await waitFor(() => userEvent.type(input, 'i')); + await expect(okButton).toBeEnabled(); + }, +} satisfies StoryObj<typeof MkDialog>; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index c40ebfe10f..825c1d0513 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')"> +<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()"> <div :class="$style.root"> <div v-if="icon" :class="$style.icon"> <i :class="icon"></i> @@ -36,7 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> <template v-if="select.items"> - <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + <template v-for="item in select.items"> + <optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle"> + <option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option> + </optgroup> + <option v-else :value="item.value">{{ item.text }}</option> + </template> </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> @@ -51,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue'; +import { ref, shallowRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -67,11 +72,16 @@ type Input = { maxLength?: number; }; +type SelectItem = { + value: any; + text: string; +}; + type Select = { - items: { - value: any; - text: string; - }[]; + items: (SelectItem | { + sectionTitle: string; + items: SelectItem[]; + })[]; default: string | null; }; @@ -156,10 +166,6 @@ function onBgClick() { if (props.cancelableByBgClick) cancel(); } */ -function onKeydown(evt: KeyboardEvent) { - if (evt.key === 'Escape') cancel(); -} - function onInputKeydown(evt: KeyboardEvent) { if (evt.key === 'Enter' && okButtonDisabledReason.value === null) { evt.preventDefault(); @@ -167,14 +173,6 @@ function onInputKeydown(evt: KeyboardEvent) { ok(); } } - -onMounted(() => { - document.addEventListener('keydown', onKeydown); -}); - -onBeforeUnmount(() => { - document.removeEventListener('keydown', onKeydown); -}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkDivider.stories.impl.ts b/packages/frontend/src/components/MkDivider.stories.impl.ts new file mode 100644 index 0000000000..a593111987 --- /dev/null +++ b/packages/frontend/src/components/MkDivider.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDivider from './MkDivider.vue'; +void MkDivider; diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue new file mode 100644 index 0000000000..e4e3af99e4 --- /dev/null +++ b/packages/frontend/src/components/MkDivider.vue @@ -0,0 +1,32 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + class="default" :style="[ + marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {}, + marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {}, + borderStyle ? { borderStyle: borderStyle } : {}, + borderWidth ? { borderWidth: borderWidth } : {}, + borderColor ? { borderColor: borderColor } : {}, + ]" +/> +</template> + +<script setup lang="ts"> +defineProps<{ + marginTopBottom?: string; + marginLeftRight?: string; + borderStyle?: string; + borderWidth?: string; + borderColor?: string; +}>(); +</script> + +<style scoped lang="scss"> +.default { + border-top: solid 0.5px var(--divider); +} +</style> diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts new file mode 100644 index 0000000000..27d6b7df6c --- /dev/null +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { onBeforeUnmount } from 'vue'; +import MkDonation from './MkDonation.vue'; +import { instance } from '@/instance.js'; +export const Default = { + render(args) { + return { + components: { + MkDonation, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + closed: action('closed'), + }; + }, + }, + template: '<MkDonation v-bind="props" v-on="events" />', + }; + }, + args: { + // @ts-expect-error name is used for mocking instance + name: 'Misskey Hub', + }, + decorators: [ + (_, { args }) => ({ + setup() { + // @ts-expect-error name is used for mocking instance + instance.name = args.name; + onBeforeUnmount(() => instance.name = null); + }, + template: '<story/>', + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkDonation>; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts new file mode 100644 index 0000000000..5f6e6a0667 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import MkDrive_file from './MkDrive.file.vue'; +import { file } from '../../.storybook/fakes.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive_file, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + dragstart: action('dragstart'), + dragend: action('dragend'), + }; + }, + }, + template: '<MkDrive_file v-bind="props" v-on="events" />', + }; + }, + args: { + file: file(), + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj<typeof MkDrive_file>; diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 13a2a2126c..20ad2984d8 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -119,14 +119,14 @@ function onDragend() { background: rgba(#000, 0.05); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b65a5; } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } @@ -137,14 +137,14 @@ function onDragend() { background: rgba(#000, 0.1); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b588c; } &.red { - &:before, - &:after { + &::before, + &::after { background: #ce2212; } } @@ -163,8 +163,8 @@ function onDragend() { } > .label { - &:before, - &:after { + &::before, + &::after { display: none; } } @@ -185,8 +185,8 @@ function onDragend() { left: 0; pointer-events: none; - &:before, - &:after { + &::before, + &::after { content: ""; display: block; position: absolute; @@ -194,14 +194,14 @@ function onDragend() { background: #0c7ac9; } - &:before { + &::before { top: 0; left: 57px; width: 28px; height: 8px; } - &:after { + &::after { top: 57px; left: 0; width: 8px; @@ -209,8 +209,8 @@ function onDragend() { } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts new file mode 100644 index 0000000000..5f8ef48520 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import * as Misskey from 'misskey-js'; +import MkDrive_folder from './MkDrive.folder.vue'; +import { folder } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive_folder, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + move: action('move'), + upload: action('upload'), + removeFile: action('removeFile'), + removeFolder: action('removeFolder'), + dragstart: action('dragstart'), + dragend: action('dragend'), + }; + }, + }, + template: '<MkDrive_folder v-bind="props" v-on="events" />', + }; + }, + args: { + folder: folder(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/drive/folders/delete', async ({ request }) => { + action('POST /api/drive/folders/delete')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + http.post('/api/drive/folders/update', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest; + action('POST /api/drive/folders/update')(req); + return HttpResponse.json({ + ...folder(), + id: req.folderId, + name: req.name ?? folder().name, + parentId: req.parentId ?? folder().parentId, + }); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkDrive_folder>; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 1691e01c4a..3990d0b861 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -27,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} </p> - <button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button> + <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> + <div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div> + </button> </div> </template> @@ -39,7 +41,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { MenuItem } from '@/types/menu.js'; const props = withDefaults(defineProps<{ @@ -53,6 +55,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; + (ev: 'unchose', v: Misskey.entities.DriveFolder): void; (ev: 'move', v: Misskey.entities.DriveFolder): void; (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; @@ -68,7 +71,11 @@ const isDragging = ref(false); const title = computed(() => props.folder.name); function checkboxClicked() { - emit('chosen', props.folder); + if (props.isSelected) { + emit('unchose', props.folder); + } else { + emit('chosen', props.folder); + } } function onClick() { @@ -222,6 +229,17 @@ function rename() { }); } +function move() { + os.selectDriveFolder(false).then(folder => { + if (folder[0] && folder[0].id === props.folder.id) return; + + misskeyApi('drive/folders/update', { + folderId: props.folder.id, + parentId: folder[0] ? folder[0].id : null, + }); + }); +} + function deleteFolder() { misskeyApi('drive/folders/delete', { folderId: props.folder.id, @@ -257,15 +275,20 @@ function onContextmenu(ev: MouseEvent) { text: i18n.ts.openInWindow, icon: 'ti ti-app-window', action: () => { - os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { initialFolder: props.folder, }, { - }, 'closed'); + closed: () => dispose(), + }); }, }, { type: 'divider' }, { text: i18n.ts.rename, icon: 'ti ti-forms', action: rename, + }, { + text: i18n.ts.move, + icon: 'ti ti ti-folder-symlink', + action: move, }, { type: 'divider' }, { text: i18n.ts.delete, icon: 'ti ti-trash', @@ -295,7 +318,7 @@ function onContextmenu(ev: MouseEvent) { cursor: pointer; &.draghover { - &:after { + &::after { content: ""; pointer-events: none; position: absolute; @@ -309,17 +332,43 @@ function onContextmenu(ev: MouseEvent) { } } -.checkbox { +.checkboxWrapper { position: absolute; - bottom: 8px; - right: 8px; - width: 16px; - height: 16px; - background: #fff; - border: solid 1px #000; + border-radius: 50%; + bottom: 2px; + right: 2px; + padding: 8px; + box-sizing: border-box; + + > .checkbox { + position: relative; + width: 18px; + height: 18px; + background: #fff; + border: solid 2px var(--divider); + border-radius: 4px; + box-sizing: border-box; + + &.checked { + border-color: var(--accent); + background: var(--accent); + + &::after { + content: "\ea5e"; + font-family: 'tabler-icons'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 12px; + line-height: 22px; + } + } + } - &.checked { - background: var(--accent); + &:hover { + background: var(--accentedBg); } } diff --git a/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts b/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts new file mode 100644 index 0000000000..9d49f24fa4 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDrive_navFolder from './MkDrive.navFolder.vue'; +void MkDrive_navFolder; diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts new file mode 100644 index 0000000000..fe20e61415 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import * as Misskey from 'misskey-js'; +import MkDrive from './MkDrive.vue'; +import { file, folder } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + selected: action('selected'), + 'change-selection': action('change-selection'), + 'move-root': action('move-root'), + cd: action('cd'), + 'open-folder': action('open-folder'), + }; + }, + }, + template: '<MkDrive v-bind="props" v-on="events" />', + }; + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/drive/files', async ({ request }) => { + action('POST /api/drive/files')(await request.json()); + return HttpResponse.json([file()]); + }), + http.post('/api/drive/folders', async ({ request }) => { + action('POST /api/drive/folders')(await request.json()); + return HttpResponse.json([folder(crypto.randomUUID())]); + }), + http.post('/api/drive/folders/create', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersCreateRequest; + action('POST /api/drive/folders/create')(req); + return HttpResponse.json(folder(crypto.randomUUID(), req.name, req.parentId)); + }), + http.post('/api/drive/folders/delete', async ({ request }) => { + action('POST /api/drive/folders/delete')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + http.post('/api/drive/folders/update', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest; + action('POST /api/drive/folders/update')(req); + return HttpResponse.json({ + ...folder(), + id: req.folderId, + name: req.name ?? folder().name, + parentId: req.parentId ?? folder().parentId, + }); + }), + ] + }, + }, +} satisfies StoryObj<typeof MkDrive>; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 222fabb8f4..2d1c7c95f0 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only :selectMode="select === 'folder'" :isSelected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" + @unchose="unchoseFolder" @move="move" @upload="upload" @removeFile="removeFile" @@ -438,6 +439,11 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } } +function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) { + selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id); + emit('change-selection', selectedFolders.value); +} + function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts new file mode 100644 index 0000000000..3fa24d7edb --- /dev/null +++ b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue'; +import { file } from '../../.storybook/fakes.js'; +export const Default = { + render(args) { + return { + components: { + MkDriveFileThumbnail, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkDriveFileThumbnail v-bind="props" />', + }; + }, + args: { + file: file(), + fit: 'contain', + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj<typeof MkDriveFileThumbnail>; diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 65c3ea1f01..9a6d272113 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -26,7 +26,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; const props = defineProps<{ file: Misskey.entities.DriveFile; - fit: string; + fit: 'cover' | 'contain'; }>(); const is = computed(() => { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts new file mode 100644 index 0000000000..fe8f705165 --- /dev/null +++ b/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDriveSelectDialog from './MkDriveSelectDialog.vue'; +void MkDriveSelectDialog; diff --git a/packages/frontend/src/components/MkDriveWindow.stories.impl.ts b/packages/frontend/src/components/MkDriveWindow.stories.impl.ts new file mode 100644 index 0000000000..faa1f7fd5f --- /dev/null +++ b/packages/frontend/src/components/MkDriveWindow.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDriveWindow from './MkDriveWindow.vue'; +void MkDriveWindow; diff --git a/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts new file mode 100644 index 0000000000..69aef577de --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkEmojiPicker_section from './MkEmojiPicker.section.vue'; +void MkEmojiPicker_section; diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts new file mode 100644 index 0000000000..d38d8de808 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { StoryObj } from '@storybook/vue3'; +import { i18n } from '@/i18n.js'; +import MkEmojiPicker from './MkEmojiPicker.vue'; +export const Default = { + render(args) { + return { + components: { + MkEmojiPicker, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + }; + }, + }, + template: '<MkEmojiPicker v-bind="props" v-on="events" />', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const faceSection = canvas.getByText(/face/i); + await waitFor(() => userEvent.click(faceSection)); + const grinning = canvasElement.querySelector('[data-emoji="😀"]'); + await expect(grinning).toBeInTheDocument(); + if (grinning == null) throw new Error(); // NOTE: not called + await waitFor(() => userEvent.click(grinning)); + const recentUsedSection = canvas.getByText(new RegExp(i18n.ts.recentUsed)).parentElement; + await expect(recentUsedSection).toBeInTheDocument(); + if (recentUsedSection == null) throw new Error(); // NOTE: not called + await expect(within(recentUsedSection).getByAltText('😀')).toBeInTheDocument(); + await expect(within(recentUsedSection).queryByAltText('😬')).toEqual(null); + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkEmojiPicker>; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 5bceb39d76..297d5f899e 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> - <input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter"> + <input + ref="searchEl" + :value="q" + class="search" + data-prevent-emoji-insert + :class="{ filled: q != null && q != '' }" + :placeholder="i18n.ts.search" + type="search" + autocapitalize="off" + @input="input()" + @paste.stop="paste" + @keydown="onKeydown" + > <!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 --> <div ref="emojisEl" class="emojis" tabindex="-1"> <section class="result"> @@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: string): void; + (ev: 'esc'): void; }>(); const searchEl = shallowRef<HTMLInputElement>(); @@ -402,7 +415,9 @@ function chosen(emoji: any, ev?: MouseEvent) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); } const key = getKey(emoji); @@ -431,9 +446,18 @@ function paste(event: ClipboardEvent): void { } } -function onEnter(ev: KeyboardEvent) { +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; - done(); + if (ev.key === 'Enter') { + ev.preventDefault(); + ev.stopPropagation(); + done(); + } + if (ev.key === 'Escape') { + ev.preventDefault(); + ev.stopPropagation(); + emit('esc'); + } } function done(query?: string): boolean | void { @@ -700,11 +724,6 @@ defineExpose({ border-radius: var(--radius-xs); font-size: 24px; - &:focus-visible { - outline: solid 2px var(--focus); - z-index: 1; - } - &:hover { background: rgba(0, 0, 0, 0.05); } diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts b/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts new file mode 100644 index 0000000000..131087ad45 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkEmojiPickerDialog from './MkEmojiPickerDialog.vue'; +void MkEmojiPickerDialog; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index c6b3896989..0ec0679393 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -9,10 +9,12 @@ SPDX-License-Identifier: AGPL-3.0-only v-slot="{ type, maxHeight }" :zPriority="'middle'" :preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" :src="src" @click="modal?.close()" + @esc="modal?.close()" @opening="opening" @close="emit('close')" @closed="emit('closed')" @@ -28,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only :asDrawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen" + @esc="modal?.close()" /> </MkModal> </template> diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index c5dd877971..6783804cc5 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1"> +<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel"> <article> <header> <h1 :title="flash.title">{{ flash.title }}</h1> @@ -39,6 +39,10 @@ const props = defineProps<{ color: var(--accent); } + &:focus-visible { + outline-offset: -2px; + } + > article { padding: 16px; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index dbe3db9eac..229cd59056 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened"> <MkStickyContainer> <template #header> - <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> + <button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> <div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerText"> - <div> + <div :class="$style.headerTextMain"> <MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine> </div> <div :class="$style.headerTextSub"> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="opened" class="ti ti-chevron-up icon"></i> <i v-else class="ti ti-chevron-down icon"></i> </div> - </div> + </button> </template> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> @@ -147,6 +147,10 @@ onMounted(() => { background: var(--buttonHoverBg); } + &:focus-within { + outline-offset: 2px; + } + &.active { color: var(--accent); background: var(--buttonHoverBg); @@ -190,6 +194,12 @@ onMounted(() => { padding-right: 12px; } +.headerTextMain, +.headerTextSub { + width: fit-content; + max-width: 100%; +} + .headerTextSub { color: var(--fgTransparentWeak); font-size: .85em; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ba0527e570..3fdf673eb3 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/scripts/achievements.js'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { host } from '@/config.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; @@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro const wait = ref(false); const connection = useStream().useChannel('main'); -if (props.user.isFollowing == null) { +if (props.user.isFollowing == null && $i) { misskeyApi('users/show', { userId: props.user.id, }) @@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { + pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` }); + wait.value = true; try { @@ -121,6 +125,8 @@ async function onClick() { }); hasPendingFollowRequestFromYou.value = true; + if ($i == null) return; + claimAchievement('following1'); if ($i.followingCount >= 10) { @@ -183,17 +189,7 @@ onBeforeUnmount(() => { } &:focus-visible { - &:after { - content: ""; - pointer-events: none; - position: absolute; - top: -5px; - right: -5px; - bottom: -5px; - left: -5px; - border: 2px solid var(--focus); - border-radius: var(--radius-xl); - } + outline-offset: 2px; } &:hover { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 47cccd9b7c..2bb5b8762a 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -83,7 +83,7 @@ function leaveHover(): void { > article { > footer { - &:before { + &::before { opacity: 1; } } @@ -139,7 +139,7 @@ function leaveHover(): void { text-shadow: 0 0 8px #000; background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); - &:before { + &::before { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 4e3fafe845..8d301f16bd 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined" :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined" > - <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/> - <img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/> + <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/> + <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/> </TransitionGroup> </div> </template> @@ -151,22 +151,26 @@ function drawImage(bitmap: CanvasImageSource) { } function drawAvg() { - if (!canvas.value || !props.hash) return; + if (!canvas.value) return; + + const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888'; const ctx = canvas.value.getContext('2d'); if (!ctx) return; // avgColorでお茶をにごす ctx.beginPath(); - ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; + ctx.fillStyle = color; ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value); } async function draw() { - if (props.hash == null) return; + if (import.meta.env.MODE === 'test' && props.hash == null) return; drawAvg(); + if (props.hash == null) return; + if (props.onlyAvgColor) return; const work = await canvasPromise; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 064d203621..06389b4013 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -79,7 +79,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; - (ev: 'enter'): void; + (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: string | number): void; }>(); @@ -111,7 +111,7 @@ const onKeydown = (ev: KeyboardEvent) => { emit('keydown', ev); if (ev.code === 'Enter') { - emit('enter'); + emit('enter', ev); } }; diff --git a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts new file mode 100644 index 0000000000..9e8de9d878 --- /dev/null +++ b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { federationInstance } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import { getChartResolver } from '../../.storybook/charts.js'; +import MkInstanceCardMini from './MkInstanceCardMini.vue'; + +export const Default = { + render(args) { + return { + components: { + MkInstanceCardMini, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkInstanceCardMini v-bind="props" />', + }; + }, + args: { + instance: federationInstance(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.get('/undefined/preview.webp', async ({ request }) => { + const urlStr = new URL(request.url).searchParams.get('url'); + if (urlStr == null) { + return new HttpResponse(null, { status: 404 }); + } + const url = new URL(urlStr); + + if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) { + const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); + return new HttpResponse(image, { + headers: { + 'Content-Type': 'image/jpeg', + }, + }); + } else { + return new HttpResponse(null, { status: 404 }); + } + }), + http.get('/api/charts/instance', getChartResolver(['requests.received'])), + ], + }, + }, +} satisfies StoryObj<typeof MkInstanceCardMini>; diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index feb62415aa..10b390e7f9 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -29,8 +29,8 @@ const chartValues = ref<number[] | null>(null); misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く - res['requests.received'].splice(0, 1); - chartValues.value = res['requests.received']; + res.requests.received.splice(0, 1); + chartValues.value = res.requests.received; }); function getInstanceIcon(instance): string { diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 1c6f412dc1..de51a98789 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -62,7 +62,7 @@ import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 20b1ef2be2..50c9e16e5e 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index e232b4d66f..e0880ec3e7 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 18a979a157..07cf9e0c37 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -38,11 +38,13 @@ const el = ref<HTMLElement | { $el: HTMLElement }>(); if (isEnabledUrlPreview.value) { useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, source: el.value instanceof HTMLElement ? el.value : el.value?.$el, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } </script> diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 3b5d50315e..93affd930f 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="hide = false"> + <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> @@ -39,11 +39,16 @@ SPDX-License-Identifier: AGPL-3.0-only <audio ref="audioEl" preload="metadata" + @keydown.prevent="() => {}" > <source :src="audio.url"> </audio> <div :class="[$style.controlsChild, $style.controlsLeft]"> - <button class="_button" :class="$style.controlButton" @click="togglePlayPause"> + <button + :class="['_button', $style.controlButton]" + tabindex="-1" + @click.stop="togglePlayPause" + > <i v-if="isPlaying" class="ti ti-player-pause-filled"></i> <i v-else class="ti ti-player-play-filled"></i> </button> @@ -52,13 +57,22 @@ SPDX-License-Identifier: AGPL-3.0-only <a class="_button" :class="$style.controlButton" :href="audio.url" :download="audio.name" target="_blank"> <i class="ph-download ph-bold ph-lg"></i> </a> - <button class="_button" :class="$style.controlButton" @click="showMenu"> + <button + :class="['_button', $style.controlButton]" + tabindex="-1" + @click.stop="() => {}" + @mousedown.prevent.stop="showMenu" + > <i class="ti ti-settings"></i> </button> </div> <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> <div :class="[$style.controlsChild, $style.controlsVolume]"> - <button class="_button" :class="$style.controlButton" @click="toggleMute"> + <button + :class="['_button', $style.controlButton]" + tabindex="-1" + @click.stop="toggleMute" + > <i v-if="volume === 0" class="ti ti-volume-3"></i> <i v-else class="ti ti-volume"></i> </button> @@ -83,6 +97,7 @@ import type { MenuItem } from '@/types/menu.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { type Keymap } from '@/scripts/hotkey.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; @@ -93,32 +108,44 @@ const props = defineProps<{ }>(); const keymap = { - 'up': () => { - if (hasFocus() && audioEl.value) { - volume.value = Math.min(volume.value + 0.1, 1); - } + 'up': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, }, - 'down': () => { - if (hasFocus() && audioEl.value) { - volume.value = Math.max(volume.value - 0.1, 0); - } + 'down': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, }, - 'left': () => { - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); - } + 'left': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); + } + }, }, - 'right': () => { - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); - } + 'right': { + allowRepeat: true, + callback: () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); + } + }, }, 'space': () => { if (hasFocus()) { togglePlayPause(); } }, -}; +} as const satisfies Keymap; // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { @@ -129,9 +156,21 @@ function hasFocus() { const playerEl = shallowRef<HTMLDivElement>(); const audioEl = shallowRef<HTMLAudioElement>(); -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); +async function show() { + if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.sensitiveMediaRevealConfirm, + }); + if (canceled) return; + } + + hide.value = false; +} + // Menu const menuShowing = ref(false); @@ -361,7 +400,7 @@ onDeactivated(() => { border-radius: var(--radius); overflow: clip; - &:focus { + &:focus-visible { outline: none; } } @@ -427,6 +466,10 @@ onDeactivated(() => { color: var(--accent); background-color: var(--accentedBg); } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 78979b3f47..ed8d43273f 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false"> + <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="show"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> @@ -24,24 +24,30 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, watch, ref } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; +import { defaultStore } from '@/store.js'; +import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; -const props = withDefaults(defineProps<{ +const props = defineProps<{ media: Misskey.entities.DriveFile; -}>(), { -}); +}>(); -const audioEl = shallowRef<HTMLAudioElement>(); const hide = ref(true); -watch(audioEl, () => { - if (audioEl.value) { - audioEl.value.volume = 0.3; +async function show() { + if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.sensitiveMediaRevealConfirm, + }); + if (canceled) return; } -}); + + hide.value = false; +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index e55bb1effb..cac419d42b 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -84,11 +84,21 @@ const url = computed(() => (props.raw || defaultStore.state.loadRawImages) : props.image.thumbnailUrl, ); -function onclick() { +async function onclick(ev: MouseEvent) { if (!props.controls) { return; } + if (hide.value) { + ev.stopPropagation(); + if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.sensitiveMediaRevealConfirm, + }); + if (canceled) return; + } + hide.value = false; } } diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 112a84f1fd..9e78b69574 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -41,6 +41,7 @@ import XModPlayer from '@/components/SkModPlayer.vue'; import * as os from '@/os.js'; import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@/const.js'; import { defaultStore } from '@/store.js'; +import { focusParent } from '@/scripts/focus.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; @@ -51,7 +52,9 @@ const gallery = shallowRef<HTMLDivElement>(); const pswpZIndex = os.claimZIndex('middle'); document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = computed(() => props.mediaList.filter(media => previewable(media)).length); -let lightbox: PhotoSwipeLightbox | null; +let lightbox: PhotoSwipeLightbox | null = null; + +let activeEl: HTMLElement | null = null; const popstateHandler = (): void => { if (lightbox?.pswp && lightbox.pswp.isOpen === true) { @@ -62,7 +65,7 @@ const popstateHandler = (): void => { async function calcAspectRatio() { if (!gallery.value) return; - let img = props.mediaList[0]; + const img = props.mediaList[0]; if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) { gallery.value.style.aspectRatio = ''; @@ -139,18 +142,17 @@ onMounted(() => { bgOpacity: 1, showAnimationDuration: 100, hideAnimationDuration: 100, + returnFocus: false, pswpModule: PhotoSwipe, }); - lightbox.on('itemData', (ev) => { - const { itemData } = ev; - + lightbox.addFilter('itemData', (itemData) => { // element is children const { element } = itemData; const id = element?.dataset.id; const file = props.mediaList.find(media => media.id === id); - if (!file) return; + if (!file) return itemData; itemData.src = file.url; itemData.w = Number(file.properties.width); @@ -162,19 +164,21 @@ onMounted(() => { itemData.alt = file.comment ?? undefined; itemData.comment = file.comment; itemData.thumbCropped = true; + + return itemData; }); lightbox.on('uiRegister', () => { lightbox?.pswp?.ui?.registerElement({ name: 'altText', - className: 'pwsp__alt-text-container', + className: 'pswp__alt-text-container', appendTo: 'wrapper', - onInit: (el, pwsp) => { - let textBox = document.createElement('p'); - textBox.className = 'pwsp__alt-text _acrylic'; + onInit: (el, pswp) => { + const textBox = document.createElement('p'); + textBox.className = 'pswp__alt-text _acrylic'; el.appendChild(textBox); - pwsp.on('change', (a) => { + pwsp.on('change', () => { if (pwsp.currSlide?.data.comment) { textBox.style.display = ''; } else { @@ -187,25 +191,33 @@ onMounted(() => { }); }); - lightbox.init(); - - window.addEventListener('popstate', popstateHandler); - - lightbox.on('beforeOpen', () => { + lightbox.on('afterInit', () => { + activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null; + focusParent(activeEl, true, true); + lightbox?.pswp?.element?.focus({ + preventScroll: true, + }); history.pushState(null, '', '#pswp'); }); - lightbox.on('close', () => { + lightbox.on('destroy', () => { + focusParent(activeEl, true, false); + activeEl = null; if (window.location.hash === '#pswp') { history.back(); } }); + + window.addEventListener('popstate', popstateHandler); + + lightbox.init(); }); onUnmounted(() => { window.removeEventListener('popstate', popstateHandler); lightbox?.destroy(); lightbox = null; + activeEl = null; }); const previewable = (file: Misskey.entities.DriveFile): boolean => { @@ -214,6 +226,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => { if (isModule(file)) return true; return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type); }; + +const openGallery = () => { + if (props.mediaList.filter(media => previewable(media)).length > 0) { + lightbox?.loadAndOpen(0); + } +}; + +defineExpose({ + openGallery, +}); </script> <style lang="scss" module> @@ -317,7 +339,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => { backdrop-filter: var(--modalBgFilter); } -.pwsp__alt-text-container { +.pswp__alt-text-container { display: flex; flex-direction: row; align-items: center; @@ -331,7 +353,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => { max-width: 800px; } -.pwsp__alt-text { +.pswp__alt-text { color: var(--fg); margin: 0 auto; text-align: center; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 4dc5059724..1c3c9a312b 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > - <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> @@ -115,6 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; +import { type Keymap } from '@/scripts/hotkey.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import { defaultStore } from '@/store.js'; @@ -130,32 +131,44 @@ const props = defineProps<{ }>(); const keymap = { - 'up': () => { - if (hasFocus() && videoEl.value) { - volume.value = Math.min(volume.value + 0.1, 1); - } + 'up': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, }, - 'down': () => { - if (hasFocus() && videoEl.value) { - volume.value = Math.max(volume.value - 0.1, 0); - } + 'down': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, }, - 'left': () => { - if (hasFocus() && videoEl.value) { - videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0); - } + 'left': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0); + } + }, }, - 'right': () => { - if (hasFocus() && videoEl.value) { - videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration); - } + 'right': { + allowRepeat: true, + callback: () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration); + } + }, }, 'space': () => { if (hasFocus()) { togglePlayPause(); } }, -}; +} as const satisfies Keymap; // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { @@ -163,9 +176,21 @@ function hasFocus() { return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); } -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +async function show() { + if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.sensitiveMediaRevealConfirm, + }); + if (canceled) return; + } + + hide.value = false; +} + // Menu const menuShowing = ref(false); @@ -471,7 +496,7 @@ onDeactivated(() => { position: relative; overflow: clip; - &:focus { + &:focus-visible { outline: none; } } @@ -578,6 +603,10 @@ onDeactivated(() => { border-radius: 99rem; font-size: 1.1rem; + + &:focus-visible { + outline: none; + } } .videoLoading { @@ -641,6 +670,10 @@ onDeactivated(() => { &:hover { background-color: var(--accent); } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index dfb6d34618..235790556c 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; +import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue'; import MkMenu from './MkMenu.vue'; import { MenuItem } from '@/types/menu.js'; @@ -19,7 +19,6 @@ const props = defineProps<{ targetElement: HTMLElement; rootElement: HTMLElement; width?: number; - viaKeyboard?: boolean; }>(); const emit = defineEmits<{ @@ -27,6 +26,8 @@ const emit = defineEmits<{ (ev: 'actioned'): void; }>(); +provide('isNestingMenu', true); + const el = shallowRef<HTMLElement>(); const align = 'left'; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 55a59b5c10..0537f4f988 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -4,23 +4,42 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div role="menu"> +<div role="menu" @focusin.passive.stop="() => {}"> <div - ref="itemsEl" v-hotkey="keymap" + ref="itemsEl" + v-hotkey="keymap" + tabindex="0" class="_popup _shadow" - :class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer }]" - :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" - @contextmenu.self="e => e.preventDefault()" + :class="{ + [$style.root]: true, + [$style.center]: align === 'center', + [$style.asDrawer]: asDrawer, + }" + :style="{ + width: (width && !asDrawer) ? `${width}px` : '', + maxHeight: maxHeight ? `min(${maxHeight}px, calc(100dvh - 32px))` : 'calc(100dvh - 32px)', + }" + @keydown.stop="() => {}" + @contextmenu.self.prevent="() => {}" > - <template v-for="(item, i) in (items2 ?? [])"> - <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> - <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> + <template v-for="item in (items2 ?? [])"> + <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> + <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> </span> - <span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]"> + <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> - <MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <MkA + v-else-if="item.type === 'link'" + role="menuitem" + tabindex="0" + :class="['_button', $style.item]" + :to="item.to" + @click.passive="close(true)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> @@ -28,20 +47,49 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </div> </MkA> - <a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <a + v-else-if="item.type === 'a'" + role="menuitem" + tabindex="0" + :class="['_button', $style.item]" + :href="item.href" + :target="item.target" + :rel="item.target === '_blank' ? 'noopener noreferrer' : undefined" + :download="item.download" + @click.passive="close(true)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <div :class="$style.item_content"> <span :class="$style.item_content_text">{{ item.text }}</span> <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </div> </a> - <button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button + v-else-if="item.type === 'user'" + role="menuitem" + tabindex="0" + :class="['_button', $style.item, { [$style.active]: item.active }]" + @click.prevent="item.active ? close(false) : clicked(item.action, $event)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/> <div v-if="item.indicate" :class="$style.item_content"> <span :class="$style.indicator"><i class="_indicatorCircle"></i></span> </div> </button> - <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button + v-else-if="item.type === 'switch'" + role="menuitemcheckbox" + tabindex="0" + :class="['_button', $style.item]" + :disabled="unref(item.disabled)" + @click.prevent="switchItem(item)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> <div :class="$style.item_content"> @@ -49,29 +97,61 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> </div> </button> - <button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)"> + <button + v-else-if="item.type === 'radio'" + role="menuitem" + tabindex="0" + :class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]" + :disabled="unref(item.disabled)" + @mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)" + @keydown.enter.prevent="preferClick ? null : showRadioOptions(item, $event)" + @click.prevent="!preferClick ? null : showRadioOptions(item, $event)" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> - <button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button + v-else-if="item.type === 'radioOption'" + role="menuitemradio" + tabindex="0" + :class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]" + @click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <div :class="$style.icon"> - <span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span> + <span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span> </div> <div :class="$style.item_content"> <span :class="$style.item_content_text">{{ item.text }}</span> </div> </button> - <button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)"> + <button + v-else-if="item.type === 'parent'" + role="menuitem" + tabindex="0" + :class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]" + @mouseenter.prevent="preferClick ? null : showChildren(item, $event)" + @keydown.enter.prevent="preferClick ? null : showChildren(item, $event)" + @click.prevent="!preferClick ? null : showChildren(item, $event)" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> - <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button + v-else role="menuitem" + tabindex="0" + :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" + @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" + @mouseenter.passive="onItemMouseEnter" + @mouseleave.passive="onItemMouseLeave" + > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> @@ -80,24 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </button> </template> - <span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]"> + <span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/> + <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/> </div> </div> </template> <script lang="ts"> -import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; +import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { isTouchUsing } from '@/scripts/touch.js'; +import { type Keymap } from '@/scripts/hotkey.js'; +import { isFocusable } from '@/scripts/focus.js'; +import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; const childrenCache = new WeakMap<MenuParent, MenuItem[]>(); </script> @@ -107,7 +189,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue')); const props = defineProps<{ items: MenuItem[]; - viaKeyboard?: boolean; asDrawer?: boolean; align?: 'center' | string; width?: number; @@ -119,17 +200,28 @@ const emit = defineEmits<{ (ev: 'hide'): void; }>(); -const itemsEl = shallowRef<HTMLDivElement>(); +const isNestingMenu = inject<boolean>('isNestingMenu', false); + +const itemsEl = shallowRef<HTMLElement>(); const items2 = ref<InnerMenuItem[]>(); const child = shallowRef<InstanceType<typeof XChild>>(); -const keymap = computed(() => ({ - 'up|k|shift+tab': focusUp, - 'down|j|tab': focusDown, - 'esc': close, -})); +const keymap = { + 'up|k|shift+tab': { + allowRepeat: true, + callback: () => focusUp(), + }, + 'down|j|tab': { + allowRepeat: true, + callback: () => focusDown(), + }, + 'esc': { + allowRepeat: true, + callback: () => close(false), + }, +} as const satisfies Keymap; const childShowingItem = ref<MenuItem | null>(); @@ -167,25 +259,19 @@ function childActioned() { close(true); } -const onGlobalMousedown = (event: MouseEvent) => { - if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return; - if (child.value && child.value.checkHit(event)) return; - closeChild(); -}; - let childCloseTimer: null | number = null; -function onItemMouseEnter(item) { +function onItemMouseEnter() { childCloseTimer = window.setTimeout(() => { closeChild(); }, 300); } -function onItemMouseLeave(item) { +function onItemMouseLeave() { if (childCloseTimer) window.clearTimeout(childCloseTimer); } -async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { +async function showRadioOptions(item: MenuRadio, ev: Event) { const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => { const value = item.options[key]; return { @@ -200,7 +286,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { if (props.asDrawer) { os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => { - emit('close'); + close(false); }); emit('hide'); } else { @@ -210,7 +296,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { } } -async function showChildren(item: MenuParent, ev: MouseEvent) { +async function showChildren(item: MenuParent, ev: Event) { const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; @@ -227,7 +313,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { if (props.asDrawer) { os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => { - emit('close'); + close(false); }); emit('hide'); } else { @@ -246,41 +332,87 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { } function close(actioned = false) { - emit('close', actioned); + disposeHandlers(); + nextTick(() => { + closeChild(); + emit('close', actioned); + }); +} + +function switchItem(item: MenuSwitch & { ref: any }) { + if (item.disabled !== undefined && (typeof item.disabled === 'boolean' ? item.disabled : item.disabled.value)) return; + item.ref = !item.ref; } function focusUp() { - focusPrev(document.activeElement); + if (disposed) return; + if (!itemsEl.value?.contains(document.activeElement)) return; + + const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); + const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); + const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; + + targetElement.focus(); } function focusDown() { - focusNext(document.activeElement); -} + if (disposed) return; + if (!itemsEl.value?.contains(document.activeElement)) return; -function switchItem(item: MenuSwitch & { ref: any }) { - if (item.disabled !== undefined && (typeof item.disabled === 'boolean' ? item.disabled : item.disabled.value)) return; - item.ref = !item.ref; -} + const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); + const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; + const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; -function getValue<T>(item?: ComputedRef<T> | T) { - return isRef(item) ? item.value : item; + targetElement.focus(); } -onMounted(() => { - if (props.viaKeyboard) { - nextTick(() => { - if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false); - }); - } +const onGlobalFocusin = (ev: FocusEvent) => { + if (disposed) return; + if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return; + nextTick(() => { + if (itemsEl.value != null && isFocusable(itemsEl.value)) { + itemsEl.value.focus({ preventScroll: true }); + nextTick(() => focusDown()); + } + }); +}; - // TODO: アクティブな要素までスクロール - //itemsEl.scrollTo(); +const onGlobalMousedown = (ev: MouseEvent) => { + if (disposed) return; + if (childTarget.value?.contains(getNodeOrNull(ev.target))) return; + if (child.value?.checkHit(ev)) return; + closeChild(); +}; +const setupHandlers = () => { + if (!isNestingMenu) { + document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + } document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); +}; + +let disposed = false; + +const disposeHandlers = () => { + disposed = true; + if (!isNestingMenu) { + document.removeEventListener('focusin', onGlobalFocusin); + } + document.removeEventListener('mousedown', onGlobalMousedown); +}; + +onMounted(() => { + setupHandlers(); + + if (!isNestingMenu) { + nextTick(() => itemsEl.value?.focus({ preventScroll: true })); + } }); onBeforeUnmount(() => { - document.removeEventListener('mousedown', onGlobalMousedown); + disposeHandlers(); }); </script> @@ -293,6 +425,10 @@ onBeforeUnmount(() => { overflow: auto; overscroll-behavior: contain; + &:focus-visible { + outline: none; + } + &.center { > .item { text-align: center; @@ -310,7 +446,7 @@ onBeforeUnmount(() => { font-size: 1em; padding: 12px 24px; - &:before { + &::before { width: calc(100% - 24px); border-radius: var(--radius); } @@ -340,8 +476,10 @@ onBeforeUnmount(() => { text-align: left; overflow: hidden; text-overflow: ellipsis; + text-decoration: none !important; + color: var(--menuFg, var(--fg)); - &:before { + &::before { content: ""; display: block; position: absolute; @@ -355,56 +493,56 @@ onBeforeUnmount(() => { border-radius: var(--radius-sm); } - &:not(:disabled):hover { - color: var(--accent); - text-decoration: none; + &:focus-visible { + outline: none; - &:before { - background: var(--accentedBg); + &:not(:hover):not(:active)::before { + outline: var(--focus) solid 2px; + outline-offset: -2px; } } - &.danger { - color: #ff2a2a; - - &:hover { - color: #fff; + &:not(:disabled) { + &:hover, + &:focus-visible:active, + &:focus-visible.active { + color: var(--menuHoverFg, var(--accent)); - &:before { - background: #ff4242; + &::before { + background-color: var(--menuHoverBg, var(--accentedBg)); } } - &:active { - color: #fff; + &:not(:focus-visible):active, + &:not(:focus-visible).active { + color: var(--menuActiveFg, var(--fgOnAccent)); - &:before { - background: #d42e2e !important; + &::before { + background-color: var(--menuActiveBg, var(--accent)); } } } - &:active, - &.active { - color: var(--fgOnAccent) !important; - opacity: 1; - - &:before { - background: var(--accent) !important; - } + &:disabled { + cursor: not-allowed; } - &.radioActive { - color: var(--accent) !important; - opacity: 1; + &.danger { + --menuFg: #ff2a2a; + --menuHoverFg: #fff; + --menuHoverBg: #ff4242; + --menuActiveFg: #fff; + --menuActiveBg: #d42e2e; + } - &:before { - background-color: var(--accentedBg) !important; - } + &.radio { + --menuActiveFg: var(--accent); + --menuActiveBg: var(--accentedBg); } - &:not(:active):focus-visible { - box-shadow: 0 0 0 2px var(--focus) inset; + &.parent { + --menuActiveFg: var(--accent); + --menuActiveBg: var(--accentedBg); } &.label { @@ -422,22 +560,6 @@ onBeforeUnmount(() => { pointer-events: none; opacity: 0.7; } - - &.parent { - pointer-events: auto; - display: flex; - align-items: center; - cursor: default; - - &.childShowing { - color: var(--accent); - text-decoration: none; - - &:before { - background: var(--accentedBg); - } - } - } } .item_content { @@ -456,18 +578,6 @@ onBeforeUnmount(() => { overflow: hidden; } -.switch { - position: relative; - display: flex; - transition: all 0.2s ease; - user-select: none; - cursor: pointer; -} - -.switchDisabled { - cursor: not-allowed; -} - .switchButton { margin-left: -2px; --height: 1.35em; @@ -479,14 +589,6 @@ onBeforeUnmount(() => { text-overflow: ellipsis; } -.switchInput { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; -} - .icon { margin-right: 8px; line-height: 1; @@ -515,12 +617,12 @@ onBeforeUnmount(() => { border-top: solid 0.5px var(--divider); } -.radio { +.radioIcon { display: inline-block; position: relative; width: 1em; height: 1em; - vertical-align: -.125em; + vertical-align: -0.125em; border-radius: 50%; border: solid 2px var(--divider); background-color: var(--panel); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 9e69ab2207..f8032f9b43 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -30,9 +30,9 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.transition_modal_leaveTo]: transitionName === 'modal', [$style.transition_send_leaveTo]: transitionName === 'send', })" - :duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened" + :duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened" > - <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick"> <slot :max-height="maxHeight" :type="type"></slot> @@ -47,6 +47,9 @@ import * as os from '@/os.js'; import { isTouchUsing } from '@/scripts/touch.js'; import { defaultStore } from '@/store.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { type Keymap } from '@/scripts/hotkey.js'; +import { focusTrap } from '@/scripts/focus-trap.js'; +import { focusParent } from '@/scripts/focus.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -68,6 +71,8 @@ const props = withDefaults(defineProps<{ zPriority?: 'low' | 'middle' | 'high'; noOverlap?: boolean; transparentBg?: boolean; + hasInteractionWithOtherFocusTrappedEls?: boolean; + returnFocusTo?: HTMLElement | null; }>(), { manualShowing: null, src: null, @@ -76,6 +81,8 @@ const props = withDefaults(defineProps<{ zPriority: 'low', noOverlap: true, transparentBg: false, + hasInteractionWithOtherFocusTrappedEls: false, + returnFocusTo: null, }); const emit = defineEmits<{ @@ -93,6 +100,7 @@ const maxHeight = ref<number>(); const fixed = ref(false); const transformOrigin = ref('center'); const showing = ref(true); +const modalRootEl = shallowRef<HTMLElement>(); const content = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex(props.zPriority); const useSendAnime = ref(false); @@ -131,6 +139,7 @@ const transitionDuration = computed((() => : 0 )); +let releaseFocusTrap: (() => void) | null = null; let contentClicking = false; function close(opts: { useSendAnimation?: boolean } = {}) { @@ -154,8 +163,11 @@ if (type.value === 'drawer') { } const keymap = { - 'esc': () => emit('esc'), -}; + 'esc': { + allowRepeat: true, + callback: () => emit('esc'), + }, +} as const satisfies Keymap; const MARGIN = 16; const SCROLLBAR_THICKNESS = 16; @@ -292,6 +304,10 @@ const onOpened = () => { }, { passive: true }); }; +const onClosed = () => { + emit('closed'); +}; + const alignObserver = new ResizeObserver((entries, observer) => { align(); }); @@ -309,6 +325,20 @@ onMounted(() => { align(); }, { immediate: true }); + watch([showing, () => props.manualShowing], ([showing, manualShowing]) => { + if (manualShowing === true || (manualShowing == null && showing === true)) { + if (modalRootEl.value != null) { + const { release } = focusTrap(modalRootEl.value, props.hasInteractionWithOtherFocusTrappedEls); + + releaseFocusTrap = release; + modalRootEl.value.focus(); + } + } else { + releaseFocusTrap?.(); + focusParent(props.returnFocusTo ?? props.src, true, false); + } + }, { immediate: true }); + nextTick(() => { alignObserver.observe(content.value!); }); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index d3657afa94..c3c7812036 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown"> +<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> + <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> <div ref="headerEl" :class="$style.header"> - <button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> + <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button> <span :class="$style.title"> <slot name="header"></slot> </span> - <button v-if="!withOkButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button> - <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button> + <button v-if="!withOkButton && withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button> + <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button> </div> <div :class="$style.body"> <slot :width="bodyWidth" :height="bodyHeight"></slot> @@ -27,11 +27,13 @@ import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ withOkButton: boolean; + withCloseButton: boolean; okButtonDisabled: boolean; width: number; height: number; }>(), { withOkButton: false, + withCloseButton: true, okButtonDisabled: false, width: 400, height: 500, @@ -42,6 +44,7 @@ const emit = defineEmits<{ (event: 'close'): void; (event: 'closed'): void; (event: 'ok'): void; + (event: 'esc'): void; }>(); const modal = shallowRef<InstanceType<typeof MkModal>>(); @@ -50,21 +53,13 @@ const headerEl = shallowRef<HTMLElement>(); const bodyWidth = ref(0); const bodyHeight = ref(0); -const close = () => { +function close() { modal.value?.close(); -}; +} -const onBgClick = () => { +function onBgClick() { emit('click'); -}; - -const onKeydown = (evt) => { - if (evt.which === 27) { // Esc - evt.preventDefault(); - evt.stopPropagation(); - close(); - } -}; +} const ro = new ResizeObserver((entries, observer) => { if (rootEl.value == null || headerEl.value == null) return; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index b8ce7ed830..7df84a70db 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" - :tabindex="!isDeleted ? '-1' : undefined" + :tabindex="isDeleted ? '-1' : '0'" > <div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo"> <MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/> @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </I18n> <div :class="$style.renoteInfo"> - <button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()"> + <button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()"> <i class="ti ti-dots" :class="$style.renoteMenu"></i> <MkTime :time="note.createdAt"/> </button> @@ -92,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> <div v-if="appearNote.files && appearNote.files.length > 0"> - <MkMediaList :mediaList="appearNote.files" @click.stop/> + <MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> <div v-if="isEnabledUrlPreview"> @@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :style="renoted ? 'color: var(--accent) !important;' : ''" @click.stop - @mousedown="renoted ? undoRenote(appearNote) : boostVisibility()" + @mousedown.prevent="renoted ? undoRenote(appearNote) : boostVisibility()" > <i class="ti ti-repeat"></i> <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p> @@ -154,10 +154,10 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -204,8 +204,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkButton from '@/components/MkButton.vue'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; +import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; @@ -231,7 +230,10 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { useRouter } from '@/router/supplier.js'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { host } from '@/config.js'; import { isEnabledUrlPreview } from '@/instance.js'; +import { type Keymap } from '@/scripts/hotkey.js'; +import { focusPrev, focusNext } from '@/scripts/focus.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -302,7 +304,7 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); - +const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); @@ -328,6 +330,11 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state. const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ + type: 'lookup', + url: `https://${host}/notes/${appearNote.value.id}`, +})); + /* Overload FunctionにLintが対応していないのでコメントアウト function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; @@ -348,15 +355,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string let renoting = false; const keymap = { - 'r': () => reply(true), - 'e|a|plus': () => react(true), - '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, - 'up|k|shift+tab': focusBefore, - 'down|j|tab': focusAfter, - 'esc': blur, - 'm|o': () => showMenu(true), - 's': () => showContent.value !== showContent.value, -}; + 'r': () => { + if (renoteCollapsed.value) return; + reply(); + }, + 'e|a|plus': () => { + if (renoteCollapsed.value) return; + react(); + }, + 'q': () => { + if (renoteCollapsed.value) return; + if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); + }, + 'm': () => { + if (renoteCollapsed.value) return; + showMenu(); + }, + 'c': () => { + if (renoteCollapsed.value) return; + if (!defaultStore.state.showClipButtonInNoteFooter) return; + clip(); + }, + 'o': () => { + if (renoteCollapsed.value) return; + galleryEl.value?.openGallery(); + }, + 'v|enter': () => { + if (renoteCollapsed.value) { + renoteCollapsed.value = false; + } else if (appearNote.value.cw != null) { + showContent.value = !showContent.value; + } else if (isLong) { + collapsed.value = !collapsed.value; + } + }, + 'esc': { + allowRepeat: true, + callback: () => blur(), + }, + 'up|k|shift+tab': { + allowRepeat: true, + callback: () => focusBefore(), + }, + 'down|j|tab': { + allowRepeat: true, + callback: () => focusAfter(), + }, +} as const satisfies Keymap; provide('react', (reaction: string) => { misskeyApi('notes/reactions/create', { @@ -389,12 +434,14 @@ if (!props.mock) { if (users.length < 1) return; - os.popup(MkUsersTooltip, { + const { dispose } = os.popup(MkUsersTooltip, { showing, users, count: appearNote.value.renoteCount, targetElement: renoteButton.value, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); useTooltip(quoteButton, async (showing) => { @@ -438,13 +485,15 @@ if (!props.mock) { if (users.length < 1) return; - os.popup(MkReactionsViewerDetails, { + const { dispose } = os.popup(MkReactionsViewerDetails, { showing, reaction: '❤️', users, count: appearNote.value.reactionCount, targetElement: reactButton.value!, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } } @@ -460,7 +509,7 @@ function boostVisibility() { } function renote(visibility: Visibility, localOnly: boolean = false) { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); renoting = true; @@ -506,7 +555,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { } function quote() { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); if (props.mock) { return; @@ -560,22 +609,21 @@ function quote() { } } -function reply(viaKeyboard = false): void { - pleaseLogin(); +function reply(): void { + pleaseLogin(undefined, pleaseLoginContext.value); if (props.mock) { return; } os.post({ reply: appearNote.value, channel: appearNote.value.channel, - animation: !viaKeyboard, }).then(() => { focus(); }); } function like(): void { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); sound.playMisskeySfx('reaction'); if (props.mock) { @@ -595,7 +643,7 @@ function like(): void { } function react(viaKeyboard = false): void { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -613,7 +661,9 @@ function react(viaKeyboard = false): void { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); } } else { blur(); @@ -706,15 +756,13 @@ function onContextmenu(ev: MouseEvent): void { } } -function showMenu(viaKeyboard = false): void { +function showMenu(): void { if (props.mock) { return; } const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); - os.popupMenu(menu, menuButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); + os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { @@ -724,7 +772,7 @@ async function menuVersions(viaKeyboard = false): Promise<void> { }).then(focus).finally(cleanup); } -async function clip() { +async function clip(): Promise<void> { if (props.mock) { return; } @@ -732,7 +780,7 @@ async function clip() { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } -function showRenoteMenu(viaKeyboard = false): void { +function showRenoteMenu(): void { if (props.mock) { return; } @@ -752,23 +800,19 @@ function showRenoteMenu(viaKeyboard = false): void { } if (isMyRenote) { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); os.popupMenu([ getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getUnrenote(), - ], renoteTime.value, { - viaKeyboard: viaKeyboard, - }); + ], renoteTime.value); } else { os.popupMenu([ getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, - ], renoteTime.value, { - viaKeyboard: viaKeyboard, - }); + ], renoteTime.value); } } @@ -793,11 +837,11 @@ function blur() { } function focusBefore() { - focusPrev(rootEl.value ?? null); + focusPrev(rootEl.value); } function focusAfter() { - focusNext(rootEl.value ?? null); + focusNext(rootEl.value); } function readPromo() { @@ -835,7 +879,7 @@ function emitUpdReaction(emoji: string, delta: number) { &:focus-visible { outline: none; - &:after { + &::after { content: ""; pointer-events: none; display: block; @@ -848,7 +892,7 @@ function emitUpdReaction(emoji: string, delta: number) { margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 1px var(--focus); + border: dashed 2px var(--focus); border-radius: var(--radius); box-sizing: border-box; } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 3904d33cfe..1354c0e7aa 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="rootEl" v-hotkey="keymap" :class="$style.root" + :tabindex="isDeleted ? '-1' : '0'" > <div v-if="appearNote.reply && appearNote.reply.replyId"> <div v-if="!conversationLoaded" style="padding: 16px"> @@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </I18n> </span> <div :class="$style.renoteInfo"> - <button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()"> + <button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()"> <i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i> <MkTime :time="note.createdAt"/> </button> @@ -97,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <div v-if="appearNote.files && appearNote.files.length > 0"> - <MkMediaList :mediaList="appearNote.files"/> + <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <div v-if="isEnabledUrlPreview"> @@ -127,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="$style.noteFooterButton" :style="renoted ? 'color: var(--accent) !important;' : ''" - @mousedown="renoted ? undoRenote() : boostVisibility()" + @mousedown.prevent="renoted ? undoRenote() : boostVisibility()" > <i class="ti ti-repeat"></i> <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p> @@ -154,10 +155,10 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else class="ph-smiley ph-bold ph-lg"></i> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> <i class="ti ti-dots"></i> </button> </footer> @@ -236,7 +237,7 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; @@ -249,6 +250,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; +import { host } from '@/config.js'; import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; @@ -264,6 +266,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; import { isEnabledUrlPreview } from '@/instance.js'; +import { type Keymap } from '@/scripts/hotkey.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -315,6 +318,7 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); @@ -349,14 +353,31 @@ if ($i) { let renoting = false; +const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ + type: 'lookup', + url: `https://${host}/notes/${appearNote.value.id}`, +})); + const keymap = { - 'r': () => reply(true), - 'e|a|plus': () => react(true), - '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, - 'esc': blur, - 'm|o': () => showMenu(true), - 's': () => showContent.value !== showContent.value, -}; + 'r': () => reply(), + 'e|a|plus': () => react(), + 'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); }, + 'm': () => showMenu(), + 'c': () => { + if (!defaultStore.state.showClipButtonInNoteFooter) return; + clip(); + }, + 'o': () => galleryEl.value?.openGallery(), + 'v|enter': () => { + if (appearNote.value.cw != null) { + showContent.value = !showContent.value; + } + }, + 'esc': { + allowRepeat: true, + callback: () => blur(), + }, +} as const satisfies Keymap; provide('react', (reaction: string) => { misskeyApi('notes/reactions/create', { @@ -416,12 +437,14 @@ useTooltip(renoteButton, async (showing) => { if (users.length < 1) return; - os.popup(MkUsersTooltip, { + const { dispose } = os.popup(MkUsersTooltip, { showing, users, count: appearNote.value.renoteCount, targetElement: renoteButton.value, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); useTooltip(quoteButton, async (showing) => { @@ -465,18 +488,20 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') { if (users.length < 1) return; - os.popup(MkReactionsViewerDetails, { + const { dispose } = os.popup(MkReactionsViewerDetails, { showing, reaction: '❤️', users, count: appearNote.value.reactionCount, targetElement: reactButton.value!, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } function renote(visibility: Visibility, localOnly: boolean = false) { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); renoting = true; @@ -569,20 +594,19 @@ function quote() { } } -function reply(viaKeyboard = false): void { - pleaseLogin(); +function reply(): void { + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); os.post({ reply: appearNote.value, channel: appearNote.value.channel, - animation: !viaKeyboard, }).then(() => { focus(); }); } -function react(viaKeyboard = false): void { - pleaseLogin(); +function react(): void { + pleaseLogin(undefined, pleaseLoginContext.value); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -591,12 +615,14 @@ function react(viaKeyboard = false): void { noteId: appearNote.value.id, override: defaultLike.value, }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); } } else { blur(); @@ -687,11 +713,9 @@ function onContextmenu(ev: MouseEvent): void { } } -function showMenu(viaKeyboard = false): void { +function showMenu(): void { const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); - os.popupMenu(menu, menuButton.value, { - viaKeyboard, - }).then(focus).finally(cleanup); + os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { @@ -701,13 +725,13 @@ async function menuVersions(viaKeyboard = false): Promise<void> { }).then(focus).finally(cleanup); } -async function clip() { +async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } -function showRenoteMenu(viaKeyboard = false): void { +function showRenoteMenu(): void { if (!isMyRenote) return; - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', @@ -718,9 +742,7 @@ function showRenoteMenu(viaKeyboard = false): void { }); isDeleted.value = true; }, - }], renoteTime.value, { - viaKeyboard: viaKeyboard, - }); + }], renoteTime.value); } function focus() { @@ -794,6 +816,28 @@ function animatedMFM() { transition: box-shadow 0.1s ease; overflow: clip; contain: content; + + &:focus-visible { + outline: none; + + &::after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 2px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } } .footer { diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index a8853a8a5f..7ccc2c0320 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <MkAvatar :class="$style.avatar" :user="user" link preview/> + <MkAvatar :class="$style.avatar" :user="user"/> <div :class="$style.main"> <div :class="$style.header"> <MkUserName :user="user" :nowrap="true"/> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 8917c685ca..9948676198 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.head"> - <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> - <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> - <img v-else-if="notification.icon" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> + <MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/> + <img v-else-if="'icon' in notification" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> <div :class="[$style.subIcon, { [$style.t_follow]: notification.type === 'follow', @@ -174,13 +174,13 @@ const props = withDefaults(defineProps<{ const followRequestDone = ref(false); const acceptFollowRequest = () => { - if (props.notification.user == null) return; + if (!('user' in props.notification)) return; followRequestDone.value = true; misskeyApi('following/requests/accept', { userId: props.notification.user.id }); }; const rejectFollowRequest = () => { - if (props.notification.user == null) return; + if (!('user' in props.notification)) return; followRequestDone.value = true; misskeyApi('following/requests/reject', { userId: props.notification.user.id }); }; @@ -353,7 +353,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) margin-right: 4px; position: relative; - &:before { + &::before { position: absolute; transform: rotate(180deg); } diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index f6dc00698c..8559d4b96e 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1"> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj"> <div v-if="page.eyeCatchingImage" class="thumbnail"> <MediaImage :image="page.eyeCatchingImage" @@ -50,12 +50,29 @@ const props = defineProps<{ <style lang="scss" scoped> .vhpxefrj { display: block; + position: relative; &:hover { text-decoration: none; color: var(--accent); } + &:focus-within { + outline: none; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--radius); + pointer-events: none; + box-shadow: inset 0 0 0 2px var(--focus); + } + } + > .thumbnail { & + article { border-radius: 0 0 var(--radius) var(--radius); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index ec57737b09..8f6109ca04 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -33,7 +33,7 @@ import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue' import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index ba0013ec7d..393ac4efba 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -36,7 +36,9 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { host } from '@/config.js'; import { useInterval } from '@/scripts/use-interval.js'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; const props = defineProps<{ noteId: string; @@ -62,6 +64,11 @@ const timer = computed(() => i18n.tsx._poll[ const showResult = ref(props.readOnly || isVoted.value); +const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ + type: 'lookup', + url: `https://${host}/notes/${props.noteId}`, +})); + // 期限付きアンケート if (props.poll.expiresAt) { const tick = () => { @@ -78,7 +85,7 @@ if (props.poll.expiresAt) { } const vote = async (id) => { - pleaseLogin(); + pleaseLogin(undefined, pleaseLoginContext.value); if (props.readOnly || closed.value || isVoted.value) return; if (!props.poll.multiple) { diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index db74354bbb..3726ddf822 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" 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/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 3748f0cc64..ff29b66193 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed"> - <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed"> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/> </MkModal> </template> @@ -19,8 +19,8 @@ defineProps<{ items: MenuItem[]; align?: 'center' | string; width?: number; - viaKeyboard?: boolean; src?: any; + returnFocusTo?: HTMLElement | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 78df70ca5c..d778bc046c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -261,7 +261,7 @@ const canPost = computed((): boolean => { 1 <= files.value.length || poll.value != null || props.renote != null || - (props.reply != null && quoteId.value != null) + quoteId.value != null ) && (textLength.value <= maxTextLength.value) && (!poll.value || poll.value.choices.length >= 2); @@ -369,6 +369,8 @@ function watchForDraft() { watch(files, () => saveDraft(), { deep: true }); watch(visibility, () => saveDraft()); watch(localOnly, () => saveDraft()); + watch(quoteId, () => saveDraft()); + watch(reactionAcceptance, () => saveDraft()); } function MFMWindow() { @@ -469,7 +471,7 @@ function setVisibility() { return; } - os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, isSilenced: $i.isSilenced, localOnly: localOnly.value, @@ -482,7 +484,8 @@ function setVisibility() { defaultStore.set('visibility', visibility.value); } }, - }, 'closed'); + closed: () => dispose(), + }); } async function toggleLocalOnly() { @@ -575,6 +578,7 @@ function clear() { function onKeydown(ev: KeyboardEvent) { if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post(); + if (ev.key === 'Escape') emit('esc'); } @@ -630,8 +634,8 @@ async function onPaste(ev: ClipboardEvent) { return; } - const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0"); - const file = new File([paste], `${fileName}.txt`, { type: "text/plain" }); + const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0'); + const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); upload(file, `${fileName}.txt`); }); } @@ -707,6 +711,8 @@ function saveDraft() { files: files.value, poll: poll.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, + quoteId: quoteId.value, + reactionAcceptance: reactionAcceptance.value, }, }; @@ -737,7 +743,9 @@ async function post(ev?: MouseEvent) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); } } @@ -930,10 +938,23 @@ async function insertEmoji(ev: MouseEvent) { textAreaReadOnly.value = true; const target = ev.currentTarget ?? ev.target; if (target == null) return; + + // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、 + // focustrapをかけているとinsertTextAtCursorが効かない + // そのため、投稿フォームのテキストに直接注入する + // See: https://github.com/misskey-dev/misskey/pull/14282 + // https://github.com/misskey-dev/misskey/issues/14274 + + let pos = textareaEl.value?.selectionStart ?? 0; + let posEnd = textareaEl.value?.selectionEnd ?? text.value.length; emojiPicker.show( target as HTMLElement, emoji => { - insertTextAtCursor(textareaEl.value, emoji); + const textBefore = text.value.substring(0, pos); + const textAfter = text.value.substring(posEnd); + text.value = textBefore + emoji + textAfter; + pos += emoji.length; + posEnd += emoji.length; }, () => { textAreaReadOnly.value = false; @@ -1026,6 +1047,8 @@ onMounted(() => { users.forEach(u => pushVisibleUser(u)); }); } + quoteId.value = draft.data.quoteId; + reactionAcceptance.value = draft.data.reactionAcceptance; } } @@ -1033,9 +1056,11 @@ onMounted(() => { if (props.initialNote) { const init = props.initialNote; text.value = init.text ? init.text : ''; - files.value = init.files ?? []; - cw.value = init.cw ?? null; useCw.value = init.cw != null; + cw.value = init.cw ?? null; + visibility.value = init.visibility; + localOnly.value = init.localOnly ?? false; + files.value = init.files ?? []; if (init.poll) { poll.value = { choices: init.poll.choices.map(x => x.text), @@ -1044,9 +1069,13 @@ onMounted(() => { expiredAfter: null, }; } - visibility.value = init.visibility; - localOnly.value = init.localOnly ?? false; + if (init.visibleUserIds) { + misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => { + users.forEach(u => pushVisibleUser(u)); + }); + } quoteId.value = init.renote ? init.renote.id : null; + reactionAcceptance.value = init.reactionAcceptance; } nextTick(() => watchForDraft()); @@ -1119,6 +1148,15 @@ defineExpose({ margin: 12px 12px 12px 6px; vertical-align: bottom; + &:focus-visible { + outline: none; + + .submitInner { + outline: 2px solid var(--fgOnAccent); + outline-offset: -4px; + } + } + &:disabled { opacity: 0.7; } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index a979cbc59f..a3fb7c691f 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -108,7 +108,7 @@ async function rename(file) { async function describe(file) { if (mock) return; - os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { default: file.comment !== null ? file.comment : '', file: file, }, { @@ -121,7 +121,8 @@ async function describe(file) { file.comment = comment; }); }, - }, 'closed'); + closed: () => dispose(), + }); } async function crop(file: Misskey.entities.DriveFile): Promise<void> { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index ad990e21db..947c0ee4d0 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()"> +<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()"> <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/> </MkModal> </template> diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue new file mode 100644 index 0000000000..d950d66c6e --- /dev/null +++ b/packages/frontend/src/components/MkPreview.vue @@ -0,0 +1,150 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.preview"> + <div :class="$style.preview__content1"> + <MkInput v-model="text"> + <template #label>Text</template> + </MkInput> + <MkSwitch v-model="flag" :class="$style.preview__content1__switch_button"> + <span>Switch is now {{ flag ? 'on' : 'off' }}</span> + </MkSwitch> + <div :class="$style.preview__content1__input"> + <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> + <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> + <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> + </div> + <div :class="$style.preview__content1__button"> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> + </div> + </div> + <div :class="$style.preview__content2" style="pointer-events: none;"> + <Mfm :text="mfm"/> + </div> + <div :class="$style.preview__content3"> + <MkButton inline primary @click="openMenu">Open menu</MkButton> + <MkButton inline primary @click="openDialog">Open dialog</MkButton> + <MkButton inline primary @click="openForm">Open form</MkButton> + <MkButton inline primary @click="openDrive">Open drive</MkButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkRadio from '@/components/MkRadio.vue'; +import * as os from '@/os.js'; +import * as config from '@/config.js'; +import { $i } from '@/account.js'; + +const text = ref(''); +const flag = ref(true); +const radio = ref('misskey'); +const mfm = ref(`Hello world! This is an @example mention. BTW you are @${$i ? $i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`); + +const openDialog = async () => { + await os.alert({ + type: 'warning', + title: 'Oh my Aichan', + text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); +}; + +const openForm = async () => { + await os.form('Example form', { + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property', + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property', + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property', + }, + }); +}; + +const openDrive = async () => { + await os.selectDriveFile(false); +}; + +const selectUser = async () => { + await os.selectUser(); +}; + +const openMenu = async (ev: Event) => { + os.popupMenu([{ + type: 'label', + text: 'Fruits', + }, { + text: 'Create some apples', + action: () => {}, + }, { + text: 'Read some oranges', + action: () => {}, + }, { + text: 'Update some melons', + action: () => {}, + }, { + text: 'Delete some bananas', + danger: true, + action: () => {}, + }], ev.currentTarget ?? ev.target); +}; +</script> + +<style lang="scss" module> +.preview { + padding: 16px; + + &__content1 { + + &__switch_button { + padding: 16px 0 8px 0; + } + + &__input { + padding: 8px 0 8px 0; + + div { + margin: 0 8px 8px 0; + } + } + + &__button { + padding: 4px 0 8px 0; + + button { + margin: 0 8px 8px 0; + } + } + } + + &__content2 { + padding: 8px 0 8px 0; + } + + &__content3 { + padding: 8px 0 8px 0; + + button { + margin: 0 8px 8px 0; + + } + } +} +</style> diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index 0b4023f254..e02f76a58f 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]" :aria-checked="checked" :aria-disabled="disabled" + role="checkbox" @click="toggle" > <input @@ -69,6 +70,11 @@ function toggle(): void { border-color: var(--inputBorderHover) !important; } + &:focus-within { + outline: none; + box-shadow: 0 0 0 2px var(--focus); + } + &.checked { background-color: var(--accentedBg) !important; border-color: var(--accentedBg) !important; @@ -78,7 +84,7 @@ function toggle(): void { > .button { border-color: var(--accent); - &:after { + &::after { background-color: var(--accent); transform: scale(1); opacity: 1; @@ -104,7 +110,7 @@ function toggle(): void { border-radius: var(--radius-full); transition: inherit; - &:after { + &::after { content: ''; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 549438f61b..705c93f770 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -29,6 +29,9 @@ export default defineComponent({ // なぜかFragmentになることがあるため if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; + // vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる) + options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if')); + return () => h('div', { class: 'novjtcto', }, [ @@ -40,6 +43,7 @@ export default defineComponent({ }, options.map(option => h(MkRadio, { key: option.key as string, value: option.props?.value, + disabled: option.props?.disabled, modelValue: value.value, 'onUpdate:modelValue': _v => value.value = _v, }, () => option.children)), diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 46d76e2551..244fcdceae 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -101,17 +101,19 @@ const steps = computed(() => { } }); -const onMousedown = (ev: MouseEvent | TouchEvent) => { +function onMousedown(ev: MouseEvent | TouchEvent) { ev.preventDefault(); const tooltipShowing = ref(true); - os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { showing: tooltipShowing, text: computed(() => { return props.textConverter(finalValue.value); }), targetElement: thumbEl, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); const style = document.createElement('style'); style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); @@ -152,7 +154,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { window.addEventListener('touchmove', onDrag); window.addEventListener('mouseup', onMouseup, { once: true }); window.addEventListener('touchend', onMouseup, { once: true }); -}; +} </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 068a2968db..c0cbd8a65d 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -24,11 +24,13 @@ const elRef = shallowRef(); if (props.withTooltip) { useTooltip(elRef, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), { showing, reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'), targetElement: elRef.value.$el, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } </script> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index 8b5e6efdf3..60118fadd2 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -81,6 +81,7 @@ function getReactionName(reaction: string): string { } .user { + display: flex; line-height: 24px; padding-top: 4px; white-space: nowrap; diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index f74f5ec21c..6506035f8f 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -114,10 +114,12 @@ async function menu(ev) { text: i18n.ts.info, icon: 'ti ti-info-circle', action: async () => { - os.popup(MkCustomEmojiDetailedDialog, { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { emoji: await misskeyApiGet('emoji', { name: props.reaction.replace(/:/g, '').replace(/@\./, ''), }), + }, { + closed: () => dispose(), }); }, }], ev.currentTarget ?? ev.target); @@ -129,7 +131,9 @@ function anime() { const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; const y = rect.top + (buttonEl.value.offsetHeight / 2); - os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end'); + const { dispose } = os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, { + end: () => dispose(), + }); } watch(() => props.count, (newCount, oldCount) => { @@ -151,13 +155,15 @@ if (!mock) { const users = reactions.map(x => x.user); - os.popup(XDetails, { + const { dispose } = os.popup(XDetails, { showing, reaction: props.reaction, users, count: props.count, targetElement: buttonEl.value, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }, 100); } </script> diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 76bc3e8c0c..8254ac83cf 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -6,20 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> - <div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show"> + <div + ref="container" + tabindex="0" + :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]" + @focus="focused = true" + @blur="focused = false" + @mousedown.prevent="show" + @keydown.space.enter="show" + > <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <select ref="inputEl" v-model="v" v-adaptive-border + tabindex="-1" :class="$style.inputCore" :disabled="disabled" :required="required" :readonly="readonly" :placeholder="placeholder" - @focus="focused = true" - @blur="focused = false" @input="onInput" + @mousedown.prevent="() => {}" + @keydown.prevent="() => {}" > <slot></slot> </select> @@ -75,7 +84,7 @@ const height = props.large ? 39 : 36; -const focus = () => inputEl.value?.focus(); +const focus = () => container.value?.focus(); const onInput = (ev) => { changed.value = true; }; @@ -126,7 +135,9 @@ onMounted(() => { }); function show() { - focused.value = true; + if (opening.value) return; + focus(); + opening.value = true; const menu: MenuItem[] = []; @@ -173,8 +184,6 @@ function show() { onClosing: () => { opening.value = false; }, - }).then(() => { - focused.value = false; }); } </script> @@ -225,6 +234,10 @@ function show() { } } + &:focus { + outline: none; + } + &:hover { > .inputCore { border-color: var(--inputBorderHover) !important; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index a46a35c45c..42fa2bf4a7 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -6,10 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> <div class="_gaps_m"> - <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> + <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> <MkInfo v-if="message"> {{ message }} </MkInfo> + <div v-if="openOnRemote" class="_gaps_m"> + <div class="_gaps_s"> + <MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> + {{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> + </MkButton> + <button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> + {{ i18n.ts.specifyServerHost }} + </button> + </div> + <div :class="$style.orHr"> + <p :class="$style.orMsg">{{ i18n.ts.or }}</p> + </div> + </div> <div v-if="!totpLogin" class="normal-signin _gaps_m"> <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <template #prefix>@</template> @@ -28,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.retry }} </MkButton> </div> - <div v-if="user && user.securityKeys" class="or-hr"> - <p class="or-msg">{{ i18n.ts.or }}</p> + <div v-if="user && user.securityKeys" :class="$style.orHr"> + <p :class="$style.orMsg">{{ i18n.ts.or }}</p> </div> <div class="twofa-group totp-group _gaps"> <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required> @@ -53,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -60,6 +74,7 @@ import MkInfo from '@/components/MkInfo.vue'; import { host as configHost } from '@/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; +import { query, extractDomain } from '@/scripts/url.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -72,28 +87,22 @@ const host = ref(toUnicode(configHost)); const totpLogin = ref(false); const isBackupCode = ref(false); const queryingKey = ref(false); -const credentialRequest = ref<CredentialRequestOptions | null>(null); +let credentialRequest: CredentialRequestOptions | null = null; const emit = defineEmits<{ (ev: 'login', v: any): void; }>(); -const props = defineProps({ - withAvatar: { - type: Boolean, - required: false, - default: true, - }, - autoSet: { - type: Boolean, - required: false, - default: false, - }, - message: { - type: String, - required: false, - default: '', - }, +const props = withDefaults(defineProps<{ + withAvatar?: boolean; + autoSet?: boolean; + message?: string, + openOnRemote?: OpenOnRemoteOptions, +}>(), { + withAvatar: true, + autoSet: false, + message: '', + openOnRemote: undefined, }); function onUsernameChange(): void { @@ -113,14 +122,14 @@ function onLogin(res: any): Promise<void> | void { } async function queryKey(): Promise<void> { - if (credentialRequest.value == null) return; + if (credentialRequest == null) return; queryingKey.value = true; - await webAuthnRequest(credentialRequest.value) + await webAuthnRequest(credentialRequest) .catch(() => { queryingKey.value = false; return Promise.reject(null); }).then(credential => { - credentialRequest.value = null; + credentialRequest = null; queryingKey.value = false; signing.value = true; return misskeyApi('signin', { @@ -151,7 +160,7 @@ function onSubmit(): void { }).then(res => { totpLogin.value = true; signing.value = false; - credentialRequest.value = parseRequestOptionsFromJSON({ + credentialRequest = parseRequestOptionsFromJSON({ publicKey: res, }); }) @@ -218,8 +227,65 @@ function loginFailed(err: any): void { } function resetPassword(): void { - os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { - }, 'closed'); + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { + closed: () => dispose(), + }); +} + +function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { + switch (options.type) { + case 'web': + case 'lookup': { + let _path: string; + + if (options.type === 'lookup') { + // TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼ + // _path = `/lookup?uri=${encodeURIComponent(_path)}`; + _path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`; + } else { + _path = options.path; + } + + if (targetHost) { + window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); + } else { + window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); + } + break; + } + case 'share': { + const params = query(options.params); + if (targetHost) { + window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener'); + } else { + window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener'); + } + break; + } + } +} + +async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> { + const { canceled, result: hostTemp } = await os.inputText({ + title: i18n.ts.inputHostName, + placeholder: 'misskey.example.com', + }); + + if (canceled) return; + + let targetHost: string | null = hostTemp; + + // ドメイン部分だけを取り出す + targetHost = extractDomain(targetHost); + if (targetHost == null) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + text: i18n.ts.tryAgain, + }); + return; + } + openRemote(options, targetHost); } </script> @@ -233,4 +299,36 @@ function resetPassword(): void { background-size: cover; border-radius: var(--radius-full); } + +.instanceManualSelectButton { + display: block; + text-align: center; + opacity: .7; + font-size: .8em; + + &:hover { + text-decoration: underline; + } +} + +.orHr { + position: relative; + margin: .4em auto; + width: 100%; + height: 1px; + background: var(--divider); +} + +.orMsg { + position: absolute; + top: -.6em; + display: inline-block; + padding: 0 1em; + background: var(--panel); + font-size: 0.8em; + color: var(--fgOnPanel); + margin: 0; + left: 50%; + transform: translateX(-50%); +} </style> diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 33355bb99e..524c62b4d3 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -6,21 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkModalWindow ref="dialog" - :width="370" - :height="400" + :width="400" + :height="430" @close="onClose" @closed="emit('closed')" > <template #header>{{ i18n.ts.login }}</template> <MkSpacer :marginMin="20" :marginMax="28"> - <MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/> + <MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> </MkSpacer> </MkModalWindow> </template> <script lang="ts" setup> import { shallowRef } from 'vue'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import MkSignin from '@/components/MkSignin.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -28,9 +29,11 @@ import { i18n } from '@/i18n.js'; withDefaults(defineProps<{ autoSet?: boolean; message?: string, + openOnRemote?: OpenOnRemoteOptions, }>(), { autoSet: false, message: '', + openOnRemote: undefined, }); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 2a7c72ccd9..041ae88109 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -10,15 +10,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="items"> <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </a> - <button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </button> - <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </MkA> @@ -67,6 +67,10 @@ defineProps<{ background: var(--panelHighlight); } + &:focus-visible { + outline-offset: -2px; + } + &.active { color: var(--accent); background: var(--accentedBg); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index a19b45448b..a0994d9cc9 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only type="checkbox" :disabled="disabled" :class="$style.input" - @keydown.enter="toggle" + @click="toggle" > - <XButton :checked="checked" :disabled="disabled" @toggle="toggle"/> - <span :class="$style.body"> + <XButton :class="$style.toggle" :checked="checked" :disabled="disabled" @toggle="toggle"/> + <span v-if="!noBody" :class="$style.body"> <!-- TODO: 無名slotの方は廃止 --> <span :class="$style.label"> <span @click="toggle"> @@ -34,16 +34,19 @@ const props = defineProps<{ modelValue: boolean | Ref<boolean>; disabled?: boolean; helpText?: string; + noBody?: boolean; }>(); const emit = defineEmits<{ (ev: 'update:modelValue', v: boolean): void; + (ev: 'change', v: boolean): void; }>(); const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); + emit('change', !checked.value); }; </script> @@ -72,7 +75,13 @@ const toggle = () => { height: 0; opacity: 0; margin: 0; + + &:focus-visible ~ .toggle { + outline: 2px solid var(--focus); + outline-offset: 2px; + } } + .body { margin-left: 12px; margin-top: 2px; diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts new file mode 100644 index 0000000000..69b8edd85a --- /dev/null +++ b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import * as os from '@/os.js'; + +export type SystemWebhookEventType = 'abuseReport' | 'abuseReportResolved'; + +export type MkSystemWebhookEditorProps = { + mode: 'create' | 'edit'; + id?: string; + requiredEvents?: SystemWebhookEventType[]; +}; + +export type MkSystemWebhookResult = { + id?: string; + isActive: boolean; + name: string; + on: SystemWebhookEventType[]; + url: string; + secret: string; +}; + +export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> { + const { result } = await new Promise<{ result: MkSystemWebhookResult | null }>(async resolve => { + const { dispose } = os.popup( + defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')), + props, + { + submitted: (ev: MkSystemWebhookResult) => { + resolve({ result: ev }); + }, + canceled: () => { + resolve({ result: null }); + }, + closed: () => { + dispose(); + }, + }, + ); + }); + + return result; +} diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue new file mode 100644 index 0000000000..f5c7a3160b --- /dev/null +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -0,0 +1,238 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialogEl" + :width="450" + :height="590" + :canClose="true" + :withOkButton="false" + :okButtonDisabled="false" + @click="onCancelClicked" + @close="onCancelClicked" + @closed="emit('closed')" +> + <template #header> + {{ mode === 'create' ? i18n.ts._webhookSettings.createWebhook : i18n.ts._webhookSettings.modifyWebhook }} + </template> + + <div style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <MkLoading v-if="loading !== 0"/> + <div v-else :class="$style.root" class="_gaps_m"> + <MkInput v-model="title"> + <template #label>{{ i18n.ts._webhookSettings.name }}</template> + </MkInput> + <MkInput v-model="url"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="secret"> + <template #label>{{ i18n.ts._webhookSettings.secret }}</template> + </MkInput> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._webhookSettings.trigger }}</template> + + <div class="_gaps_s"> + <MkSwitch v-model="events.abuseReport" :disabled="disabledEvents.abuseReport"> + <template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReport }}</template> + </MkSwitch> + <MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved"> + <template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template> + </MkSwitch> + <MkSwitch v-model="events.userCreated" :disabled="disabledEvents.userCreated"> + <template #label>{{ i18n.ts._webhookSettings._systemEvents.userCreated }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkSwitch v-model="isActive"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </div> + </MkSpacer> + <div :class="$style.footer" class="_buttonsCenter"> + <MkButton primary rounded :disabled="disableSubmitButton" @click="onSubmitClicked"> + <i class="ti ti-check"></i> + {{ i18n.ts.ok }} + </MkButton> + <MkButton rounded @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { + MkSystemWebhookEditorProps, + MkSystemWebhookResult, + SystemWebhookEventType, +} from '@/components/MkSystemWebhookEditor.impl.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import * as os from '@/os.js'; + +type EventType = { + abuseReport: boolean; + abuseReportResolved: boolean; + userCreated: boolean; +} + +const emit = defineEmits<{ + (ev: 'submitted', result: MkSystemWebhookResult): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); + +const props = defineProps<MkSystemWebhookEditorProps>(); + +const { mode, id, requiredEvents } = toRefs(props); + +const loading = ref<number>(0); + +const title = ref<string>(''); +const url = ref<string>(''); +const secret = ref<string>(''); +const events = ref<EventType>({ + abuseReport: true, + abuseReportResolved: true, + userCreated: true, +}); +const isActive = ref<boolean>(true); + +const disabledEvents = ref<EventType>({ + abuseReport: false, + abuseReportResolved: false, + userCreated: false, +}); + +const disableSubmitButton = computed(() => { + if (!title.value) { + return true; + } + if (!url.value) { + return true; + } + if (!secret.value) { + return true; + } + + return false; +}); + +async function onSubmitClicked() { + await loadingScope(async () => { + const params = { + isActive: isActive.value, + name: title.value, + url: url.value, + secret: secret.value, + on: Object.keys(events.value).filter(ev => events.value[ev as keyof EventType]) as SystemWebhookEventType[], + }; + + try { + switch (mode.value) { + case 'create': { + const result = await misskeyApi('admin/system-webhook/create', params); + dialogEl.value?.close(); + emit('submitted', result); + break; + } + case 'edit': { + // eslint-disable-next-line + const result = await misskeyApi('admin/system-webhook/update', { id: id.value!, ...params }); + dialogEl.value?.close(); + emit('submitted', result); + break; + } + } + // eslint-disable-next-line + } catch (ex: any) { + const msg = ex.message ?? i18n.ts.internalServerErrorDescription; + await os.alert({ type: 'error', title: i18n.ts.error, text: msg }); + dialogEl.value?.close(); + emit('canceled'); + } + }); +} + +function onCancelClicked() { + dialogEl.value?.close(); + emit('canceled'); +} + +async function loadingScope<T>(fn: () => Promise<T>): Promise<T> { + loading.value++; + try { + return await fn(); + } finally { + loading.value--; + } +} + +onMounted(async () => { + await loadingScope(async () => { + switch (mode.value) { + case 'edit': { + if (!id.value) { + throw new Error('id is required'); + } + + try { + const res = await misskeyApi('admin/system-webhook/show', { id: id.value }); + + title.value = res.name; + url.value = res.url; + secret.value = res.secret; + isActive.value = res.isActive; + for (const ev of Object.keys(events.value)) { + events.value[ev] = res.on.includes(ev as SystemWebhookEventType); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (ex: any) { + const msg = ex.message ?? i18n.ts.internalServerErrorDescription; + await os.alert({ type: 'error', title: i18n.ts.error, text: msg }); + dialogEl.value?.close(); + emit('canceled'); + } + break; + } + } + + for (const ev of requiredEvents.value ?? []) { + disabledEvents.value[ev] = true; + } + }); +}); +</script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; +} + +.footer { + position: sticky; + z-index: 10000; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + background: var(--acrylicBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} +</style> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 0f7eb3b86c..b69c19eb9e 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -19,6 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import type { BasicTimelineType } from '@/timelines.js'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; @@ -29,7 +30,7 @@ import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; const props = withDefaults(defineProps<{ - src: 'home' | 'local' | 'social' | 'bubble' | 'global' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; + src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; list?: string; antenna?: string; channel?: string; diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index 44566b1d9b..a9014d4202 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -105,7 +105,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index a2dcede7dd..322082f5a0 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -115,7 +115,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue index 877c3c9eaa..b900a30c85 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue @@ -7,10 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._timeline.description1 }}</div> <div class="_gaps_s"> - <div><i class="ti ti-home"></i> <b>{{ i18n.ts._timelines.home }}</b> … {{ i18n.ts._initialTutorial._timeline.home }}</div> - <div><i class="ti ti-planet"></i> <b>{{ i18n.ts._timelines.local }}</b> … {{ i18n.ts._initialTutorial._timeline.local }}</div> - <div><i class="ti ti-universe"></i> <b>{{ i18n.ts._timelines.social }}</b> … {{ i18n.ts._initialTutorial._timeline.social }}</div> - <div><i class="ti ti-whirl"></i> <b>{{ i18n.ts._timelines.global }}</b> … {{ i18n.ts._initialTutorial._timeline.global }}</div> + <div v-for="tl in basicTimelineTypes"> + <i :class="basicTimelineIconClass(tl)"></i> <b>{{ i18n.ts._timelines[tl] }}</b> … {{ i18n.ts._initialTutorial._timeline[tl] }} + </div> </div> <div class="_gaps_s"> <div>{{ i18n.ts._initialTutorial._timeline.description2 }}</div> @@ -22,12 +21,12 @@ SPDX-License-Identifier: AGPL-3.0-only <a href="https://misskey-hub.net/docs/for-users/features/timeline/" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> - </div> </template> <script setup lang="ts"> import { i18n } from '@/i18n.js'; +import { basicTimelineIconClass, basicTimelineTypes } from '@/timelines.js'; </script> <style lang="scss" module> @@ -56,7 +55,7 @@ import { i18n } from '@/i18n.js'; font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index d2711e4ec5..9adc8d466c 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -172,7 +172,7 @@ const emit = defineEmits<{ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const page = ref(props.initialPage ?? 0); watch(page, (to) => { diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 1c9ba35637..a51b878580 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only scrolling="no" :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')" :class="$style.playerIframe" - :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" + :src="transformPlayerUrl(player.url)" :style="{ border: 0 }" ></iframe> <span v-else>invalid url</span> @@ -91,6 +91,7 @@ import * as os from '@/os.js'; import { deviceKind } from '@/scripts/device-kind.js'; import MkButton from '@/components/MkButton.vue'; import { versatileLang } from '@/scripts/intl-const.js'; +import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; import { defaultStore } from '@/store.js'; type SummalyResult = Awaited<ReturnType<typeof summaly>>; @@ -188,11 +189,13 @@ function adjustTweetHeight(message: any) { if (height) tweetHeight.value = height; } -const openPlayer = (): void => { - os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), { +function openPlayer(): void { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), { url: requestUrl.href, + }, { + // TODO }); -}; +} (window as any).addEventListener('message', adjustTweetHeight); diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 2ebc86426c..e528f04dfc 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span> </div> </div> - <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/> + <MkFollowButton v-if="user.id != $i?.id" :class="$style.follow" :user="user" mini/> </div> </template> diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index b76be051d8..7d210a4385 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref } from 'vue'; +import { onMounted, ref, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; @@ -91,7 +91,7 @@ const host = ref(''); const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); const selected = ref<Misskey.entities.UserLite | null>(null); -const dialogEl = ref(); +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); function search() { if (username.value === '' && host.value === '') { @@ -123,7 +123,7 @@ async function ok() { }); emit('ok', user); - dialogEl.value.close(); + dialogEl.value?.close(); // 最近使ったユーザー更新 let recents = defaultStore.state.recentlyUsedUsers; @@ -134,7 +134,7 @@ async function ok() { function cancel() { emit('cancel'); - dialogEl.value.close(); + dialogEl.value?.close(); } onMounted(() => { diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index 1d376382ca..514350c930 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -148,7 +148,7 @@ const emit = defineEmits<{ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const page = ref(defaultStore.state.accountSetupWizard); watch(page, () => { @@ -176,9 +176,11 @@ function setupComplete() { function launchTutorial() { setupComplete(); nextTick(() => { - os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), { initialPage: 1, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index e0aec8b2f3..3c3f9e94b6 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 3f24d2a23c..6fb3304468 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="_gaps_s" :class="$style.mainActions"> <MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton> - <MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton> + <MkButton :class="$style.mainAction" full rounded link to="https://joinsharkey.org/#findaninstance">{{ i18n.ts.exploreOtherServers }}</MkButton> <MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> </div> </div> @@ -69,7 +69,8 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; -import { openInstanceMenu } from '@/ui/_common_/common'; +import { openInstanceMenu } from '@/ui/_common_/common.js'; +import type { MenuItem } from '@/types/menu.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); @@ -78,24 +79,24 @@ misskeyApi('stats', {}).then((res) => { }); function signin() { - os.popup(XSigninDialog, { + const { dispose } = os.popup(XSigninDialog, { autoSet: true, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } function signup() { - os.popup(XSignupDialog, { + const { dispose } = os.popup(XSignupDialog, { autoSet: true, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } -function showMenu(ev) { +function showMenu(ev: MouseEvent) { openInstanceMenu(ev); } - -function exploreOtherServers() { - window.open('https://joinsharkey.org/#findaninstance', '_blank', 'noopener'); -} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 1fad222fc5..e3711b3463 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="poamfof"> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player"> - <iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> + <iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe> </div> <span v-else>invalid url</span> </Transition> @@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@/scripts/intl-const.js'; +import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; import { defaultStore } from '@/store.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index c1d8cf0ca6..02e5a7f98c 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -35,12 +35,10 @@ export const Default = { // FIXME: 通るけどその後落ちるのでコメントアウト // await expect(a.href).toMatch(/^https?:\/\/.*#test$/); await userEvent.pointer({ keys: '[MouseRight]', target: a }); - await tick(); const menu = canvas.getByRole('menu'); await expect(menu).toBeInTheDocument(); await userEvent.click(a); a.blur(); - await tick(); await expect(menu).not.toBeInTheDocument(); }, args: { diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 6cb948d3dd..e0303dbb27 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -16,7 +16,7 @@ export type MkABehavior = 'window' | 'browser' | null; <script lang="ts" setup> import { computed, inject, shallowRef } from 'vue'; import * as os from '@/os.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router/supplier.js'; diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index aef26ab92d..8c0b7ef52f 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -9,12 +9,6 @@ import { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; import { i18n } from '@/i18n.js'; -let lock: Promise<undefined> | undefined; - -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - const common = { render(args) { return { @@ -37,56 +31,41 @@ const common = { }; }, async play({ canvasElement, args }) { - if (lock) { - console.warn('This test is unexpectedly running twice in parallel, fix it!'); - console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267'); - await lock; + const canvas = within(canvasElement); + const a = canvas.getByRole<HTMLAnchorElement>('link'); + // FIXME: 通るけどその後落ちるのでコメントアウト + // await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + const img = within(a).getByRole('img'); + await expect(img).toBeInTheDocument(); + let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + await expect(buttons).toHaveLength(1); + const i = buttons[0]; + await expect(i).toBeInTheDocument(); + await userEvent.click(i); + await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back); + await expect(a).not.toBeInTheDocument(); + await expect(i).not.toBeInTheDocument(); + buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + const hasReduceFrequency = args.specify?.ratio !== 0; + await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1); + const reduce = hasReduceFrequency ? buttons[0] : null; + const back = buttons[hasReduceFrequency ? 1 : 0]; + if (reduce) { + await expect(reduce).toBeInTheDocument(); + await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); } - - let resolve: (value?: any) => void; - lock = new Promise(r => resolve = r); - - try { - // NOTE: sleep しないと何故か落ちる - await sleep(100); - const canvas = within(canvasElement); - const a = canvas.getByRole<HTMLAnchorElement>('link'); - // await expect(a.href).toMatch(/^https?:\/\/.*#test$/); - const img = within(a).getByRole('img'); - await expect(img).toBeInTheDocument(); - let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); - await expect(buttons).toHaveLength(1); - const i = buttons[0]; - await expect(i).toBeInTheDocument(); - await userEvent.click(i); - await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back); - await expect(a).not.toBeInTheDocument(); - await expect(i).not.toBeInTheDocument(); - buttons = canvas.getAllByRole<HTMLButtonElement>('button'); - const hasReduceFrequency = args.specify?.ratio !== 0; - await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1); - const reduce = hasReduceFrequency ? buttons[0] : null; - const back = buttons[hasReduceFrequency ? 1 : 0]; - if (reduce) { - await expect(reduce).toBeInTheDocument(); - await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); - } - await expect(back).toBeInTheDocument(); - await expect(back).toHaveTextContent(i18n.ts._ad.back); - await userEvent.click(back); - await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy()); - if (reduce) { - await expect(reduce).not.toBeInTheDocument(); - } - await expect(back).not.toBeInTheDocument(); - const aAgain = canvas.getByRole<HTMLAnchorElement>('link'); - await expect(aAgain).toBeInTheDocument(); - const imgAgain = within(aAgain).getByRole('img'); - await expect(imgAgain).toBeInTheDocument(); - } finally { - resolve!(); - lock = undefined; + await expect(back).toBeInTheDocument(); + await expect(back).toHaveTextContent(i18n.ts._ad.back); + await userEvent.click(back); + await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy()); + if (reduce) { + await expect(reduce).not.toBeInTheDocument(); } + await expect(back).not.toBeInTheDocument(); + const aAgain = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(aAgain).toBeInTheDocument(); + const imgAgain = within(aAgain).getByRole('img'); + await expect(imgAgain).toBeInTheDocument(); }, args: { prefer: [], diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 1f7f09335e..4cacc7b292 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -31,7 +31,7 @@ import { defaultStore } from '@/store.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; @@ -107,12 +107,12 @@ function onClick(ev: MouseEvent) { text: i18n.ts.info, icon: 'ti ti-info-circle', action: async () => { - os.popup(MkCustomEmojiDetailedDialog, { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { emoji: await misskeyApiGet('emoji', { name: customEmojiName.value, }), }, { - anchor: ev.target, + closed: () => dispose(), }); }, }], ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index 9cf7dab06d..c485305376 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -14,7 +14,7 @@ import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } import { defaultStore } from '@/store.js'; import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import * as os from '@/os.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index ac563ffaf5..a3a2b9f319 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -68,7 +68,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven const validTime = (t: string | boolean | null | undefined) => { if (t == null) return null; if (typeof t === 'boolean') return null; - return t.match(/^[0-9.]+s$/) ? t : null; + return t.match(/^\-?[0-9.]+s$/) ? t : null; }; const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : props.isAnim ? true : false; diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 89993e1b8e..b12dc8cb31 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -8,7 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div ref="headerEl"> <slot name="header"></slot> </div> - <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> + <div + ref="bodyEl" + :data-sticky-container-header-height="headerHeight" + :data-sticky-container-footer-height="footerHeight" + > <slot></slot> </div> <div ref="footerEl"> diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 23fe99bd9c..027b226f3f 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -41,12 +41,12 @@ function getDateSafe(n: Date | string | number) { } } -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const _time = props.time == null ? NaN : getDateSafe(props.time).getTime(); const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const now = ref(props.origin?.getTime() ?? Date.now()); const ago = computed(() => (now.value - _time) / 1000/*ms*/); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 083e163630..15595ba515 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -51,11 +51,13 @@ const el = ref(); if (props.showUrlPreview && isEnabledUrlPreview.value) { useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, source: el.value instanceof HTMLElement ? el.value : el.value?.$el, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); }); } diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 06cb30eff1..19bd794a5d 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -53,14 +53,14 @@ function resolveNested(current: Resolved, d = 0): Resolved | null { const current = resolveNested(router.current)!; const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); -const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); +const key = ref(router.getCurrentKey() + JSON.stringify(Object.fromEntries(current.props))); function onChange({ resolved, key: newKey }) { const current = resolveNested(resolved); if (current == null || 'redirect' in current.route) return; currentPageComponent.value = current.route.component; currentPageProps.value = current.props; - key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props)); + key.value = newKey + JSON.stringify(Object.fromEntries(current.props)); nextTick(() => { // ページ遷移完了後に再びキャッシュを有効化 |