refactor: pagination/date-separated-list系処理を良い感じに? (#8209)

* pages/messaging/messaging-room.vue

* wip

* wip

* wip???

* wip?

* ✌️

* messaaging-room.form.vue rewrite to compositon api

* refactor

* 関心事でないのでとりあえず置いておく

* 🎨

* 🎨

* i18n.ts

* fix scroll container find function

* fix

* FIX

* ✌️

* Fix scroll bottom detect

* wip

* aaaaaaaaaaa

* rename

* fix

* fix?

* ✌️

* ✌️

* clean up

* clena up

* refactor

* scroll event once or not

* fix

* fix once

* add safe-area-inset-bottom to spacer

* fix

* ✌️

* 🎨

* fix

* fix

* wip

* ✌️

* clean up

* fix lint

* Update packages/client/src/components/global/sticky-container.vue

Co-authored-by: Johann150 <johann.galle@protonmail.com>

* Update packages/client/src/components/ui/pagination.vue

Co-authored-by: Johann150 <johann.galle@protonmail.com>

* Update packages/client/src/pages/messaging/messaging-room.form.vue

Co-authored-by: Johann150 <johann.galle@protonmail.com>

* clean up: single line comment

* https://github.com/misskey-dev/misskey/pull/8209#discussion_r867386077

* fix

* asobi → tolerance

* pick form

* pick message

* pick room

* fix lint

* fix scroll?

* fix scroll.ts

* fix directives/sticky-container

* update global/sticky-container.vue

* fix, 🎨

* revert merge

* ✌️

* fix lint errors

* 🎨

* Update packages/client/src/types/date-separated-list.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* https://github.com/misskey-dev/misskey/pull/8209#discussion_r917225080

* use '

* Update packages/client/src/scripts/scroll.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* use Number.EPSILON

Co-authored-by: acid-chicken <root@acid-chicken.com>

* revert

* fix

* fix

* Use % instead of vh

* 🎨

* 🎨

* 🎨

* wip

* wip

* css modules

Co-authored-by: Johann150 <johann.galle@protonmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
tamaina 2023-01-13 18:25:40 +09:00 committed by GitHub
parent 519a08f8b5
commit d2204fd5c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 457 additions and 278 deletions

View file

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, h, PropType, TransitionGroup } from 'vue'; import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
import MkAd from '@/components/global/MkAd.vue'; import MkAd from '@/components/global/MkAd.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
export default defineComponent({ export default defineComponent({
props: { props: {
items: { items: {
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, type: Array as PropType<MisskeyEntity[]>,
required: true, required: true,
}, },
direction: { direction: {
@ -33,6 +34,7 @@ export default defineComponent({
}, },
setup(props, { slots, expose }) { setup(props, { slots, expose }) {
const $style = useCssModule();
function getDateText(time: string) { function getDateText(time: string) {
const date = new Date(time).getDate(); const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1; const month = new Date(time).getMonth() + 1;
@ -57,21 +59,25 @@ export default defineComponent({
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
) { ) {
const separator = h('div', { const separator = h('div', {
class: 'separator', class: $style['separator'],
key: item.id + ':separator', key: item.id + ':separator',
}, h('p', { }, h('p', {
class: 'date', class: $style['date'],
}, [ }, [
h('span', [ h('span', {
class: $style['date-1'],
}, [
h('i', { h('i', {
class: 'ti ti-chevron-up icon', class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}), }),
getDateText(item.createdAt), getDateText(item.createdAt),
]), ]),
h('span', [ h('span', {
class: $style['date-2'],
}, [
getDateText(props.items[i + 1].createdAt), getDateText(props.items[i + 1].createdAt),
h('i', { h('i', {
class: 'ti ti-chevron-down icon', class: `ti ti-chevron-down ${$style['date-2-icon']}`,
}), }),
]), ]),
])); ]));
@ -89,26 +95,62 @@ export default defineComponent({
} }
}); });
function onBeforeLeave(el: HTMLElement) {
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCanceled(el: HTMLElement) {
el.style.top = '';
el.style.left = '';
}
return () => h( return () => h(
defaultStore.state.animation ? TransitionGroup : 'div', defaultStore.state.animation ? TransitionGroup : 'div',
defaultStore.state.animation ? { {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), class: {
name: 'list', [$style['date-separated-list']]: true,
tag: 'div', [$style['date-separated-list-nogap']]: props.noGap,
'data-direction': props.direction, [$style['reversed']]: props.reversed,
'data-reversed': props.reversed ? 'true' : 'false', [$style['direction-down']]: props.direction === 'down',
} : { [$style['direction-up']]: props.direction === 'up',
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), },
...(defaultStore.state.animation ? {
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCanceled,
} : {}),
}, },
{ default: renderChildren }); { default: renderChildren });
}, },
}); });
</script> </script>
<style lang="scss"> <style lang="scss" module>
.sqadhkmv { .date-separated-list {
container-type: inline-size; container-type: inline-size;
&:global {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&.deny-move-transition > .list-move {
transition: none !important;
}
> .list-leave-active,
> .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-from,
> .list-leave-to,
> .list-leave-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
position: absolute !important;
}
> *:empty { > *:empty {
display: none; display: none;
} }
@ -116,73 +158,75 @@ export default defineComponent({
> *:not(:last-child) { > *:not(:last-child) {
margin-bottom: var(--margin); margin-bottom: var(--margin);
} }
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
} }
}
> .list-enter-active { .date-separated-list-nogap {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); > * {
} margin: 0 !important;
border: none;
border-radius: 0;
box-shadow: none;
&[data-direction="up"] { &:not(:last-child) {
> .list-enter-from { border-bottom: solid 0.5px var(--divider);
opacity: 0;
transform: translateY(64px);
}
}
&[data-direction="down"] {
> .list-enter-from {
opacity: 0;
transform: translateY(-64px);
}
}
> .separator {
text-align: center;
> .date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--dateLabelFg);
> span {
&:first-child {
margin-right: 8px;
> .icon {
margin-right: 8px;
}
}
&:last-child {
margin-left: 8px;
> .icon {
margin-left: 8px;
}
}
}
}
}
&.noGap {
> * {
margin: 0 !important;
border: none;
border-radius: 0;
box-shadow: none;
&:not(:last-child) {
border-bottom: solid 0.5px var(--divider);
}
} }
} }
} }
.direction-up {
&:global {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(64px);
}
}
}
.direction-down {
&:global {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(-64px);
}
}
}
.reversed {
display: flex;
flex-direction: column-reverse;
}
.separator {
text-align: center;
}
.date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--dateLabelFg);
}
.date-1 {
margin-right: 8px;
}
.date-1-icon {
margin-right: 8px;
}
.date-2 {
margin-left: 8px;
}
.date-2-icon {
margin-left: 8px;
}
</style> </style>

View file

@ -9,7 +9,16 @@
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap }]"> <div :class="[$style.root, { [$style.noGap]: noGap }]">
<MkDateSeparatedList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" :class="$style.notes"> <MkDateSeparatedList
ref="notes"
v-slot="{ item: note }"
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
:no-gap="noGap"
:ad="true"
:class="$style.notes"
>
<XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> <XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
</MkDateSeparatedList> </MkDateSeparatedList>
</div> </div>

View file

@ -15,14 +15,14 @@
<div v-else ref="rootEl"> <div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _margin"> <div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _margin">
<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>
</div> </div>
<slot :items="items"></slot> <slot :items="items" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _margin"> <div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _margin">
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>
@ -31,15 +31,18 @@
</Transition> </Transition>
</template> </template>
<script lang="ts" setup> <script lang="ts">
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, shallowRef, watch } from 'vue'; import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import * as os from '@/os'; import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const SECOND_FETCH_LIMIT = 30; const SECOND_FETCH_LIMIT = 30;
const TOLERANCE = 16;
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
endpoint: E; endpoint: E;
@ -58,8 +61,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
reversed?: boolean; reversed?: boolean;
offsetMode?: boolean; offsetMode?: boolean;
};
pageEl?: HTMLElement;
};
</script>
<script lang="ts" setup>
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
pagination: Paging; pagination: Paging;
disableAutoLoad?: boolean; disableAutoLoad?: boolean;
@ -72,21 +78,73 @@ const emit = defineEmits<{
(ev: 'queue', count: number): void; (ev: 'queue', count: number): void;
}>(); }>();
type Item = { id: string; [another: string]: unknown; }; let rootEl = $shallowRef<HTMLElement>();
const rootEl = shallowRef<HTMLElement>(); //
const items = ref<Item[]>([]); let backed = $ref(false);
const queue = ref<Item[]>([]);
let scrollRemove = $ref<(() => void) | null>(null);
const items = ref<MisskeyEntity[]>([]);
const queue = ref<MisskeyEntity[]>([]);
const offset = ref(0); const offset = ref(0);
const fetching = ref(true); const fetching = ref(true);
const moreFetching = ref(false); const moreFetching = ref(false);
const more = ref(false); const more = ref(false);
const backed = ref(false); //
const isBackTop = ref(false); const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0); const empty = computed(() => items.value.length === 0);
const error = ref(false); const error = ref(false);
const {
enableInfiniteScroll
} = defaultStore.reactiveState;
const init = async (): Promise<void> => { const contentEl = $computed(() => props.pagination.pageEl || rootEl);
const scrollableElement = $computed(() => getScrollContainer(contentEl));
//
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
let scrollObserver = $ref<IntersectionObserver>();
watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
if (scrollObserver) scrollObserver.disconnect();
scrollObserver = new IntersectionObserver(entries => {
backed = entries[0].isIntersecting;
}, {
root: scrollableElement,
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
threshold: 0.01,
});
}, { immediate: true });
watch($$(rootEl), () => {
scrollObserver.disconnect();
nextTick(() => {
if (rootEl) scrollObserver.observe(rootEl);
});
});
watch([$$(backed), $$(contentEl)], () => {
if (!backed) {
if (!contentEl) return;
scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE);
} else {
if (scrollRemove) scrollRemove();
scrollRemove = null;
}
});
if (props.pagination.params && isRef(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
}
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
}, { deep: true });
async function init(): Promise<void> {
queue.value = []; queue.value = [];
fetching.value = true; fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
@ -96,18 +154,15 @@ const init = async (): Promise<void> => {
}).then(res => { }).then(res => {
for (let i = 0; i < res.length; i++) { for (let i = 0; i < res.length; i++) {
const item = res[i]; const item = res[i];
if (props.pagination.reversed) { if (i === 3) item._shouldInsertAd_ = true;
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
} }
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
res.pop(); res.pop();
items.value = props.pagination.reversed ? [...res].reverse() : res; if (props.pagination.reversed) moreFetching.value = true;
items.value = res;
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed ? [...res].reverse() : res; items.value = res;
more.value = false; more.value = false;
} }
offset.value = res.length; offset.value = res.length;
@ -117,17 +172,16 @@ const init = async (): Promise<void> => {
error.value = true; error.value = true;
fetching.value = false; fetching.value = false;
}); });
}; }
const reload = (): void => { const reload = (): Promise<void> => {
items.value = []; items.value = [];
init(); return init();
}; };
const fetchMore = async (): Promise<void> => { const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.value = true; moreFetching.value = true;
backed.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, { await os.api(props.pagination.endpoint, {
...params, ...params,
@ -142,22 +196,52 @@ const fetchMore = async (): Promise<void> => {
}).then(res => { }).then(res => {
for (let i = 0; i < res.length; i++) { for (let i = 0; i < res.length; i++) {
const item = res[i]; const item = res[i];
if (props.pagination.reversed) { if (i === 10) item._shouldInsertAd_ = true;
if (i === res.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
} }
const reverseConcat = _res => {
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
items.value = items.value.concat(_res);
return nextTick(() => {
if (scrollableElement) {
scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
});
};
if (res.length > SECOND_FETCH_LIMIT) { if (res.length > SECOND_FETCH_LIMIT) {
res.pop(); res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = true; if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = true;
moreFetching.value = false;
}
} else { } else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); if (props.pagination.reversed) {
more.value = false; reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = false;
moreFetching.value = false;
}
} }
offset.value += res.length; offset.value += res.length;
moreFetching.value = false;
}, err => { }, err => {
moreFetching.value = false; moreFetching.value = false;
}); });
@ -180,10 +264,10 @@ const fetchMoreAhead = async (): Promise<void> => {
}).then(res => { }).then(res => {
if (res.length > SECOND_FETCH_LIMIT) { if (res.length > SECOND_FETCH_LIMIT) {
res.pop(); res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); items.value = items.value.concat(res);
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); items.value = items.value.concat(res);
more.value = false; more.value = false;
} }
offset.value += res.length; offset.value += res.length;
@ -193,106 +277,96 @@ const fetchMoreAhead = async (): Promise<void> => {
}); });
}; };
const prepend = (item: Item): void => { const prepend = (item: MisskeyEntity): void => {
if (props.pagination.reversed) { // unshiftOK
if (rootEl.value) { if (!rootEl) {
const container = getScrollContainer(rootEl.value); items.value.unshift(item);
if (container == null) { return;
// TODO?
} else {
const pos = getScrollPosition(rootEl.value);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//items.value = items.value.slice(-props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.shift();
}
more.value = true;
}
}
}
}
items.value.push(item);
// TODO
} else {
// unshiftOK
if (!rootEl.value) {
items.value.unshift(item);
return;
}
const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) {
// Prepend the item
items.value.unshift(item);
//
if (items.value.length >= props.displayLimit) {
// Vue 3.2
//this.items = items.value.slice(0, props.displayLimit);
while (items.value.length >= props.displayLimit) {
items.value.pop();
}
more.value = true;
}
} else {
queue.value.push(item);
onScrollTop(rootEl.value, () => {
for (const item of queue.value) {
prepend(item);
}
queue.value = [];
});
}
} }
const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
if (isTop) unshiftItems([item]);
else prependQueue(item);
}; };
const append = (item: Item): void => { function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.length;
items.value = [ ...newItems, ...items.value ].slice(0, props.displayLimit);
if (length >= props.displayLimit) more.value = true;
}
function executeQueue() {
if (queue.value.length === 0) return;
unshiftItems(queue.value);
queue.value = [];
}
function prependQueue(newItem: MisskeyEntity) {
queue.value.unshift(newItem);
if (queue.value.length >= props.displayLimit) {
queue.value.pop();
}
}
const appendItem = (item: MisskeyEntity): void => {
items.value.push(item); items.value.push(item);
}; };
const removeItem = (finder: (item: Item) => boolean) => { const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
const i = items.value.findIndex(finder); const i = items.value.findIndex(finder);
items.value.splice(i, 1); items.value.splice(i, 1);
}; };
const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => { const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
const i = items.value.findIndex(item => item.id === id); const i = items.value.findIndex(item => item.id === id);
items.value[i] = replacer(items.value[i]); items.value[i] = replacer(items.value[i]);
}; };
if (props.pagination.params && isRef(props.pagination.params)) { const inited = init();
watch(props.pagination.params, init, { deep: true });
}
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
}, { deep: true });
init();
onActivated(() => { onActivated(() => {
isBackTop.value = false; isBackTop.value = false;
}); });
onDeactivated(() => { onDeactivated(() => {
isBackTop.value = window.scrollY === 0; isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl?.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
scrollToBottom(contentEl);
}
onMounted(() => {
inited.then(() => {
if (props.pagination.reversed) {
nextTick(() => {
setTimeout(toBottom, 800);
// scrollToBottommoreFetching
// more = true
setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
}
});
});
onBeforeUnmount(() => {
scrollObserver.disconnect();
}); });
defineExpose({ defineExpose({
items, items,
queue, queue,
backed, backed,
more,
inited,
reload, reload,
prepend, prepend,
append, append: appendItem,
removeItem, removeItem,
updateItem, updateItem,
}); });

View file

@ -10,7 +10,7 @@
v-for="(message, i) in messages" v-for="(message, i) in messages"
:key="message.id" :key="message.id"
v-anim="i" v-anim="i"
class="message" class="message _panel"
:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
:data-index="i" :data-index="i"

View file

@ -256,9 +256,10 @@ defineExpose({
border: none; border: none;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
background: transparent;
box-sizing: border-box; box-sizing: border-box;
color: var(--fg); color: var(--fg);
background: rgba(12, 18, 16, 0.85);
backdrop-filter: var(--blur, blur(15px));
} }
footer { footer {

View file

@ -1,51 +1,48 @@
<template> <template>
<div <div
ref="rootEl" ref="rootEl"
class="" class="root"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop" @drop.prevent.stop="onDrop"
> >
<div class="mk-messaging-room"> <div class="body">
<div class="body"> <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> <template #empty>
<template #empty> <div class="_fullinfo">
<div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <div>{{ i18n.ts.noMessagesYet }}</div>
<div>{{ i18n.ts.noMessagesYet }}</div>
</div>
</template>
<template #default="{ items: messages, fetching: pFetching }">
<MkDateSeparatedList
v-if="messages.length > 0"
v-slot="{ item: message }"
:class="{ messages: true, 'deny-move-transition': pFetching }"
:items="messages"
direction="up"
reversed
>
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</div>
<footer>
<div v-if="typers.length > 0" class="typers">
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
<template #users>
<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
<Transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message">
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
</div> </div>
</Transition> </template>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> <template #default="{ items: messages, fetching: pFetching }">
</footer> <MkDateSeparatedList
v-if="messages.length > 0"
v-slot="{ item: message }"
:class="{ messages: true, 'deny-move-transition': pFetching }"
:items="messages"
direction="up"
reversed
>
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</div> </div>
<footer>
<div v-if="typers.length > 0" class="typers">
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
<template #users>
<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
<Transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message">
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
</div>
</Transition>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
</footer>
</div> </div>
</template> </template>
@ -140,7 +137,9 @@ async function fetch() {
document.addEventListener('visibilitychange', onVisibilitychange); document.addEventListener('visibilitychange', onVisibilitychange);
nextTick(() => { nextTick(() => {
thisScrollToBottom(); pagingComponent.inited.then(() => {
thisScrollToBottom();
});
window.setTimeout(() => { window.setTimeout(() => {
fetching = false; fetching = false;
}, 300); }, 300);
@ -305,11 +304,12 @@ definePageMetadata(computed(() => !fetching ? user ? {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-messaging-room { .root {
position: relative; display: content;
overflow: auto;
> .body { > .body {
min-height: 80%;
.more { .more {
display: block; display: block;
margin: 16px auto; margin: 16px auto;
@ -349,8 +349,9 @@ definePageMetadata(computed(() => !fetching ? user ? {
width: 100%; width: 100%;
position: sticky; position: sticky;
z-index: 2; z-index: 2;
bottom: 0;
padding-top: 8px; padding-top: 8px;
bottom: 0;
bottom: env(safe-area-inset-bottom, 0px);
> .new-message { > .new-message {
width: 100%; width: 100%;
@ -395,6 +396,8 @@ definePageMetadata(computed(() => !fetching ? user ? {
max-height: 12em; max-height: 12em;
overflow-y: scroll; overflow-y: scroll;
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
} }
} }
} }

View file

@ -10,53 +10,67 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
} }
} }
export function getScrollPosition(el: Element | null): number { export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top: number = 0) {
if (!el.parentElement) return top;
const data = el.dataset.stickyContainerHeaderHeight;
const newTop = data ? Number(data) + top : top;
if (el === container) return newTop;
return getStickyTop(el.parentElement, container, newTop);
}
export function getScrollPosition(el: HTMLElement | null): number {
const container = getScrollContainer(el); const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop; return container == null ? window.scrollY : container.scrollTop;
} }
export function isTopVisible(el: Element | null): boolean { export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) {
const scrollTop = getScrollPosition(el); // とりあえず評価してみる
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる if (isTopVisible(el)) {
cb();
if (once) return null;
}
return scrollTop <= topPosition;
}
export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
}
export function onScrollTop(el: Element, cb) {
const container = getScrollContainer(el) || window; const container = getScrollContainer(el) || window;
const onScroll = ev => { const onScroll = ev => {
if (!document.body.contains(el)) return; if (!document.body.contains(el)) return;
if (isTopVisible(el)) { if (isTopVisible(el, tolerance)) {
cb(); cb();
container.removeEventListener('scroll', onScroll); if (once) removeListener();
} }
}; };
function removeListener() { container.removeEventListener('scroll', onScroll); }
container.addEventListener('scroll', onScroll, { passive: true }); container.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
} }
export function onScrollBottom(el: Element, cb) { export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) {
const container = getScrollContainer(el) || window; const container = getScrollContainer(el);
// とりあえず評価してみる
if (isBottomVisible(el, tolerance, container)) {
cb();
if (once) return null;
}
const containerOrWindow = container || window;
const onScroll = ev => { const onScroll = ev => {
if (!document.body.contains(el)) return; if (!document.body.contains(el)) return;
const pos = getScrollPosition(el); if (isBottomVisible(el, 1, container)) {
if (pos + el.clientHeight > el.scrollHeight - 1) {
cb(); cb();
container.removeEventListener('scroll', onScroll); if (once) removeListener();
} }
}; };
container.addEventListener('scroll', onScroll, { passive: true });
function removeListener() {
containerOrWindow.removeEventListener('scroll', onScroll);
}
containerOrWindow.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
} }
export function scroll(el: Element, options: { export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
top?: number;
left?: number;
behavior?: ScrollBehavior;
}) {
const container = getScrollContainer(el); const container = getScrollContainer(el);
if (container == null) { if (container == null) {
window.scroll(options); window.scroll(options);
@ -65,21 +79,51 @@ export function scroll(el: Element, options: {
} }
} }
export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { /**
* Scroll to Top
* @param el Scroll container element
* @param options Scroll options
*/
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
scroll(el, { top: 0, ...options }); scroll(el, { top: 0, ...options });
} }
export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { /**
scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する * Scroll to Bottom
* @param el Content element
* @param options Scroll options
* @param container Scroll container element
*/
export function scrollToBottom(
el: HTMLElement,
options: ScrollToOptions = {},
container = getScrollContainer(el),
) {
if (container) {
container.scroll({ top: el.scrollHeight - container.clientHeight + getStickyTop(el, container) || 0, ...options });
} else {
window.scroll({
top: (el.scrollHeight - window.innerHeight + getStickyTop(el, container) + (window.innerWidth <= 500 ? 96 : 0)) || 0,
...options
});
}
} }
export function isBottom(el: Element, asobi = 0) { export function isTopVisible(el: HTMLElement, tolerance: number = 1): boolean {
const container = getScrollContainer(el); const scrollTop = getScrollPosition(el);
const current = container return scrollTop <= tolerance;
? el.scrollTop + el.offsetHeight }
: window.scrollY + window.innerHeight;
const max = container export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
? el.scrollHeight if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
: document.body.offsetHeight; return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
return current >= (max - asobi); }
// https://ja.javascript.info/size-and-scroll-window#ref-932
export function getBodyScrollHeight() {
return Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
);
} }

View file

@ -0,0 +1,6 @@
export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
[x: string]: any;
};

View file

@ -37,12 +37,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue'; import { defineComponent } from 'vue';
import XHeader from './header.vue'; import XHeader from './header.vue';
import { host, instanceName } from '@/config'; import { host, instanceName } from '@/config';
import { search } from '@/scripts/search'; import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { ColdDeviceStorage } from '@/store'; import { ColdDeviceStorage } from '@/store';
import { mainRouter } from '@/router'; import { mainRouter } from '@/router';
@ -52,7 +51,6 @@ const DESKTOP_THRESHOLD = 1100;
export default defineComponent({ export default defineComponent({
components: { components: {
XHeader, XHeader,
MkPagination,
MkButton, MkButton,
}, },