Compare commits

...

3 Commits

10 changed files with 192 additions and 211 deletions

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="notes"
v-slot="{ item: note }"
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:direction="pagination.scrollAtTop ? 'up' : 'down'"
:reversed="pagination.reversed"
:noGap="noGap"
:ad="true"

View File

@ -25,14 +25,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
<div v-show="pagination.scrollAtTop && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
<div v-show="!pagination.scrollAtTop && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
@ -73,11 +73,18 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
*/
reversed?: boolean;
/**
* Fetch more items when scrolling towards the top of the page
* rather than the bottom
*/
scrollAtTop?: boolean;
offsetMode?: boolean;
pageEl?: HTMLElement;
};
type MisskeyEntityMap = Map<string, MisskeyEntity>;
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
@ -156,14 +163,14 @@ const BACKGROUND_PAUSE_WAIT_SEC = 10;
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
const scrollObserver = ref<IntersectionObserver>();
watch([() => props.pagination.reversed, scrollableElement], () => {
watch([() => props.pagination.scrollAtTop, scrollableElement], () => {
if (scrollObserver.value) scrollObserver.value.disconnect();
scrollObserver.value = new IntersectionObserver(entries => {
backed.value = entries[0].isIntersecting;
}, {
root: scrollableElement.value,
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
rootMargin: props.pagination.scrollAtTop ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
threshold: 0.01,
});
}, { immediate: true });
@ -179,7 +186,7 @@ watch([backed, contentEl], () => {
if (!backed.value) {
if (!contentEl.value) return;
scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
scrollRemove.value = (props.pagination.scrollAtTop ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
@ -199,6 +206,23 @@ watch(error, (n, o) => {
emit('status', n);
});
function reverseConcat(res): Promise<void> {
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
items.value = concatMapWithArray(items.value, res);
return nextTick(() => {
if (scrollableElement.value) {
scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
});
};
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();
@ -209,6 +233,23 @@ async function init(): Promise<void> {
limit: props.pagination.limit ?? 10,
allowPartial: true,
}).then(res => {
/* HACK: Sort to avoid order inconsitencies on next fetch
APIs default to sorting by id descending if no filters are provided
However, if in "future" mode this must not be the case, as then our
first chunk will be out of order.
Since filters can be provided in props.pagination.params, just
assume that this chunk is in the wrong order to be safe.
*/
res.sort((a, b) => {
if (props.pagination.reversed) {
return a.id > b.id ? 1 : -1; // oldest -> newest
} else {
return a.id < b.id ? 1 : -1; // newest -> oldest
}
});
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
@ -218,7 +259,7 @@ async function init(): Promise<void> {
concatItems(res);
more.value = false;
} else {
if (props.pagination.reversed) moreFetching.value = true;
if (props.pagination.scrollAtTop) moreFetching.value = true;
concatItems(res);
more.value = true;
}
@ -232,21 +273,18 @@ async function init(): Promise<void> {
});
}
const reload = (): Promise<void> => {
return init();
};
const fetchMore = async (): Promise<void> => {
async function fetchMore(): Promise<void> {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
const filter = props.pagination.reversed ? 'sinceId' : 'untilId';
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
untilId: Array.from(items.value.keys()).at(-1),
[filter]: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
@ -254,78 +292,23 @@ const fetchMore = async (): Promise<void> => {
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = _res => {
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
const moreAvailable = res.length !== 0;
items.value = concatMapWithArray(items.value, _res);
return nextTick(() => {
if (scrollableElement.value) {
scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
if (props.pagination.scrollAtTop) {
reverseConcat(res).then(() => {
more.value = moreAvailable;
moreFetching.value = false;
});
};
if (res.length === 0) {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = false;
moreFetching.value = false;
}
} else {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
moreFetching.value = false;
}
items.value = concatMapWithArray(items.value, res);
more.value = moreAvailable;
moreFetching.value = false;
}
offset.value += res.length;
}, err => {
moreFetching.value = false;
});
};
const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
sinceId: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
if (res.length === 0) {
items.value = concatMapWithArray(items.value, res);
more.value = false;
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
}
offset.value += res.length;
moreFetching.value = false;
}, err => {
moreFetching.value = false;
});
};
}
/**
* AppearIntersectionObserverによってfetchMoreが呼ばれる場合
@ -346,13 +329,7 @@ const appearFetchMore = async (): Promise<void> => {
fetchMoreAppearTimeout();
};
const appearFetchMoreAhead = async (): Promise<void> => {
if (preventAppearFetchMore.value) return;
await fetchMoreAhead();
fetchMoreAppearTimeout();
};
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
const isTop = (): boolean => isBackTop.value || (props.pagination.scrollAtTop ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
@ -446,7 +423,7 @@ onActivated(() => {
});
onDeactivated(() => {
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
isBackTop.value = props.pagination.scrollAtTop ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
@ -455,7 +432,7 @@ function toBottom() {
onBeforeMount(() => {
init().then(() => {
if (props.pagination.reversed) {
if (props.pagination.scrollAtTop) {
nextTick(() => {
setTimeout(toBottom, 800);
@ -486,7 +463,7 @@ defineExpose({
queue,
backed: backed.value,
more,
reload,
reload: init,
prepend,
append: appendItem,
removeItem,

View File

@ -1,43 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Directive } from 'vue';
import { getScrollContainer, getScrollPosition } from '@/scripts/scroll.js';
export default {
mounted(src, binding, vn) {
if (binding.value === false) return;
let isBottom = true;
const container = getScrollContainer(src)!;
container.addEventListener('scroll', () => {
const pos = getScrollPosition(container);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
isBottom = (pos + viewHeight > height - 32);
}, { passive: true });
console.log('mount', container.scrollHeight);
container.scroll({ top: container.scrollHeight, behavior: 'smooth' });
const ro = new ResizeObserver((entries, observer) => {
console.log('resize', container.scrollHeight);
if (isBottom) {
const height = container.scrollHeight;
container.scroll({ top: height, behavior: 'instant' });
}
});
ro.observe(src);
// TODO: 新たにプロパティを作るのをやめMapを使う
src._ro_ = ro;
},
unmounted(src, binding, vn) {
if (src._ro_) src._ro_.unobserve(src);
},
} as Directive;

View File

@ -15,7 +15,6 @@ import anim from './anim.js';
import clickAnime from './click-anime.js';
import panel from './panel.js';
import adaptiveBorder from './adaptive-border.js';
import follow from './follow-append.js';
import adaptiveBg from './adaptive-bg.js';
export default function(app: App) {
@ -36,6 +35,5 @@ export const directives = {
'click-anime': clickAnime,
'panel': panel,
'adaptive-border': adaptiveBorder,
'follow': follow,
'adaptive-bg': adaptiveBg,
};

View File

@ -85,6 +85,7 @@ const prevUserPagination: Paging = {
const nextUserPagination: Paging = {
reversed: true,
scrollAtTop: true,
endpoint: 'users/notes',
limit: 10,
params: computed(() => note.value ? ({

View File

@ -1,5 +1,6 @@
<template>
<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
<div class="nav">
<header class="header">
<div class="left">
@ -64,14 +65,19 @@
</footer>
</div>
<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
<div class="content" style="container-type: inline-size;">
<RouterView :router="chatRouter"/>
</div>
</main>
<div class="body">
<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
<div class="content" style="container-type: inline-size;">
<RouterView :router="chatRouter"/>
</div>
<div class="side widgets">
<XWidgets/>
<div class="side widgets">
<XWidgets/>
</div>
</main>
<XStatusBars class="statusbars"/>
<XAnnouncements v-if="$i"/>
</div>
<transition name="menu-back">
@ -91,9 +97,8 @@
</template>
<script lang="ts" setup>
import { provide, ref, shallowRef, toRefs, computed, onMounted } from 'vue';
import { provide, ref, shallowRef, toRefs, computed, onMounted, defineAsyncComponent } from 'vue';
import { instanceName, url } from '@/config.js';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import XWidgets from './chat/widgets.vue';
import XCommon from './_common_/common.vue';
import XHeaderClock from './chat/header-clock.vue';
@ -110,7 +115,10 @@ import { instance } from '@/instance.js';
import { mainRouter } from '@/router/main.js';
import { defaultRoutes, page } from '@/router/definition.js';
import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js';
const XDrawerMenu = defineAsyncComponent(() => import('@/ui/_common_/navbar-for-mobile.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
const routes = [
{
@ -479,25 +487,32 @@ onMounted(() => {
}
}
> .main {
> .body {
display: flex;
flex: 1;
flex-direction: column;
flex: 1;
height: 100%;
> .content {
> .main {
display: flex;
flex: 1;
flex-direction: row;
overflow: auto;
overflow-y: scroll;
overscroll-behavior: contain;
background: var(--bg);
}
}
> .side {
width: 350px;
border-left: solid 4px var(--divider);
background: var(--panel);
> .content {
flex: 1;
overflow: auto;
overflow-y: scroll;
background: var(--bg);
}
> .side {
width: 350px;
border-left: solid 4px var(--divider);
background: var(--panel);
}
}
}
> .menuDrawer-back {

View File

@ -21,6 +21,11 @@ export default defineComponent({
required: false,
default: false
},
noMoveTransition: {
type: Boolean,
required: false,
default: false
},
ad: {
type: Boolean,
required: false,
@ -62,10 +67,10 @@ export default defineComponent({
h('i', {
class: 'ti ti-chevron-up icon',
}),
getDateText(item.createdAt)
props.reversed ? getDateText(props.items[i + 1].createdAt) : getDateText(item.createdAt),
]),
h('span', [
getDateText(props.items[i + 1].createdAt),
props.reversed ? getDateText(item.createdAt) : getDateText(props.items[i + 1].createdAt),
h('i', {
class: 'ti ti-chevron-down icon',
})
@ -85,31 +90,17 @@ export default defineComponent({
}
});
function onBeforeLeave(element: Element) {
const el = element as HTMLElement;
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCancelled(element: Element) {
const el = element as HTMLElement;
el.style.top = '';
el.style.left = '';
}
const classes = {
[$style['date-separated-list']]: true,
[$style.root]: true,
[$style['reversed']]: props.reversed,
[$style['direction-down']]: props.direction === 'down',
[$style['direction-up']]: props.direction === 'up',
};
return () => defaultStore.state.animation ? h(TransitionGroup, {
class: classes,
class: { ...classes, [$style['no-move-transition']]: props.noMoveTransition },
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCancelled,
}, { default: renderChildren }) : h('div', {
class: classes,
}, { default: renderChildren });
@ -118,29 +109,35 @@ export default defineComponent({
</script>
<style lang="scss" module>
.date-separated-list {
.root {
container-type: inline-size;
display: flex;
flex-direction: column;
&:global {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&.deny-move-transition > .list-move {
transition: none !important;
}
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-leave-active {
position: absolute;
}
> *:empty {
display: none;
}
}
}
// > *:not(:last-child) {
// margin-bottom: var(--margin);
// }
.no-move-transition {
&:global {
> .list-move {
transition: none !important;
}
}
}

View File

@ -8,35 +8,50 @@
</template>
<template #default="{ items: notes }">
<div>
<XList
ref="notes"
v-slot="{ item: note }"
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
:ad="true"
>
<XNote :key="note._featuredId_ || note._prId_ || note.id" :note="note"/>
</XList>
</div>
<XList
v-slot="{ item: note }"
:noMoveTransition="reversed && !atBottom"
:items="notes"
:direction="reversed ? 'up' : 'down'"
:reversed="reversed"
:ad="true"
>
<XNote :key="note._featuredId_ || note._prId_ || note.id" :note="note"/>
</XList>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import { shallowRef } from 'vue';
import { shallowRef, ref, watchEffect } from 'vue';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { isBottomVisible, getScrollContainer } from '@/scripts/scroll.js';
const props = defineProps<{
pagination: Paging;
reversed?: boolean;
}>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const notes = shallowRef();
const atBottom = ref(null);
watchEffect((cleanup) => {
if (props.pagination.pageEl) {
let scroller = getScrollContainer(props.pagination.pageEl);
const scrollCallback = () => {
atBottom.value = isBottomVisible(props.pagination.pageEl, 16, scroller);
};
scroller.addEventListener('scroll', scrollCallback, { passive: true });
cleanup(() => scroller.removeEventListener('scroll', scrollCallback));
}
});
defineExpose({
pagingComponent,

View File

@ -20,7 +20,7 @@
<div v-if="queue > 0" class="new" :style="{ width: width + 'px', top: inChannel ? null : top + 'px', bottom: inChannel ? bottom + 'px' : null }">
<button class="_buttonPrimary" @click="scrollToNew()">{{ i18n.ts.newNoteRecived }}</button>
</div>
<XNotes ref="tlElement" class="tl" :pagination="pagination" @queue="queueUpdated" v-follow="!!props.channelId"/>
<XNotes ref="tlElement" class="tl" :pagination="pagination" :reversed="inChannel" @queue="queueUpdated"/>
</div>
</div>
</div>
@ -72,7 +72,9 @@ let endpoint = ref(null);
const pagination = computed(() => ({
endpoint: endpoint.value,
reversed: inChannel.value,
scrollAtTop: inChannel.value,
pageEl: scroller.value,
limit: 15,
params: {
untilDate: timetravelTarget.value?.getTime(),
...query,

View File

@ -19,6 +19,9 @@
<button class="_buttonPrimary" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div>
</div>
<div class="floating">
<button v-tooltip="i18n.ts.previewNoteText" class="_button icon" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
</div>
<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
@ -53,7 +56,6 @@
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
<span v-else><i class="ti ti-icons"></i></span>
</button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button icon" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button>
</div>
</footer>
@ -1041,8 +1043,39 @@ onMounted(() => {
}
}
.icon {
display: inline-block;
padding: 0;
margin: 0;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
&.danger {
color: var(--error);
}
}
> .floating {
float: right;
margin-bottom: -32px;
position: relative;
z-index: auto;
opacity: 0.7;
> .icon {
width: 32px;
height: 32px;
font-size: 16px;
}
}
> footer {
$height: 44px;
$height: 38px;
display: flex;
padding: 0 8px 8px 8px;
line-height: $height;
@ -1051,20 +1084,6 @@ onMounted(() => {
width: $height;
height: $height;
font-size: 16px;
display: inline-block;
padding: 0;
margin: 0;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
&.danger {
color: var(--error);
}
}
> .left {