merge: misskey 2025.5.0 (!1028)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1028

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
dakkar 2025-06-29 09:54:12 +00:00
commit 13d045d813
152 changed files with 1690 additions and 842 deletions

View file

@ -140,7 +140,7 @@
"three": "0.176.0",
"tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0",
"vite": "6.3.3",
"vite": "6.3.4",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.1.2",
"vitest-fetch-mock": "0.4.5",

View file

@ -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" :isBlock="true" /></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>

View file

@ -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;

View file

@ -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>

View file

@ -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>
@ -203,22 +198,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;

View file

@ -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">
@ -88,6 +92,42 @@ const bgSame = ref(false);
const opened = ref(props.defaultOpen);
const openedAtLeastOnce = ref(props.defaultOpen);
//#region interpolate-sizeTODO:
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;
@ -110,17 +150,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; // heighttransition
}
}
.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
opacity: 0;
height: 0;
}
.root {
display: block;
interpolate-size: allow-keywords; // heighttransition
}
.header {

View file

@ -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;

View file

@ -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: var(--MI-radius-xs) 0 0 var(--MI-radius-xs);
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),

View file

@ -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 }]">
@ -30,7 +25,6 @@ import type { Paging } from '@/components/MkPagination.vue';
import DynamicNote from '@/components/DynamicNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = defineProps<{
pagination: Paging;

View file

@ -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="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="'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
@ -206,7 +205,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';
import MkFollowButton from '@/components/MkFollowButton.vue';
const $i = ensureSignin();

View file

@ -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 }">
<SkTransitionGroup
@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</SkTransitionGroup>
</template>
</MkPagination>
</MkPullToRefresh>
</component>
</template>
<script lang="ts" setup>
@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue';
import DynamicNote from '@/components/DynamicNote.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';
import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
@ -104,18 +98,38 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move,
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterFrom,
.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: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
transform: translateY(-50%);
}
.transition_x_leaveActive {
position: absolute;
}
.notifications {

View file

@ -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<{

View file

@ -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: falsepreventDefault使
}
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>

View file

@ -13,12 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.schedulePostList }}</template>
<div class="_spacer" style="--MI_SPACER-min: 14px; --MI_SPACER-max: 16px;">
<MkPagination ref="paginationEl" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #empty><MkResult type="empty" :text="i18n.ts.nothing"/></template>
<template #default="{ items }">
<div class="_gaps">
@ -37,7 +32,6 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const emit = defineEmits<{
(ev: 'cancel'): void;

View file

@ -54,12 +54,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;
@ -71,6 +71,7 @@ const pullDistance = ref(0);
const isSwipingForClass = ref(false);
let swipeAborted = false;
const isUserHome = props.page === 'user' && tabModel.value === 'home';
let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null;
function touchStart(event: TouchEvent) {
if (!prefer.r.enableHorizontalSwipe.value) return;
@ -81,6 +82,7 @@ function touchStart(event: TouchEvent) {
startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY;
swipeDirectionLocked = null; //
}
function touchMove(event: TouchEvent) {
@ -97,15 +99,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;
}
@ -166,6 +177,8 @@ function touchEnd(event: TouchEvent) {
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
swipeDirectionLocked = null; //
}
/** 横スワイプに関与する可能性のある要素を調べる */
@ -192,7 +205,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';

View file

@ -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 }">
<SkTransitionGroup
@ -20,7 +15,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"
>
<div v-for="(note, i) in notes" :key="note.id" :class="{ '_gaps': !noGap }">
@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</SkTransitionGroup>
</template>
</MkPagination>
</MkPullToRefresh>
</component>
</template>
<script lang="ts" setup>
@ -47,7 +42,6 @@ import { prefer } from '@/preferences.js';
import DynamicNote from '@/components/DynamicNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
const props = withDefaults(defineProps<{
@ -91,7 +85,6 @@ type TimelineQueryType = {
roleId?: string
};
const prComponent = useTemplateRef('prComponent');
const pagingComponent = useTemplateRef('pagingComponent');
let tlNotesCount = 0;
@ -328,18 +321,38 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move,
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterFrom,
.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: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_leaveTo {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
transform: translateY(-50%);
}
.transition_x_leaveActive {
position: absolute;
}
.reverse {

View file

@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkPagination :pagination="pagination" :displayLimit="50">
<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;

View file

@ -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 && user.isFollowing" :class="$style.followed">{{ i18n.ts.mutuals }}</span>
<span v-else-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
@ -99,6 +100,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;
@ -106,19 +108,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 = Math.max(1, ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX);

View file

@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkPullToRefresh :refresher="() => reload()">
<MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false" :alt="i18n.ts.noNotes" aria-hidden="true"/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
<!-- TODO replace with SkDateSeparatedList when merged -->
@ -27,7 +22,6 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, shallowRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { FollowingFeedTab } from '@/types/following-feed.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination from '@/components/MkPagination.vue';

View file

@ -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: var(--MI-radius-md);
}
</style>

View file

@ -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>;

View file

@ -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: var(--MI-radius-md);
}
.icon {
width: 65px;
height: 65px;
margin: 0 auto;
}
</style>

View file

@ -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>

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template>
<div :class="[ $style.body, { _spacer: spacer } ]">
<MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
<MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
<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;

View file

@ -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;

View file

@ -7,7 +7,6 @@ import { computed, nextTick, reactive } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { miLocalStorage } from '@/local-storage.js';
import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js';
import { $i } from '@/i';
// TODO: 他のタブと永続化されたstateを同期
@ -31,12 +30,6 @@ if (providedAt > cachedAt) {
export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {});
export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO_IMAGE_URL);
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
export const policies = computed<Misskey.entities.RolePolicies>(() => $i?.policies ?? instance.policies);

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-if="!loaded"/>
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
<div v-show="loaded" :class="$style.root">
<img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/>
<img v-if="instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
<div class="_gaps">
<div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div>
<div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div>
@ -36,7 +36,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { miLocalStorage } from '@/local-storage.js';
import { prefer } from '@/preferences.js';
import { serverErrorImageUrl } from '@/instance.js';
import { instance } from '@/instance.js';
const props = withDefaults(defineProps<{
error?: Error;

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTl :events="timeline" :displayLimit="50" style="margin-top: var(--MI-margin);">
<template #left="{ event }">
<div>
<MkAvatar :user="event.user" style="width: 24px; height: 24px;"/>
<MkAvatar :user="event.user" style="width: 26px; height: 26px;"/>
</div>
</template>
<template #right="{ event, timestamp, delta }">

View file

@ -25,12 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<MkPagination :pagination="usersPagination" :displayLimit="50">
<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="_gaps_s">
@ -71,7 +66,6 @@ import MkButton from '@/components/MkButton.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { infoImageUrl } from '@/instance.js';
import { useRouter } from '@/router.js';
const router = useRouter();

View file

@ -262,6 +262,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
</MkTextarea>
<MkFolder>
<template #icon><i class="ti ti-list"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template>
<template #footer>
<div class="_buttons">
<MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
</div>
</template>
<div :class="$style.metadataRoot" class="_gaps_s">
<MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo>
<div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem">
<button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :minWidth="200">
<MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName">
</MkInput>
<MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version">
</MkInput>
</FormSplit>
</div>
</div>
</div>
</MkFolder>
</div>
</MkFolder>
@ -420,10 +445,12 @@ const urlPreviewForm = useForm({
const federationForm = useForm({
federation: meta.federation,
federationHosts: meta.federationHosts.join('\n'),
deliverSuspendedSoftware: meta.deliverSuspendedSoftware,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
federation: state.federation,
federationHosts: state.federationHosts.split('\n'),
deliverSuspendedSoftware: state.deliverSuspendedSoftware,
});
fetchInstance(true);
});
@ -470,4 +497,53 @@ definePage(() => ({
font-size: 0.85em;
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
.metadataRoot {
container-type: inline-size;
}
.fieldDragItem {
display: flex;
padding: 10px;
align-items: flex-end;
border-radius: 6px;
/* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */
@container (max-width: 452px) {
align-items: center;
}
}
.dragItemHandle {
cursor: grab;
width: 32px;
height: 32px;
margin: 0 8px 0 0;
opacity: 0.5;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.dragItemRemove {
@extend .dragItemHandle;
color: #ff2a2a;
opacity: 1;
cursor: pointer;
&:hover, &:focus {
opacity: .7;
}
&:active {
cursor: pointer;
}
}
.dragItemForm {
flex-grow: 1;
}
</style>

View file

@ -27,9 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
</div>
<div v-if="!fetching && invitations.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noInvitations }}</div>
</div>
<MkResult v-if="!fetching && invitations.length == 0" type="empty" :text="i18n.ts._chat.noInvitations"/>
<MkLoading v-if="fetching"/>
</div>
</template>

View file

@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="memberships.length > 0" class="_gaps_s">
<XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/>
</div>
<div v-if="!fetching && memberships.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noRooms }}</div>
</div>
<MkResult v-if="!fetching && memberships.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
<MkLoading v-if="fetching"/>
</div>
</template>

View file

@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="rooms.length > 0" class="_gaps_s">
<XRoom v-for="room in rooms" :key="room.id" :room="room"/>
</div>
<div v-if="!fetching && rooms.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noRooms }}</div>
</div>
<MkResult v-if="!fetching && rooms.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
<MkLoading v-if="fetching"/>
</div>
</template>

View file

@ -24,10 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
</div>
</div>
<div v-else class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFound }}</div>
</div>
<MkResult v-else type="notFound"/>
</MkFoldableSection>
</div>
</template>
@ -38,7 +35,6 @@ import * as Misskey from 'misskey-js';
import XMessage from './XMessage.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';

View file

@ -0,0 +1,66 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader>
<div class="_spacer" style="--MI_SPACER-w: 600px;">
<div class="_gaps_m">
<MkResult v-if="resultType === 'empty'" type="empty"/>
<MkResult v-if="resultType === 'notFound'" type="notFound"/>
<MkResult v-if="resultType === 'error'" type="error"/>
<MkSelect
v-model="resultType" :items="[
{ label: 'empty', value: 'empty' },
{ label: 'notFound', value: 'notFound' },
{ label: 'error', value: 'error' },
]"
></MkSelect>
<MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 60px;"/>
<MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 60px;"/>
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 60px;"/>
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 60px;"/>
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 60px;"/>
<MkSelect
v-model="iconType" :items="[
{ label: 'info', value: 'info' },
{ label: 'question', value: 'question' },
{ label: 'success', value: 'success' },
{ label: 'warn', value: 'warn' },
{ label: 'error', value: 'error' },
]"
></MkSelect>
<div class="_buttons">
<MkButton @click="os.alert({ type: 'error', title: 'Error', text: 'error' })">Error</MkButton>
<MkButton @click="os.alert({ type: 'warning', title: 'Warning', text: 'warning' })">Warning</MkButton>
<MkButton @click="os.alert({ type: 'info', title: 'Info', text: 'info' })">Info</MkButton>
<MkButton @click="os.alert({ type: 'success', title: 'Success', text: 'success' })">Success</MkButton>
<MkButton @click="os.alert({ type: 'question', title: 'Question', text: 'question' })">Question</MkButton>
</div>
</div>
</div>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { definePage } from '@/page.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
const resultType = ref('empty');
const iconType = ref('info');
definePage(() => ({
title: 'DEBUG ROOM',
icon: 'ti ti-help-circle',
}));
</script>

View file

@ -68,10 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</div>
</div>
<div v-else class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
<MkResult v-else type="empty"/>
</div>
</template>
@ -82,7 +79,6 @@ import MkInfo from '@/components/MkInfo.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import bytes from '@/filters/bytes.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';

View file

@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader>
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<MkPagination :pagination="pagination">
<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 }">
<!-- TODO replace with SkDateSeparatedList when merged -->
@ -31,7 +26,6 @@ import DynamicNote from '@/components/DynamicNote.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js';
const pagination = {
endpoint: 'i/favorites' as const,

View file

@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noFollowRequests }}</div>
</div>
</template>
<template #empty><MkResult type="empty" :text="i18n.ts.noFollowRequests"/></template>
<template #default="{items}">
<div class="mk-follow-requests _gaps">
<div v-for="req in items" :key="req.id" class="user _panel">
@ -48,7 +43,6 @@ import { userPage, acct } from '@/filters/user.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/i.js';
const paginationComponent = useTemplateRef('paginationComponent');

View file

@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isSuspended" :disabled="!instance" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch>
<MkSwitch v-model="isSuspended" :disabled="!instance || suspensionState == 'softwareSuspended'" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch>
<MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch>
@ -249,7 +249,7 @@ const tab = ref('overview');
const chartSrc = ref<ChartSrc>('instance-requests');
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
const instance = ref<Misskey.entities.FederationInstance | null>(null);
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none');
const isSuspended = ref(false);
const isBlocked = ref(false);
const isSilenced = ref(false);
@ -436,6 +436,7 @@ async function toggleMediaSilenced(): Promise<void> {
async function toggleSuspended(): Promise<void> {
if (!iAmModerator) return;
if (suspensionState.value === 'softwareSuspended') return;
await os.promiseDialog(async () => {
if (!instance.value) throw new Error('No instance?');
suspensionState.value = isSuspended.value ? 'manuallySuspended' : 'none';

View file

@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader>
<div v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" class="_spacer" style="--MI_SPACER-w: 1200px;">
<div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
<div :class="$style.text">
<i class="ti ti-alert-triangle"></i>
{{ i18n.ts.nothing }}
</div>
</div>
<MkResult type="empty"/>
</div>
<div v-else class="_spacer" style="--MI_SPACER-w: 800px;">
<div class="_gaps_m" style="text-align: center;">
@ -43,7 +37,7 @@ import MkButton from '@/components/MkButton.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePage } from '@/page.js';
import { serverErrorImageUrl, instance } from '@/instance.js';
import { instance } from '@/instance.js';
import { $i } from '@/i.js';
const pagingComponent = useTemplateRef('pagingComponent');
@ -96,23 +90,3 @@ definePage(() => ({
icon: 'ti ti-user-plus',
}));
</script>
<style lang="scss" module>
.root {
padding: 32px;
text-align: center;
align-items: center;
}
.text {
margin: 0 0 8px 0;
}
.img {
vertical-align: bottom;
width: 128px;
height: 128px;
margin-bottom: 16px;
border-radius: var(--MI-radius-md);
}
</style>

View file

@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
<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.nothing }}
</p>
</div>
<MkResult type="error"/>
</div>
<div v-else-if="list" class="_spacer" style="--MI_SPACER-w: 700px;">
<div v-if="list" class="members _margin">
@ -42,7 +36,6 @@ import { i18n } from '@/i18n.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkButton from '@/components/MkButton.vue';
import { definePage } from '@/page.js';
import { serverErrorImageUrl } from '@/instance.js';
const props = defineProps<{
listId: string;

View file

@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<div>
<div v-if="antennas.length === 0" class="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
<MkResult v-if="antennas.length === 0" type="empty"/>
<MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
@ -32,7 +27,6 @@ import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { antennasCache } from '@/cache.js';
import { infoImageUrl } from '@/instance.js';
const antennas = computed(() => antennasCache.value.value ?? []);

View file

@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<div class="_gaps">
<div v-if="items.length === 0" class="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
<MkResult v-if="items.length === 0" type="empty"/>
<MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
@ -35,7 +30,6 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { userListsCache } from '@/cache.js';
import { infoImageUrl } from '@/instance.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();

View file

@ -4,11 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<div class="_fullinfo">
<img :src="notFoundImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFoundDescription }}</div>
</div>
<div style="align-content: center; height: 100cqh;">
<MkResult type="notFound" :text="i18n.ts.notFoundDescription"/>
</div>
</template>
@ -17,7 +14,6 @@ import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { pleaseLogin } from '@/utility/please-login.js';
import { notFoundImageUrl } from '@/instance.js';
const props = defineProps<{
showLoginPopup?: boolean;

View file

@ -6,30 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs">
<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
<div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
<p :class="$style.text">
<i class="ti ti-alert-triangle"></i>
{{ error }}
</p>
</div>
<MkResult type="error" :text="error"/>
</div>
<div v-else-if="tab === 'users'" class="_spacer" style="--MI_SPACER-w: 1200px;">
<div class="_gaps_s">
<div v-if="role">{{ role.description }}</div>
<MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/>
<div v-else-if="!visible" class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
</div>
</div>
<div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;">
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
<div v-else-if="!visible" class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
</div>
</PageWithHeader>
</template>
@ -37,13 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { instanceName } from '@@/js/config.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkUserList from '@/components/MkUserList.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import MkTimeline from '@/components/MkTimeline.vue';
import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
roleId: string;
@ -97,23 +83,3 @@ definePage(() => ({
icon: 'ti ti-badge',
}));
</script>
<style lang="scss" module>
.root {
padding: 32px;
text-align: center;
align-items: center;
}
.text {
margin: 0 0 8px 0;
}
.img {
vertical-align: bottom;
width: 128px;
height: 128px;
margin-bottom: 16px;
border-radius: var(--MI-radius-md);
}
</style>

View file

@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps_m">
<FormPagination ref="list" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #empty><MkResult type="empty"/></template>
<template #default="{items}">
<div class="_gaps">
<MkFolder v-for="token in items" :key="token.id" :defaultOpen="true">
@ -63,7 +58,6 @@ import { definePage } from '@/page.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import { infoImageUrl } from '@/instance.js';
const list = ref<InstanceType<typeof FormPagination>>();

View file

@ -78,12 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template>
<MkPagination :pagination="renoteMutingPagination">
<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="_gaps_s">
@ -115,12 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.mutedUsers }}</template>
<MkPagination :pagination="mutingPagination">
<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="_gaps_s">
@ -154,12 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.blockedUsers }}</template>
<MkPagination :pagination="blockingPagination">
<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="_gaps_s">
@ -197,7 +182,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js';
import { instance, infoImageUrl } from '@/instance.js';
import { instance } from '@/instance.js';
import { ensureSignin } from '@/i.js';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';

View file

@ -628,6 +628,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['swipe', 'pull', 'refresh']">
<MkPreferenceContainer k="enablePullToRefresh">
<MkSwitch v-model="enablePullToRefresh">
<template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts._settings.enablePullToRefresh_description }}</SearchKeyword></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['keep', 'screen', 'display', 'on']">
<MkPreferenceContainer k="keepScreenOn">
<MkSwitch v-model="keepScreenOn">
@ -1032,6 +1041,7 @@ const animatedMfm = prefer.model('animatedMfm');
const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages');
const keepScreenOn = prefer.model('keepScreenOn');
const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
const enablePullToRefresh = prefer.model('enablePullToRefresh');
const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
const contextMenu = prefer.model('contextMenu');
const menuStyle = prefer.model('menuStyle');
@ -1088,6 +1098,8 @@ watch([
keepScreenOn,
contextMenu,
makeEveryTextElementsSelectable,
enableHorizontalSwipe,
enablePullToRefresh,
noteDesign,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });

View file

@ -0,0 +1,47 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/profiles" :label="i18n.ts._preferencesProfile.manageProfiles" :keywords="['profile', 'settings', 'preferences', 'manage']" icon="ti ti-settings-cog">
<div class="_gaps">
<MkFolder v-for="backup in backups">
<template #label>{{ backup.name }}</template>
<MkButton danger @click="del(backup)">{{ i18n.ts.delete }}</MkButton>
</MkFolder>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import { deleteCloudBackup, listCloudBackups } from '@/preferences/utility.js';
const backups = await listCloudBackups();
function del(backup) {
deleteCloudBackup(backup.name);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts._preferencesProfile.manageProfiles,
icon: 'ti ti-settings-cog',
}));
</script>
<style lang="scss" module>
</style>

View file

@ -173,8 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLazy>
<div v-if="noteview === 'pinned'" class="_gaps">
<div v-if="user.pinnedNotes.length < 1" class="_fullinfo">
<img :src="infoImageUrl" draggable="false" aria-hidden="true" :alt="i18n.ts.noNotes"/>
<div>{{ i18n.ts.noNotes }}</div>
<MkResult type="empty" :text="i18n.ts.noNotes"/>
</div>
<div v-else class="_panel">
<DynamicNote v-for="note of user.pinnedNotes" :key="note.id" class="note" :class="$style.pinnedNote" :note="note" :pinned="true"/>
@ -220,7 +219,6 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
import { useRouter } from '@/router.js';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import { infoImageUrl } from '@/instance.js';
import MkSparkle from '@/components/MkSparkle.vue';
import { prefer } from '@/preferences.js';
import DynamicNote from '@/components/DynamicNote.vue';

View file

@ -16,8 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div v-if="tab === 'pinned'" class="_gaps">
<div v-if="user.pinnedNotes.length < 1" class="_fullinfo">
<img :src="infoImageUrl" draggable="false" aria-hidden="true" :alt="i18n.ts.noNotes"/>
<div>{{ i18n.ts.noNotes }}</div>
<MkResult type="empty" :text="i18n.ts.noNotes"/>
</div>
<div v-else class="_panel">
<DynamicNote v-for="note of user.pinnedNotes" :key="note.id" class="note" :class="$style.pinnedNote" :note="note" :pinned="true"/>
@ -33,7 +32,6 @@ import * as Misskey from 'misskey-js';
import MkNotes from '@/components/MkNotes.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import DynamicNote from '@/components/DynamicNote.vue';
const props = defineProps<{

View file

@ -303,6 +303,9 @@ export const PREF_DEF = {
default: false,
},
enableHorizontalSwipe: {
default: false,
},
enablePullToRefresh: {
default: true,
},
useNativeUiForVideoAudioPlayer: {

View file

@ -74,12 +74,17 @@ export function getPreferencesProfileMenu(): MenuItem[] {
action: () => {
importProfile();
},
}, {
type: 'divider',
}, {
type: 'link',
text: i18n.ts._preferencesProfile.manageProfiles + '...',
icon: 'ti ti-settings-cog',
to: '/settings/profiles',
}];
if (prefer.s.devMode) {
menu.push({
type: 'divider',
}, {
text: 'Copy profile as text',
icon: 'ti ti-clipboard',
action: () => {
@ -145,17 +150,30 @@ export async function cloudBackup() {
});
}
export async function restoreFromCloudBackup() {
if ($i == null) return;
// TODO: 更新日時でソートして取得したい
export async function listCloudBackups() {
const keys = await misskeyApi('i/registry/keys', {
scope: ['client', 'preferences', 'backups'],
});
if (_DEV_) console.debug(keys);
return keys.map(k => ({
name: k,
}));
}
if (keys.length === 0) {
export async function deleteCloudBackup(key: string) {
await os.apiWithDialog('i/registry/remove', {
scope: ['client', 'preferences', 'backups'],
key,
});
}
export async function restoreFromCloudBackup() {
if ($i == null) return;
// TODO: 更新日時でソートしたい
const backups = await listCloudBackups();
if (backups.length === 0) {
os.alert({
type: 'warning',
title: i18n.ts._preferencesBackup.noBackupsFoundTitle,
@ -166,9 +184,9 @@ export async function restoreFromCloudBackup() {
const select = await os.select({
title: i18n.ts._preferencesBackup.selectBackupToRestore,
items: keys.map(k => ({
text: k,
value: k,
items: backups.map(backup => ({
text: backup.name,
value: backup.name,
})),
});
if (select.canceled) return;

View file

@ -9,6 +9,7 @@ import type { RouteDef } from '@/lib/nirax.js';
import { $i, iAmModerator } from '@/i.js';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
import PageTimeline from '@/pages/timeline.vue';
export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
loader: loader,
@ -21,6 +22,13 @@ function chatPage(...args: Parameters<typeof page>) {
}
export const ROUTE_DEF = [{
name: 'index',
path: '/',
component: $i ? PageTimeline : page(() => import('@/pages/welcome.vue')),
}, {
path: '/timeline',
component: PageTimeline,
}, {
path: '/@:username/pages/:pageName(*)',
component: page(() => import('@/pages/page.vue')),
}, {
@ -172,6 +180,10 @@ export const ROUTE_DEF = [{
path: '/custom-css',
name: 'preferences',
component: page(() => import('@/pages/settings/custom-css.vue')),
}, {
path: '/profiles',
name: 'profiles',
component: page(() => import('@/pages/settings/profiles.vue')),
}, {
path: '/accounts',
name: 'profile',
@ -600,12 +612,9 @@ export const ROUTE_DEF = [{
component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false,
}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),
}, {
name: 'index',
path: '/',
component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')),
path: '/debug',
component: page(() => import('@/pages/debug.vue')),
loginRequired: false,
}, {
// テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする
path: '/redirect-test',

View file

@ -209,7 +209,12 @@ rt {
._spacer {
width: 100%;
max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-max, 24px) * 2)));
margin: var(--MI_SPACER-max, 24px) auto;
/* marginを使って余白を表現すると、margin特有の親突き抜け仕様などが厄介になってくるので上下はpaddingを使う */
padding: var(--MI_SPACER-max, 24px) 0;
margin: 0 auto;
box-sizing: border-box;
container-type: inline-size;
}
@ -222,13 +227,13 @@ rt {
._forceShrinkSpacer ._spacer {
max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-min, 12px) * 2)));
margin: var(--MI_SPACER-min, 12px) auto;
padding: var(--MI_SPACER-min, 12px) 0;
}
@container (max-width: 450px) {
._spacer {
max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-min, 12px) * 2)));
margin: var(--MI_SPACER-min, 12px) auto;
padding: var(--MI_SPACER-min, 12px) 0;
}
}
@ -531,18 +536,6 @@ rt {
}
}
._fullinfo {
padding: 64px 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: var(--MI-radius-md);
}
}
._link {
color: var(--MI_THEME-link);
}

View file

@ -39,6 +39,7 @@ import type { PageMetadata } from '@/page.js';
import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue';
import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue';
import XTitlebar from '@/ui/_common_/titlebar.vue';
import XSidebar from '@/ui/_common_/navbar.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
@ -51,7 +52,6 @@ import { shouldSuggestRestoreBackup } from '@/preferences/utility.js';
import { DI } from '@/di.js';
const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));

View file

@ -5,6 +5,7 @@
import { computed, reactive, watch } from 'vue';
import type { Reactive } from 'vue';
import { deepEqual } from '@/utility/deep-equal';
function copy<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
@ -27,7 +28,7 @@ export function useForm<T extends Record<string, any>>(initialState: T, save: (n
watch([currentState, previousState], () => {
for (const key in modifiedStates) {
modifiedStates[key] = currentState[key] !== previousState[key];
modifiedStates[key] = !deepEqual(currentState[key], previousState[key]);
}
}, { deep: true });

View file

@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
</div>
<div v-else :class="$style.bdayFFallback">
<img :src="infoImageUrl" draggable="false" :class="$style.bdayFFallbackImage"/>
<div>{{ i18n.ts.nothing }}</div>
<MkResult type="empty"/>
</div>
</div>
</MkContainer>
@ -32,7 +31,6 @@ import type { GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/i.js';
const name = i18n.ts._widgets.birthdayFollowings;
@ -134,12 +132,4 @@ defineExpose<WidgetComponentExpose>({
justify-content: center;
align-items: center;
}
.bdayFFallbackImage {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--MI-radius);
}
</style>

View file

@ -11,10 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="ekmkgxbj">
<MkLoading v-if="fetching"/>
<div v-else-if="(!items || items.length === 0) && widgetProps.showHeader" class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
<MkResult v-else-if="(!items || items.length === 0) && widgetProps.showHeader" type="empty"/>
<div v-else :class="$style.feed">
<a v-for="item in items" :key="item.link" :class="$style.item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
</div>
@ -32,7 +29,6 @@ import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps
import type { GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const name = 'rss';

View file

@ -48,6 +48,7 @@
},
"compileOnSave": false,
"include": [
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",
"./test/**/*.ts",