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">
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
import MkAd from '@/components/global/MkAd.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
export default defineComponent({
props: {
items: {
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
type: Array as PropType<MisskeyEntity[]>,
required: true,
},
direction: {
@ -33,6 +34,7 @@ export default defineComponent({
},
setup(props, { slots, expose }) {
const $style = useCssModule();
function getDateText(time: string) {
const date = new Date(time).getDate();
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()
) {
const separator = h('div', {
class: 'separator',
class: $style['separator'],
key: item.id + ':separator',
}, h('p', {
class: 'date',
class: $style['date'],
}, [
h('span', [
h('span', {
class: $style['date-1'],
}, [
h('i', {
class: 'ti ti-chevron-up icon',
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}),
getDateText(item.createdAt),
]),
h('span', [
h('span', {
class: $style['date-2'],
}, [
getDateText(props.items[i + 1].createdAt),
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(
defaultStore.state.animation ? TransitionGroup : 'div',
defaultStore.state.animation ? {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
name: 'list',
tag: 'div',
'data-direction': props.direction,
'data-reversed': props.reversed ? 'true' : 'false',
} : {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
{
class: {
[$style['date-separated-list']]: true,
[$style['date-separated-list-nogap']]: props.noGap,
[$style['reversed']]: props.reversed,
[$style['direction-down']]: props.direction === 'down',
[$style['direction-up']]: props.direction === 'up',
},
...(defaultStore.state.animation ? {
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCanceled,
} : {}),
},
{ default: renderChildren });
},
});
</script>
<style lang="scss">
.sqadhkmv {
<style lang="scss" module>
.date-separated-list {
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 {
display: none;
}
@ -116,73 +158,75 @@ export default defineComponent({
> *:not(:last-child) {
margin-bottom: var(--margin);
}
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
}
> .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);
}
.date-separated-list-nogap {
> * {
margin: 0 !important;
border: none;
border-radius: 0;
box-shadow: none;
&[data-direction="up"] {
> .list-enter-from {
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);
}
&: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>

View file

@ -9,7 +9,16 @@
<template #default="{ items: notes }">
<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"/>
</MkDateSeparatedList>
</div>

View file

@ -15,14 +15,14 @@
<div v-else ref="rootEl">
<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 }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items"></slot>
<slot :items="items" :fetching="fetching || moreFetching"></slot>
<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 }}
</MkButton>
<MkLoading v-else class="loading"/>
@ -31,15 +31,18 @@
</Transition>
</template>
<script lang="ts" setup>
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, shallowRef, watch } from 'vue';
<script lang="ts">
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import * as misskey from 'misskey-js';
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 { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
import { i18n } from '@/i18n';
const SECOND_FETCH_LIMIT = 30;
const TOLERANCE = 16;
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
endpoint: E;
@ -58,8 +61,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
reversed?: boolean;
offsetMode?: boolean;
};
pageEl?: HTMLElement;
};
</script>
<script lang="ts" setup>
const props = withDefaults(defineProps<{
pagination: Paging;
disableAutoLoad?: boolean;
@ -72,21 +78,73 @@ const emit = defineEmits<{
(ev: 'queue', count: number): void;
}>();
type Item = { id: string; [another: string]: unknown; };
let rootEl = $shallowRef<HTMLElement>();
const rootEl = shallowRef<HTMLElement>();
const items = ref<Item[]>([]);
const queue = ref<Item[]>([]);
//
let backed = $ref(false);
let scrollRemove = $ref<(() => void) | null>(null);
const items = ref<MisskeyEntity[]>([]);
const queue = ref<MisskeyEntity[]>([]);
const offset = ref(0);
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const backed = ref(false); //
const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0);
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 = [];
fetching.value = true;
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 => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
if (i === 3) item._shouldInsertAd_ = true;
}
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse() : res;
if (props.pagination.reversed) moreFetching.value = true;
items.value = res;
more.value = true;
} else {
items.value = props.pagination.reversed ? [...res].reverse() : res;
items.value = res;
more.value = false;
}
offset.value = res.length;
@ -117,17 +172,16 @@ const init = async (): Promise<void> => {
error.value = true;
fetching.value = false;
});
};
}
const reload = (): void => {
const reload = (): Promise<void> => {
items.value = [];
init();
return init();
};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.value = true;
backed.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
@ -142,22 +196,52 @@ const fetchMore = async (): Promise<void> => {
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
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) {
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 {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
if (props.pagination.reversed) {
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;
moreFetching.value = false;
}, err => {
moreFetching.value = false;
});
@ -180,10 +264,10 @@ const fetchMoreAhead = async (): Promise<void> => {
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {
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;
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
items.value = items.value.concat(res);
more.value = false;
}
offset.value += res.length;
@ -193,106 +277,96 @@ const fetchMoreAhead = async (): Promise<void> => {
});
};
const prepend = (item: Item): void => {
if (props.pagination.reversed) {
if (rootEl.value) {
const container = getScrollContainer(rootEl.value);
if (container == null) {
// 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 prepend = (item: MisskeyEntity): void => {
// unshiftOK
if (!rootEl) {
items.value.unshift(item);
return;
}
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);
};
const removeItem = (finder: (item: Item) => boolean) => {
const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
const i = items.value.findIndex(finder);
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);
items.value[i] = replacer(items.value[i]);
};
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 });
init();
const inited = init();
onActivated(() => {
isBackTop.value = false;
});
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({
items,
queue,
backed,
more,
inited,
reload,
prepend,
append,
append: appendItem,
removeItem,
updateItem,
});

View file

@ -10,7 +10,7 @@
v-for="(message, i) in messages"
:key="message.id"
v-anim="i"
class="message"
class="message _panel"
: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)}`"
:data-index="i"

View file

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

View file

@ -1,51 +1,48 @@
<template>
<div
ref="rootEl"
class=""
class="root"
@dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop"
>
<div class="mk-messaging-room">
<div class="body">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<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 class="body">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ i18n.ts.noMessagesYet }}</div>
</div>
</Transition>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
</footer>
</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>
</Transition>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
</footer>
</div>
</template>
@ -140,7 +137,9 @@ async function fetch() {
document.addEventListener('visibilitychange', onVisibilitychange);
nextTick(() => {
thisScrollToBottom();
pagingComponent.inited.then(() => {
thisScrollToBottom();
});
window.setTimeout(() => {
fetching = false;
}, 300);
@ -305,11 +304,12 @@ definePageMetadata(computed(() => !fetching ? user ? {
</script>
<style lang="scss" scoped>
.mk-messaging-room {
position: relative;
overflow: auto;
.root {
display: content;
> .body {
min-height: 80%;
.more {
display: block;
margin: 16px auto;
@ -349,8 +349,9 @@ definePageMetadata(computed(() => !fetching ? user ? {
width: 100%;
position: sticky;
z-index: 2;
bottom: 0;
padding-top: 8px;
bottom: 0;
bottom: env(safe-area-inset-bottom, 0px);
> .new-message {
width: 100%;
@ -395,6 +396,8 @@ definePageMetadata(computed(() => !fetching ? user ? {
max-height: 12em;
overflow-y: scroll;
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);
return container == null ? window.scrollY : container.scrollTop;
}
export function isTopVisible(el: Element | null): boolean {
const scrollTop = getScrollPosition(el);
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) {
// とりあえず評価してみる
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 onScroll = ev => {
if (!document.body.contains(el)) return;
if (isTopVisible(el)) {
if (isTopVisible(el, tolerance)) {
cb();
container.removeEventListener('scroll', onScroll);
if (once) removeListener();
}
};
function removeListener() { container.removeEventListener('scroll', onScroll); }
container.addEventListener('scroll', onScroll, { passive: true });
return removeListener;
}
export function onScrollBottom(el: Element, cb) {
const container = getScrollContainer(el) || window;
export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) {
const container = getScrollContainer(el);
// とりあえず評価してみる
if (isBottomVisible(el, tolerance, container)) {
cb();
if (once) return null;
}
const containerOrWindow = container || window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
const pos = getScrollPosition(el);
if (pos + el.clientHeight > el.scrollHeight - 1) {
if (isBottomVisible(el, 1, container)) {
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: {
top?: number;
left?: number;
behavior?: ScrollBehavior;
}) {
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
const container = getScrollContainer(el);
if (container == null) {
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 });
}
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) {
const container = getScrollContainer(el);
const current = container
? el.scrollTop + el.offsetHeight
: window.scrollY + window.innerHeight;
const max = container
? el.scrollHeight
: document.body.offsetHeight;
return current >= (max - asobi);
export function isTopVisible(el: HTMLElement, tolerance: number = 1): boolean {
const scrollTop = getScrollPosition(el);
return scrollTop <= tolerance;
}
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;
}
// 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>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { defineComponent } from 'vue';
import XHeader from './header.vue';
import { host, instanceName } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { ColdDeviceStorage } from '@/store';
import { mainRouter } from '@/router';
@ -52,7 +51,6 @@ const DESKTOP_THRESHOLD = 1100;
export default defineComponent({
components: {
XHeader,
MkPagination,
MkButton,
},