summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-05-07 02:46:42 +0000
committerGitHub <noreply@github.com>2025-05-07 02:46:42 +0000
commit9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b (patch)
treec41c3ee20b995c3a74a75d4005ab980d217a3727 /packages/frontend/src/components
parentMerge pull request #15842 from misskey-dev/develop (diff)
parentRelease: 2025.5.0 (diff)
downloadmisskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.gz
misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.bz2
misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.zip
Merge pull request #15933 from misskey-dev/develop
Release: 2025.5.0
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAnnouncementDialog.vue62
-rw-r--r--packages/frontend/src/components/MkChannelList.vue8
-rw-r--r--packages/frontend/src/components/MkChatHistories.vue4
-rw-r--r--packages/frontend/src/components/MkDialog.vue33
-rw-r--r--packages/frontend/src/components/MkFolder.vue56
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue6
-rw-r--r--packages/frontend/src/components/MkInstanceTicker.vue18
-rw-r--r--packages/frontend/src/components/MkNotes.vue8
-rw-r--r--packages/frontend/src/components/MkNotification.vue2
-rw-r--r--packages/frontend/src/components/MkNotifications.vue48
-rw-r--r--packages/frontend/src/components/MkPagination.vue8
-rw-r--r--packages/frontend/src/components/MkPullToRefresh.vue200
-rw-r--r--packages/frontend/src/components/MkSwiper.vue27
-rw-r--r--packages/frontend/src/components/MkTimeline.vue53
-rw-r--r--packages/frontend/src/components/MkUserList.vue8
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue16
-rw-r--r--packages/frontend/src/components/global/MkError.vue30
-rw-r--r--packages/frontend/src/components/global/MkResult.stories.impl.ts57
-rw-r--r--packages/frontend/src/components/global/MkResult.vue53
-rw-r--r--packages/frontend/src/components/global/MkSystemIcon.vue109
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue3
-rw-r--r--packages/frontend/src/components/index.ts6
22 files changed, 559 insertions, 256 deletions
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 6e5b29654b..81c92bfb5c 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick">
+<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick">
<div ref="rootEl" :class="$style.root">
<div :class="$style.header">
<span :class="$style.icon">
@@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.title">{{ announcement.title }}</span>
</div>
<div :class="$style.text"><Mfm :text="announcement.text"/></div>
- <MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton>
+ <div ref="bottomEl"></div>
+ <div :class="$style.footer">
+ <MkButton
+ primary
+ full
+ :disabled="!hasReachedBottom"
+ @click="ok"
+ >{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton>
+ </div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
-import { onMounted, useTemplateRef } from 'vue';
+import { onMounted, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -32,12 +40,12 @@ import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
-const props = withDefaults(defineProps<{
+const props = defineProps<{
announcement: Misskey.entities.Announcement;
-}>(), {
-});
+}>();
const rootEl = useTemplateRef('rootEl');
+const bottomEl = useTemplateRef('bottomEl');
const modal = useTemplateRef('modal');
async function ok() {
@@ -72,7 +80,34 @@ function onBgClick() {
});
}
+const hasReachedBottom = ref(false);
+
onMounted(() => {
+ if (bottomEl.value && rootEl.value) {
+ const bottomElRect = bottomEl.value.getBoundingClientRect();
+ const rootElRect = rootEl.value.getBoundingClientRect();
+ if (
+ bottomElRect.top >= rootElRect.top &&
+ bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分)
+ ) {
+ hasReachedBottom.value = true;
+ return;
+ }
+
+ const observer = new IntersectionObserver(entries => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ hasReachedBottom.value = true;
+ observer.disconnect();
+ }
+ }
+ }, {
+ root: rootEl.value,
+ rootMargin: '0px 0px -75px 0px',
+ });
+
+ observer.observe(bottomEl.value);
+ }
});
</script>
@@ -80,9 +115,12 @@ onMounted(() => {
.root {
margin: auto;
position: relative;
- padding: 32px;
+ padding: 32px 32px 0;
min-width: 320px;
max-width: 480px;
+ max-height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
@@ -103,4 +141,14 @@ onMounted(() => {
.text {
margin: 1em 0;
}
+
+.footer {
+ position: sticky;
+ bottom: 0;
+ left: -32px;
+ backdrop-filter: var(--MI-blur, blur(15px));
+ background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
+ margin: 0 -32px;
+ padding: 24px 32px;
+}
</style>
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue
index fdb7d2a1c4..d0b50f04f2 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkPagination :pagination="pagination">
- <template #empty>
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.notFound }}</div>
- </div>
- </template>
+ <template #empty><MkResult type="empty"/></template>
<template #default="{ items }">
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
@@ -23,7 +18,6 @@ import type { Paging } from '@/components/MkPagination.vue';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
pagination: Paging;
diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue
index c508ea8451..b33ed428c7 100644
--- a/packages/frontend/src/components/MkChatHistories.vue
+++ b/packages/frontend/src/components/MkChatHistories.vue
@@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkA>
</div>
-<div v-if="!initializing && history.length == 0" class="_fullinfo">
- <div>{{ i18n.ts._chat.noHistory }}</div>
-</div>
+<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
<MkLoading v-if="initializing"/>
</template>
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 81d508c161..3f7519a43f 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div
v-else-if="!input && !select"
- :class="[$style.icon, {
- [$style.type_success]: type === 'success',
- [$style.type_error]: type === 'error',
- [$style.type_warning]: type === 'warning',
- [$style.type_info]: type === 'info',
- }]"
+ :class="[$style.icon]"
>
- <i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
- <i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
- <i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
- <i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
- <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
+ <MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
+ <MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
+ <MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
+ <MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
+ <MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div>
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
@@ -202,22 +197,6 @@ function onInputKeydown(evt: KeyboardEvent) {
margin: 0 auto;
}
-.type_info {
- color: #55c4dd;
-}
-
-.type_success {
- color: var(--MI_THEME-success);
-}
-
-.type_error {
- color: var(--MI_THEME-error);
-}
-
-.type_warning {
- color: var(--MI_THEME-warn);
-}
-
.title {
margin: 0 0 8px 0;
font-weight: bold;
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 1236b843f2..e86861c874 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -31,6 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''"
+ @enter="enter"
+ @afterEnter="afterEnter"
+ @leave="leave"
+ @afterLeave="afterLeave"
>
<KeepAlive>
<div v-show="opened">
@@ -86,6 +90,42 @@ const bgSame = ref(false);
const opened = ref(props.defaultOpen);
const openedAtLeastOnce = ref(props.defaultOpen);
+//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す)
+function enter(el: Element) {
+ if (CSS.supports('interpolate-size', 'allow-keywords')) return;
+ if (!(el instanceof HTMLElement)) return;
+
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = '0';
+ el.offsetHeight; // reflow
+ el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
+}
+
+function afterEnter(el: Element) {
+ if (CSS.supports('interpolate-size', 'allow-keywords')) return;
+ if (!(el instanceof HTMLElement)) return;
+
+ el.style.height = '';
+}
+
+function leave(el: Element) {
+ if (CSS.supports('interpolate-size', 'allow-keywords')) return;
+ if (!(el instanceof HTMLElement)) return;
+
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = `${elementHeight}px`;
+ el.offsetHeight; // reflow
+ el.style.height = '0';
+}
+
+function afterLeave(el: Element) {
+ if (CSS.supports('interpolate-size', 'allow-keywords')) return;
+ if (!(el instanceof HTMLElement)) return;
+
+ el.style.height = '';
+}
+//#endregion
+
function toggle() {
if (!opened.value) {
openedAtLeastOnce.value = true;
@@ -108,17 +148,27 @@ onMounted(() => {
.transition_toggle_enterActive,
.transition_toggle_leaveActive {
overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない
- transition: opacity 0.3s, height 0.3s !important;
+ transition: opacity 0.3s, height 0.3s;
}
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_toggle_enterFrom,
+ .transition_toggle_leaveTo {
+ height: 0;
+ }
+
+ .root {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ }
+}
+
.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
opacity: 0;
- height: 0;
}
.root {
display: block;
- interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
}
.header {
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 0884cdc016..6ac4441cac 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
</template>
</div>
- <div v-else class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.nothing }}</div>
- </div>
+ <MkResult v-else type="empty"/>
</div>
</MkModalWindow>
</template>
@@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
const props = defineProps<{
title: string;
diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue
index eba8a73aec..380fb7b2d8 100644
--- a/packages/frontend/src/components/MkInstanceTicker.vue
+++ b/packages/frontend/src/components/MkInstanceTicker.vue
@@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
-import type { CSSProperties } from 'vue';
import { instanceName as localInstanceName } from '@@/js/config.js';
+import type { CSSProperties } from 'vue';
import { instance as localInstance } from '@/instance.js';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
@@ -61,19 +61,9 @@ $height: 2ex;
border-radius: 4px 0 0 4px;
overflow: clip;
color: #fff;
- text-shadow: /* .866 ≈ sin(60deg) */
- 1px 0 1px #000,
- .866px .5px 1px #000,
- .5px .866px 1px #000,
- 0 1px 1px #000,
- -.5px .866px 1px #000,
- -.866px .5px 1px #000,
- -1px 0 1px #000,
- -.866px -.5px 1px #000,
- -.5px -.866px 1px #000,
- 0 -1px 1px #000,
- .5px -.866px 1px #000,
- .866px -.5px 1px #000;
+
+ // text-shadowは重いから使うな
+
mask-image: linear-gradient(90deg,
rgb(0,0,0),
rgb(0,0,0) calc(100% - 16px),
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index 9d862a4eac..509099e0b9 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
- <template #empty>
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.noNotes }}</div>
- </div>
- </template>
+ <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
@@ -34,7 +29,6 @@ import type { Paging } from '@/components/MkPagination.vue';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
const props = defineProps<{
pagination: Paging;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 9672efca0a..21104b41df 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
- <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<div
@@ -176,7 +175,6 @@ import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { ensureSignin } from '@/i.js';
-import { infoImageUrl } from '@/instance.js';
const $i = ensureSignin();
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index b8fada1020..3c88b8af0d 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkPullToRefresh :refresher="() => reload()">
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<MkPagination ref="pagingComponent" :pagination="pagination">
- <template #empty>
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.noNotifications }}</div>
- </div>
- </template>
+ <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template>
<template #default="{ items: notifications }">
<component
@@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</template>
</MkPagination>
-</MkPullToRefresh>
+</component>
</template>
<script lang="ts" setup>
@@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue';
import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
@@ -103,18 +97,38 @@ defineExpose({
</script>
<style lang="scss" module>
-.transition_x_move,
-.transition_x_enterActive,
+.transition_x_move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+ &.item,
+ .item {
+ /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
+ content-visibility: visible !important;
+ }
+}
+
.transition_x_leaveActive {
- transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
+ transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
-.transition_x_enterFrom,
-.transition_x_leaveTo {
+
+.transition_x_enterFrom {
opacity: 0;
- transform: translateY(-50%);
+ transform: translateY(max(-64px, -100%));
}
-.transition_x_leaveActive {
- position: absolute;
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_x_enterFrom {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ height: 0;
+ }
+}
+
+.transition_x_leaveTo {
+ opacity: 0;
}
.notifications {
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 9adc3d98da..54da5a889d 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -16,12 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkError v-else-if="error" @retry="init()"/>
<div v-else-if="empty" key="_empty_">
- <slot name="empty">
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.nothing }}</div>
- </div>
- </slot>
+ <slot name="empty"><MkResult type="empty"/></slot>
</div>
<div v-else ref="rootEl" class="_gaps">
@@ -88,7 +83,6 @@ function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): M
</script>
<script lang="ts" setup>
-import { infoImageUrl } from '@/instance.js';
import MkButton from '@/components/MkButton.vue';
const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index 22ae563d13..b0638db785 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div ref="rootEl">
- <div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`">
+<div ref="rootEl" :class="isPulling ? $style.isPulling : null">
+ <!-- 小数が含まれるとレンダリングが高頻度になりすぎパフォーマンスが悪化するためround -->
+ <div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${Math.round(pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR)))}px;`">
<div :class="$style.frameContent">
<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
- <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
+ <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i>
<div :class="$style.text">
- <template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
+ <template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template>
<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
<template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
</div>
@@ -29,24 +30,21 @@ import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
-const FIRE_THRESHOLD = 230;
+const FIRE_THRESHOLD = 200;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
-const isPullStart = ref(false);
-const isPullEnd = ref(false);
+const isPulling = ref(false);
+const isPulledEnough = ref(false);
const isRefreshing = ref(false);
const pullDistance = ref(0);
-let supportPointerDesktop = false;
let startScreenY: number | null = null;
const rootEl = useTemplateRef('rootEl');
let scrollEl: HTMLElement | null = null;
-let disabled = false;
-
const props = withDefaults(defineProps<{
refresher: () => Promise<void>;
}>(), {
@@ -57,19 +55,72 @@ const emit = defineEmits<{
(ev: 'refresh'): void;
}>();
-function getScreenY(event) {
- if (supportPointerDesktop) {
+function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number {
+ if (event.touches && event.touches[0] && event.touches[0].screenY != null) {
+ return event.touches[0].screenY;
+ } else {
return event.screenY;
}
- return event.touches[0].screenY;
}
-function moveStart(event) {
- if (!isPullStart.value && !isRefreshing.value && !disabled) {
- isPullStart.value = true;
- startScreenY = getScreenY(event);
- pullDistance.value = 0;
+// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
+function lockDownScroll() {
+ if (scrollEl == null) return;
+ scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom';
+ scrollEl.style.overscrollBehavior = 'none';
+}
+
+function unlockDownScroll() {
+ if (scrollEl == null) return;
+ scrollEl.style.touchAction = 'auto';
+ scrollEl.style.overscrollBehavior = 'contain';
+}
+
+function moveStartByMouse(event: MouseEvent) {
+ if (event.button !== 1) return;
+ if (isRefreshing.value) return;
+
+ const scrollPos = scrollEl!.scrollTop;
+ if (scrollPos !== 0) {
+ unlockDownScroll();
+ return;
+ }
+
+ lockDownScroll();
+
+ event.preventDefault(); // 中クリックによるスクロール、テキスト選択などを防ぐ
+
+ isPulling.value = true;
+ startScreenY = getScreenY(event);
+ pullDistance.value = 0;
+
+ window.addEventListener('mousemove', moving, { passive: true });
+ window.addEventListener('mouseup', () => {
+ window.removeEventListener('mousemove', moving);
+ onPullRelease();
+ }, { passive: true, once: true });
+}
+
+function moveStartByTouch(event: TouchEvent) {
+ if (isRefreshing.value) return;
+
+ const scrollPos = scrollEl!.scrollTop;
+ if (scrollPos !== 0) {
+ unlockDownScroll();
+ return;
}
+
+ lockDownScroll();
+
+ isPulling.value = true;
+ startScreenY = getScreenY(event);
+ pullDistance.value = 0;
+
+ window.addEventListener('touchmove', moving, { passive: true });
+ window.addEventListener('touchend', () => {
+ window.removeEventListener('touchmove', moving);
+ onPullRelease();
+ }, { passive: true, once: true });
}
function moveBySystem(to: number): Promise<void> {
@@ -108,31 +159,36 @@ async function closeContent() {
}
}
-function moveEnd() {
- if (isPullStart.value && !isRefreshing.value) {
- startScreenY = null;
- if (isPullEnd.value) {
- isPullEnd.value = false;
- isRefreshing.value = true;
- fixOverContent().then(() => {
- emit('refresh');
- props.refresher().then(() => {
- refreshFinished();
- });
+function onPullRelease() {
+ startScreenY = null;
+ if (isPulledEnough.value) {
+ isPulledEnough.value = false;
+ isRefreshing.value = true;
+ fixOverContent().then(() => {
+ emit('refresh');
+ props.refresher().then(() => {
+ refreshFinished();
});
- } else {
- closeContent().then(() => isPullStart.value = false);
- }
+ });
+ } else {
+ closeContent().then(() => isPulling.value = false);
}
}
-function moving(event: TouchEvent | PointerEvent) {
- if (!isPullStart.value || isRefreshing.value || disabled) return;
+function toggleScrollLockOnTouchEnd() {
+ const scrollPos = scrollEl!.scrollTop;
+ if (scrollPos === 0) {
+ lockDownScroll();
+ } else {
+ unlockDownScroll();
+ }
+}
- if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) {
+function moving(event: MouseEvent | TouchEvent) {
+ if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) {
pullDistance.value = 0;
- isPullEnd.value = false;
- moveEnd();
+ isPulledEnough.value = false;
+ onPullRelease();
return;
}
@@ -144,15 +200,7 @@ function moving(event: TouchEvent | PointerEvent) {
const moveHeight = moveScreenY - startScreenY!;
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
- if (pullDistance.value > 0) {
- if (event.cancelable) event.preventDefault();
- }
-
- if (pullDistance.value > SCROLL_STOP) {
- event.stopPropagation();
- }
-
- isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD;
+ isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD;
}
/**
@@ -162,65 +210,33 @@ function moving(event: TouchEvent | PointerEvent) {
*/
function refreshFinished() {
closeContent().then(() => {
- isPullStart.value = false;
+ isPulling.value = false;
isRefreshing.value = false;
});
}
-function setDisabled(value) {
- disabled = value;
-}
-
-function onScrollContainerScroll() {
- const scrollPos = scrollEl!.scrollTop;
-
- // When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
- if (scrollPos === 0) {
- scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom';
- registerEventListenersForReadyToPull();
- } else {
- scrollEl!.style.touchAction = 'auto';
- unregisterEventListenersForReadyToPull();
- }
-}
-
-function registerEventListenersForReadyToPull() {
- if (rootEl.value == null) return;
- rootEl.value.addEventListener('touchstart', moveStart, { passive: true });
- rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない
-}
-
-function unregisterEventListenersForReadyToPull() {
- if (rootEl.value == null) return;
- rootEl.value.removeEventListener('touchstart', moveStart);
- rootEl.value.removeEventListener('touchmove', moving);
-}
-
onMounted(() => {
if (rootEl.value == null) return;
-
scrollEl = getScrollContainer(rootEl.value);
- if (scrollEl == null) return;
-
- scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true });
-
- rootEl.value.addEventListener('touchend', moveEnd, { passive: true });
-
- registerEventListenersForReadyToPull();
+ lockDownScroll();
+ rootEl.value.addEventListener('mousedown', moveStartByMouse, { passive: false }); // preventDefaultするため
+ rootEl.value.addEventListener('touchstart', moveStartByTouch, { passive: true });
+ rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true });
});
onUnmounted(() => {
- if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll);
-
- unregisterEventListenersForReadyToPull();
-});
-
-defineExpose({
- setDisabled,
+ unlockDownScroll();
+ if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse);
+ if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch);
+ if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd);
});
</script>
<style lang="scss" module>
+.isPulling {
+ will-change: contents;
+}
+
.frame {
position: relative;
overflow: clip;
@@ -242,7 +258,6 @@ defineExpose({
display: flex;
flex-direction: column;
align-items: center;
- font-size: 14px;
> .icon, > .loader {
margin: 6px 0;
@@ -258,6 +273,7 @@ defineExpose({
> .text {
margin: 5px 0;
+ font-size: 90%;
}
}
</style>
diff --git a/packages/frontend/src/components/MkSwiper.vue b/packages/frontend/src/components/MkSwiper.vue
index 1d0ffaea11..b66bfb0e9d 100644
--- a/packages/frontend/src/components/MkSwiper.vue
+++ b/packages/frontend/src/components/MkSwiper.vue
@@ -53,12 +53,12 @@ const MIN_SWIPE_DISTANCE = 20;
// スワイプ時の動作を発火する最小の距離
const SWIPE_DISTANCE_THRESHOLD = 70;
-// スワイプを中断するY方向の移動距離
-const SWIPE_ABORT_Y_THRESHOLD = 75;
-
// スワイプできる最大の距離
const MAX_SWIPE_DISTANCE = 120;
+// スワイプ方向を判定する角度の許容範囲(度数)
+const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50;
+
// ▲ しきい値 ▲ //
let startScreenX: number | null = null;
@@ -69,6 +69,7 @@ const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === t
const pullDistance = ref(0);
const isSwipingForClass = ref(false);
let swipeAborted = false;
+let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null;
function touchStart(event: TouchEvent) {
if (!prefer.r.enableHorizontalSwipe.value) return;
@@ -79,6 +80,7 @@ function touchStart(event: TouchEvent) {
startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY;
+ swipeDirectionLocked = null; // スワイプ方向をリセット
}
function touchMove(event: TouchEvent) {
@@ -95,15 +97,24 @@ function touchMove(event: TouchEvent) {
let distanceX = event.touches[0].screenX - startScreenX;
let distanceY = event.touches[0].screenY - startScreenY;
- if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
- swipeAborted = true;
+ // スワイプ方向をロック
+ if (!swipeDirectionLocked) {
+ const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI));
+ if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) {
+ swipeDirectionLocked = 'vertical';
+ } else {
+ swipeDirectionLocked = 'horizontal';
+ }
+ }
+ // 縦方向のスワイプの場合は中断
+ if (swipeDirectionLocked === 'vertical') {
+ swipeAborted = true;
pullDistance.value = 0;
isSwiping.value = false;
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
-
return;
}
@@ -164,6 +175,8 @@ function touchEnd(event: TouchEvent) {
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
+
+ swipeDirectionLocked = null; // スワイプ方向をリセット
}
/** 横スワイプに関与する可能性のある要素を調べる */
@@ -190,7 +203,7 @@ watch(tabModel, (newTab, oldTab) => {
const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
- if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
+ if (oldIndex >= 0 && newIndex >= 0 && oldIndex < newIndex) {
transitionName.value = 'swipeAnimationLeft';
} else {
transitionName.value = 'swipeAnimationRight';
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 8ca690f2ce..6a265aa836 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()">
- <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)">
- <template #empty>
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.noNotes }}</div>
- </div>
- </template>
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
+ <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
+ <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
<component
@@ -21,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
- :moveClass=" $style.transition_x_move"
+ :moveClass="$style.transition_x_move"
tag="div"
>
<template v-for="(note, i) in notes" :key="note.id">
@@ -36,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
</template>
</MkPagination>
-</MkPullToRefresh>
+</component>
</template>
<script lang="ts" setup>
@@ -53,7 +48,6 @@ import { prefer } from '@/preferences.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -93,7 +87,6 @@ type TimelineQueryType = {
roleId?: string
};
-const prComponent = useTemplateRef('prComponent');
const pagingComponent = useTemplateRef('pagingComponent');
let tlNotesCount = 0;
@@ -306,18 +299,38 @@ defineExpose({
</script>
<style lang="scss" module>
-.transition_x_move,
-.transition_x_enterActive,
+.transition_x_move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+ &.note,
+ .note {
+ /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
+ content-visibility: visible !important;
+ }
+}
+
.transition_x_leaveActive {
- transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
+ transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
-.transition_x_enterFrom,
-.transition_x_leaveTo {
+
+.transition_x_enterFrom {
opacity: 0;
- transform: translateY(-50%);
+ transform: translateY(max(-64px, -100%));
}
-.transition_x_leaveActive {
- position: absolute;
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_x_leaveTo {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ height: 0;
+ }
+}
+
+.transition_x_leaveTo {
+ opacity: 0;
}
.reverse {
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 0d1ffd715f..90087cb000 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkPagination :pagination="pagination">
- <template #empty>
- <div class="_fullinfo">
- <img :src="infoImageUrl" draggable="false"/>
- <div>{{ i18n.ts.noUsers }}</div>
- </div>
- </template>
+ <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
<template #default="{ items }">
<div :class="$style.root">
@@ -25,7 +20,6 @@ import type { Paging } from '@/components/MkPagination.vue';
import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
pagination: Paging;
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 3bd2a2ffae..2a423bfa55 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
appear @afterLeave="emit('closed')"
>
<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
- <div v-if="user != null">
+ <MkError v-if="error" @retry="fetchUser()"/>
+ <div v-else-if="user != null">
<div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''">
<span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
</div>
@@ -85,6 +86,7 @@ const zIndex = os.claimZIndex('middle');
const user = ref<Misskey.entities.UserDetailed | null>(null);
const top = ref(0);
const left = ref(0);
+const error = ref(false);
function showMenu(ev: MouseEvent) {
if (user.value == null) return;
@@ -92,19 +94,27 @@ function showMenu(ev: MouseEvent) {
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
}
-onMounted(() => {
+async function fetchUser() {
if (typeof props.q === 'object') {
user.value = props.q;
+ error.value = false;
} else {
- const query = props.q.startsWith('@') ?
+ const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ?
Misskey.acct.parse(props.q.substring(1)) :
{ userId: props.q };
misskeyApi('users/show', query).then(res => {
if (!props.showing) return;
user.value = res;
+ error.value = false;
+ }, () => {
+ error.value = true;
});
}
+}
+
+onMounted(() => {
+ fetchUser();
const rect = props.source.getBoundingClientRect();
const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX;
diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue
index 95ed255189..6a5c4c18bf 100644
--- a/packages/frontend/src/components/global/MkError.vue
+++ b/packages/frontend/src/components/global/MkError.vue
@@ -4,20 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
- <div :class="$style.root">
- <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
- <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
- <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton>
- </div>
-</Transition>
+<MkResult type="error">
+ <MkButton :class="$style.button" rounded @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton>
+</MkResult>
</template>
<script lang="ts" setup>
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
-import { prefer } from '@/preferences.js';
-import { serverErrorImageUrl } from '@/instance.js';
const emit = defineEmits<{
(ev: 'retry'): void;
@@ -25,25 +19,7 @@ const emit = defineEmits<{
</script>
<style lang="scss" module>
-.root {
- padding: 32px;
- text-align: center;
- align-items: center;
-}
-
-.text {
- margin: 0 0 8px 0;
-}
-
.button {
margin: 0 auto;
}
-
-.img {
- vertical-align: bottom;
- width: 128px;
- height: 128px;
- margin-bottom: 16px;
- border-radius: 16px;
-}
</style>
diff --git a/packages/frontend/src/components/global/MkResult.stories.impl.ts b/packages/frontend/src/components/global/MkResult.stories.impl.ts
new file mode 100644
index 0000000000..05f8c9069b
--- /dev/null
+++ b/packages/frontend/src/components/global/MkResult.stories.impl.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import MkResult from './MkResult.vue';
+import type { StoryObj } from '@storybook/vue3';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkResult,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkResult v-bind="props" />',
+ };
+ },
+ args: {
+ type: 'empty',
+ text: 'Lorem Ipsum',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkResult>;
+export const emptyWithNoText = {
+ ...Default,
+ args: {
+ ...Default.args,
+ text: undefined,
+ },
+} satisfies StoryObj<typeof MkResult>;
+export const notFound = {
+ ...Default,
+ args: {
+ ...Default.args,
+ type: 'notFound',
+ },
+} satisfies StoryObj<typeof MkResult>;
+export const errorType = {
+ ...Default,
+ args: {
+ ...Default.args,
+ type: 'error',
+ },
+} satisfies StoryObj<typeof MkResult>;
diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue
new file mode 100644
index 0000000000..fdfc7091e8
--- /dev/null
+++ b/packages/frontend/src/components/global/MkResult.vue
@@ -0,0 +1,53 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
+ <div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps">
+ <img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
+ <MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/>
+ <img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>
+ <MkSystemIcon v-else-if="type === 'notFound'" type="question" :class="$style.icon"/>
+ <img v-if="type === 'error' && instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
+ <MkSystemIcon v-else-if="type === 'error'" type="error" :class="$style.icon"/>
+
+ <div style="opacity: 0.7;">{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : type === 'error' ? i18n.ts.somethingHappened : null) }}</div>
+ <slot></slot>
+ </div>
+</Transition>
+</template>
+
+<script lang="ts" setup>
+import {} from 'vue';
+import { instance } from '@/instance.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+
+const props = defineProps<{
+ type: 'empty' | 'notFound' | 'error';
+ text?: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ text-align: center;
+ padding: 32px;
+}
+
+.img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+}
+
+.icon {
+ width: 65px;
+ height: 65px;
+ margin: 0 auto;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue
new file mode 100644
index 0000000000..3285d5a940
--- /dev/null
+++ b/packages/frontend/src/components/global/MkSystemIcon.vue
@@ -0,0 +1,109 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160">
+ <path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.anim]"/>
+ <path d="M80,52L80,52" :class="[$style.line, $style.fade]"/>
+ <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
+</svg>
+<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160">
+ <path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.anim]"/>
+ <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/>
+ <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
+</svg>
+<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
+ <path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.anim]"/>
+ <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
+</svg>
+<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
+ <path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.anim]"/>
+ <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/>
+ <path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:390;" :class="[$style.line, $style.anim]"/>
+</svg>
+<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160">
+ <path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.anim]"/>
+ <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.anim]"/>
+ <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+import {} from 'vue';
+
+const props = defineProps<{
+ type: 'info' | 'question' | 'success' | 'warn' | 'error';
+}>();
+</script>
+
+<style lang="scss" module>
+.icon {
+ stroke-linecap: round;
+ stroke-linejoin: round;
+
+ &.info {
+ color: var(--MI_THEME-accent);
+ }
+
+ &.question {
+ color: var(--MI_THEME-fg);
+ }
+
+ &.success {
+ color: var(--MI_THEME-success);
+ }
+
+ &.warn {
+ color: var(--MI_THEME-warn);
+ }
+
+ &.error {
+ color: var(--MI_THEME-error);
+ }
+}
+
+.line {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 8px;
+}
+
+.fill {
+ fill: currentColor;
+}
+
+.anim {
+ stroke-dasharray: var(--l);
+ stroke-dashoffset: var(--l);
+ animation: line-animation var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
+ animation-delay: var(--delay, 0s);
+}
+
+.fade {
+ opacity: 0;
+ animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
+ animation-delay: var(--delay, 0s);
+}
+
+@keyframes line-animation {
+ 0% {
+ stroke-dashoffset: var(--l);
+ opacity: 0;
+ }
+ 100% {
+ stroke-dashoffset: 0;
+ opacity: 1;
+ }
+}
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue
index 58c222038a..33a34e0b67 100644
--- a/packages/frontend/src/components/global/PageWithHeader.vue
+++ b/packages/frontend/src/components/global/PageWithHeader.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
<div :class="$style.body">
- <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs">
+ <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs">
<slot></slot>
</MkSwiper>
<slot v-else></slot>
@@ -25,6 +25,7 @@ import type { PageHeaderProps } from './MkPageHeader.vue';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
import MkSwiper from '@/components/MkSwiper.vue';
import { useRouter } from '@/router.js';
+import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean;
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
index ec6ea7c569..9981772ae8 100644
--- a/packages/frontend/src/components/index.ts
+++ b/packages/frontend/src/components/index.ts
@@ -24,6 +24,8 @@ import MkAd from './global/MkAd.vue';
import MkPageHeader from './global/MkPageHeader.vue';
import MkStickyContainer from './global/MkStickyContainer.vue';
import MkLazy from './global/MkLazy.vue';
+import MkResult from './global/MkResult.vue';
+import MkSystemIcon from './global/MkSystemIcon.vue';
import PageWithHeader from './global/PageWithHeader.vue';
import PageWithAnimBg from './global/PageWithAnimBg.vue';
import SearchMarker from './global/SearchMarker.vue';
@@ -61,6 +63,8 @@ export const components = {
MkPageHeader: MkPageHeader,
MkStickyContainer: MkStickyContainer,
MkLazy: MkLazy,
+ MkResult: MkResult,
+ MkSystemIcon: MkSystemIcon,
PageWithHeader: PageWithHeader,
PageWithAnimBg: PageWithAnimBg,
SearchMarker: SearchMarker,
@@ -92,6 +96,8 @@ declare module '@vue/runtime-core' {
MkPageHeader: typeof MkPageHeader;
MkStickyContainer: typeof MkStickyContainer;
MkLazy: typeof MkLazy;
+ MkResult: typeof MkResult;
+ MkSystemIcon: typeof MkSystemIcon;
PageWithHeader: typeof PageWithHeader;
PageWithAnimBg: typeof PageWithAnimBg;
SearchMarker: typeof SearchMarker;