diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2023-01-13 18:25:40 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-01-13 18:25:40 +0900 |
| commit | d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b (patch) | |
| tree | 3479f9cde6139af5cebf94cf6de005e16c404e21 | |
| parent | Update CHANGELOG.md (diff) | |
| download | sharkey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.tar.gz sharkey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.tar.bz2 sharkey-d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b.zip | |
refactor: pagination/date-separated-list系処理を良い感じに? (#8209)
* pages/messaging/messaging-room.vue
* wip
* wip
* wip???
* wip?
* :v:
* messaaging-room.form.vue rewrite to compositon api
* refactor
* 関心事でないのでとりあえず置いておく
* :art:
* :art:
* i18n.ts
* fix scroll container find function
* fix
* FIX
* :v:
* Fix scroll bottom detect
* wip
* aaaaaaaaaaa
* rename
* fix
* fix?
* :v:
* :v:
* clean up
* clena up
* refactor
* scroll event once or not
* fix
* fix once
* add safe-area-inset-bottom to spacer
* fix
* :v:
* :art:
* fix
* fix
* wip
* :v:
* 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, :art:
* revert merge
* :v:
* fix lint errors
* :art:
* 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
* :art:
* :art:
* :art:
* wip
* wip
* css modules
Co-authored-by: Johann150 <johann.galle@protonmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
| -rw-r--r-- | packages/frontend/src/components/MkDateSeparatedList.vue | 188 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotes.vue | 11 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPagination.vue | 286 | ||||
| -rw-r--r-- | packages/frontend/src/pages/messaging/index.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/messaging/messaging-room.form.vue | 3 | ||||
| -rw-r--r-- | packages/frontend/src/pages/messaging/messaging-room.vue | 91 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/scroll.ts | 120 | ||||
| -rw-r--r-- | packages/frontend/src/types/date-separated-list.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/ui/visitor/a.vue | 4 |
9 files changed, 445 insertions, 266 deletions
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 5d8e14c3c8..cb88444d34 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -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,100 +95,138 @@ 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; - > *:empty { - display: none; + &:global { + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); } - > *:not(:last-child) { - margin-bottom: var(--margin); + &.deny-move-transition > .list-move { + transition: none !important; } - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + > .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-enter-active { + > .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; } - &[data-direction="up"] { - > .list-enter-from { - opacity: 0; - transform: translateY(64px); - } + > *:empty { + display: none; } - &[data-direction="down"] { - > .list-enter-from { - opacity: 0; - transform: translateY(-64px); + > *:not(:last-child) { + margin-bottom: var(--margin); + } + } +} + +.date-separated-list-nogap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); } } +} - > .separator { - text-align: center; +.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); + } + } +} - > .date { - display: inline-block; - position: relative; - margin: 0; - padding: 0 16px; - line-height: 32px; - text-align: center; - font-size: 12px; - color: var(--dateLabelFg); +.reversed { + display: flex; + flex-direction: column-reverse; +} - > span { - &:first-child { - margin-right: 8px; +.separator { + text-align: center; +} - > .icon { - margin-right: 8px; - } - } +.date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); +} - &:last-child { - margin-left: 8px; +.date-1 { + margin-right: 8px; +} - > .icon { - margin-left: 8px; - } - } - } - } - } +.date-1-icon { + margin-right: 8px; +} - &.noGap { - > * { - margin: 0 !important; - border: none; - border-radius: 0; - box-shadow: none; +.date-2 { + margin-left: 8px; +} - &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); - } - } - } +.date-2-icon { + margin-left: 8px; } </style> + diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 73659fd501..f9952e4245 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -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> diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index b92e6d2360..a78e7e064a 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -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>(); + +// 遡り中かどうか +let backed = $ref(false); + +let scrollRemove = $ref<(() => void) | null>(null); -const rootEl = shallowRef<HTMLElement>(); -const items = ref<Item[]>([]); -const queue = ref<Item[]>([]); +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 { - // 初回表示時はunshiftだけでOK - if (!rootEl.value) { - items.value.unshift(item); - return; - } +const prepend = (item: MisskeyEntity): void => { + // 初回表示時はunshiftだけでOK + 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 (isTop) unshiftItems([item]); + else prependQueue(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 = []; - }); - } +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 append = (item: Item): void => { +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, }); diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue index e751754503..4801348bdb 100644 --- a/packages/frontend/src/pages/messaging/index.vue +++ b/packages/frontend/src/pages/messaging/index.vue @@ -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" diff --git a/packages/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue index e880129033..6a49d0e89d 100644 --- a/packages/frontend/src/pages/messaging/messaging-room.form.vue +++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue @@ -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 { diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue index 01b227a672..f1749d449d 100644 --- a/packages/frontend/src/pages/messaging/messaging-room.vue +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -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> + <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> - </MkPagination> + </I18n> + <MkEllipsis/> </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/> + <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 :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> + </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; } } } diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts index f5bc6bf9ce..e3d9dc00c2 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend/src/scripts/scroll.ts @@ -10,53 +10,67 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { } } -export function getScrollPosition(el: Element | null): number { - const container = getScrollContainer(el); - return container == null ? window.scrollY : container.scrollTop; +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 isTopVisible(el: Element | null): boolean { - const scrollTop = getScrollPosition(el); - const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる - - return scrollTop <= topPosition; +export function getScrollPosition(el: HTMLElement | null): number { + const container = getScrollContainer(el); + return container == null ? window.scrollY : container.scrollTop; } -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: HTMLElement, cb: () => unknown, tolerance: number = 1, once: boolean = false) { + // とりあえず評価してみる + if (isTopVisible(el)) { + cb(); + if (once) return null; + } -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 + ); } diff --git a/packages/frontend/src/types/date-separated-list.ts b/packages/frontend/src/types/date-separated-list.ts new file mode 100644 index 0000000000..81bd87fc8e --- /dev/null +++ b/packages/frontend/src/types/date-separated-list.ts @@ -0,0 +1,6 @@ +export type MisskeyEntity = { + id: string; + createdAt: string; + _shouldInsertAd_?: boolean; + [x: string]: any; +}; diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue index 67c4e122b4..9494b1b705 100644 --- a/packages/frontend/src/ui/visitor/a.vue +++ b/packages/frontend/src/ui/visitor/a.vue @@ -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, }, |