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:
		
							parent
							
								
									519a08f8b5
								
							
						
					
					
						commit
						d2204fd5c8
					
				
					 9 changed files with 457 additions and 278 deletions
				
			
		|  | @ -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', { | ||||
| 						class: $style['date-1'], | ||||
| 					}, [ | ||||
| 					h('span', [ | ||||
| 						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' : ''), | ||||
| 			{ | ||||
| 					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', | ||||
| 				'data-direction': props.direction, | ||||
| 				'data-reversed': props.reversed ? 'true' : 'false', | ||||
| 			} : { | ||||
| 				class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), | ||||
| 						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,63 +158,10 @@ 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); | ||||
| 	} | ||||
| 
 | ||||
| 	&[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 { | ||||
| .date-separated-list-nogap { | ||||
| 	> * { | ||||
| 		margin: 0 !important; | ||||
| 		border: none; | ||||
|  | @ -184,5 +173,60 @@ export default defineComponent({ | |||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .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> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 (!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; | ||||
| 		} | ||||
| 
 | ||||
| 		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); | ||||
| 
 | ||||
| 			if (props.pagination.reversed) { | ||||
| 				reverseConcat(res).then(() => { | ||||
| 					more.value = true; | ||||
| 					moreFetching.value = false; | ||||
| 				}); | ||||
| 			} else { | ||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | ||||
| 				items.value = items.value.concat(res); | ||||
| 				more.value = true; | ||||
| 				moreFetching.value = false; | ||||
| 			} | ||||
| 		} else { | ||||
| 			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 { | ||||
| const prepend = (item: MisskeyEntity): void => { | ||||
| 	// 初回表示時はunshiftだけでOK | ||||
| 		if (!rootEl.value) { | ||||
| 	if (!rootEl) { | ||||
| 		items.value.unshift(item); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); | ||||
| 	const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); | ||||
| 
 | ||||
| 		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 = []; | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| 	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); | ||||
| 
 | ||||
| 				// scrollToBottomでmoreFetchingボタンが画面外まで出るまで | ||||
| 				// more = trueを遅らせる | ||||
| 				setTimeout(() => { | ||||
| 					moreFetching.value = false; | ||||
| 				}, 2000); | ||||
| 			}); | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
| 
 | ||||
| onBeforeUnmount(() => { | ||||
| 	scrollObserver.disconnect(); | ||||
| }); | ||||
| 
 | ||||
| defineExpose({ | ||||
| 	items, | ||||
| 	queue, | ||||
| 	backed, | ||||
| 	more, | ||||
| 	inited, | ||||
| 	reload, | ||||
| 	prepend, | ||||
| 	append, | ||||
| 	append: appendItem, | ||||
| 	removeItem, | ||||
| 	updateItem, | ||||
| }); | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -1,11 +1,10 @@ | |||
| <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> | ||||
|  | @ -14,7 +13,6 @@ | |||
| 					<div>{{ i18n.ts.noMessagesYet }}</div> | ||||
| 				</div> | ||||
| 			</template> | ||||
| 
 | ||||
| 			<template #default="{ items: messages, fetching: pFetching }"> | ||||
| 				<MkDateSeparatedList | ||||
| 					v-if="messages.length > 0" | ||||
|  | @ -46,7 +44,6 @@ | |||
| 		<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> | ||||
| 	</footer> | ||||
| </div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
|  | @ -140,7 +137,9 @@ async function fetch() { | |||
| 	document.addEventListener('visibilitychange', onVisibilitychange); | ||||
| 
 | ||||
| 	nextTick(() => { | ||||
| 		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; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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の相対位置を取得できればより正確になる
 | ||||
| 
 | ||||
| 	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; | ||||
| export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) { | ||||
| 	// とりあえず評価してみる
 | ||||
| 	if (isTopVisible(el)) { | ||||
| 		cb(); | ||||
| 			container.removeEventListener('scroll', onScroll); | ||||
| 		} | ||||
| 	}; | ||||
| 	container.addEventListener('scroll', onScroll, { passive: true }); | ||||
| 		if (once) return null; | ||||
| 	} | ||||
| 
 | ||||
| export function onScrollBottom(el: Element, cb) { | ||||
| 	const container = getScrollContainer(el) || window; | ||||
| 
 | ||||
| 	const onScroll = ev => { | ||||
| 		if (!document.body.contains(el)) return; | ||||
| 		const pos = getScrollPosition(el); | ||||
| 		if (pos + el.clientHeight > el.scrollHeight - 1) { | ||||
| 		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 scroll(el: Element, options: { | ||||
| 	top?: number; | ||||
| 	left?: number; | ||||
| 	behavior?: ScrollBehavior; | ||||
| }) { | ||||
| 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; | ||||
| 		if (isBottomVisible(el, 1, container)) { | ||||
| 			cb(); | ||||
| 			if (once) removeListener(); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	function removeListener() { | ||||
| 		containerOrWindow.removeEventListener('scroll', onScroll); | ||||
| 	} | ||||
| 	containerOrWindow.addEventListener('scroll', onScroll, { passive: true }); | ||||
| 	return removeListener; | ||||
| } | ||||
| 
 | ||||
| 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 | ||||
| 	); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										6
									
								
								packages/frontend/src/types/date-separated-list.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								packages/frontend/src/types/date-separated-list.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| export type MisskeyEntity = { | ||||
| 	id: string; | ||||
| 	createdAt: string; | ||||
| 	_shouldInsertAd_?: boolean; | ||||
| 	[x: string]: any; | ||||
| }; | ||||
|  | @ -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, | ||||
| 	}, | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue