[chatui] Fix animation behavior when reversed (in channel)

This commit is contained in:
Derek 2024-02-19 01:04:37 -05:00
parent 1c91784062
commit 8d4614a48d
8 changed files with 121 additions and 174 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

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