wip
This commit is contained in:
parent
f0703a97f1
commit
ba2029efb6
|
@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
|
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
|
||||||
mode="out-in"
|
mode="out-in"
|
||||||
>
|
>
|
||||||
<MkLoading v-if="fetching"/>
|
<MkLoading v-if="paginator.fetching.value"/>
|
||||||
|
|
||||||
<MkError v-else-if="error" @retry="init()"/>
|
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
|
||||||
|
|
||||||
<div v-else-if="empty" key="_empty_">
|
<div v-else-if="paginator.items.value.size === 0" key="_empty_">
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
<div class="_fullinfo">
|
<div class="_fullinfo">
|
||||||
<img :src="infoImageUrl" draggable="false"/>
|
<img :src="infoImageUrl" draggable="false"/>
|
||||||
|
@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else ref="rootEl" class="_gaps">
|
<div v-else ref="rootEl" class="_gaps">
|
||||||
<div v-show="pagination.reversed && more" key="_more_">
|
<div v-show="pagination.reversed && paginator.canFetchMore.value" key="_more_">
|
||||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
|
<MkButton v-if="!paginator.moreFetching.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.moreFetching.value" primary rounded @click="paginator.fetchMoreAhead">
|
||||||
{{ i18n.ts.loadMore }}
|
{{ i18n.ts.loadMore }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
<MkLoading v-else/>
|
<MkLoading v-else/>
|
||||||
</div>
|
</div>
|
||||||
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
|
<slot :items="Array.from(paginator.items.value.values())" :fetching="paginator.fetching.value || paginator.moreFetching.value"></slot>
|
||||||
<div v-show="!pagination.reversed && more" key="_more_">
|
<div v-show="!pagination.reversed && paginator.canFetchMore.value" key="_more_">
|
||||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
|
<MkButton v-if="!paginator.moreFetching.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.moreFetching.value" primary rounded @click="paginator.fetchMore">
|
||||||
{{ i18n.ts.loadMore }}
|
{{ i18n.ts.loadMore }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
<MkLoading v-else/>
|
<MkLoading v-else/>
|
||||||
|
@ -42,451 +42,36 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
|
|
||||||
import * as Misskey from 'misskey-js';
|
|
||||||
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
|
|
||||||
import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js';
|
|
||||||
import type { ComputedRef } from 'vue';
|
|
||||||
import type { MisskeyEntity } from '@/types/date-separated-list.js';
|
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
import { prefer } from '@/preferences.js';
|
|
||||||
|
|
||||||
const SECOND_FETCH_LIMIT = 30;
|
|
||||||
const TOLERANCE = 16;
|
|
||||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
|
||||||
|
|
||||||
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
|
||||||
endpoint: E;
|
|
||||||
limit: number;
|
|
||||||
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 検索APIのような、ページング不可なエンドポイントを利用する場合
|
|
||||||
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
|
|
||||||
*/
|
|
||||||
noPaging?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* items 配列の中身を逆順にする(新しい方が最後)
|
|
||||||
*/
|
|
||||||
reversed?: boolean;
|
|
||||||
|
|
||||||
offsetMode?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MisskeyEntityMap = Map<string, MisskeyEntity>;
|
|
||||||
|
|
||||||
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
|
||||||
return entities.map(en => [en.id, en]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
|
||||||
return new Map([...map, ...arrayToEntries(entities)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { PagingCtx } from '@/use/use-pagination.js';
|
||||||
import { infoImageUrl } from '@/instance.js';
|
import { infoImageUrl } from '@/instance.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { usePagination } from '@/use/use-pagination.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
pagination: Paging;
|
pagination: PagingCtx;
|
||||||
disableAutoLoad?: boolean;
|
disableAutoLoad?: boolean;
|
||||||
displayLimit?: number;
|
displayLimit?: number;
|
||||||
}>(), {
|
}>(), {
|
||||||
displayLimit: 20,
|
displayLimit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const paginator = usePagination({
|
||||||
(ev: 'queue', count: number): void;
|
ctx: props.pagination,
|
||||||
(ev: 'status', error: boolean): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const rootEl = useTemplateRef('rootEl');
|
|
||||||
|
|
||||||
// 遡り中かどうか
|
|
||||||
const backed = ref(false);
|
|
||||||
|
|
||||||
const scrollRemove = ref<(() => void) | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表示するアイテムのソース
|
|
||||||
* 最新が0番目
|
|
||||||
*/
|
|
||||||
const items = ref<MisskeyEntityMap>(new Map());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* タブが非アクティブなどの場合に更新を貯めておく
|
|
||||||
* 最新が0番目
|
|
||||||
*/
|
|
||||||
const queue = ref<MisskeyEntityMap>(new Map());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
|
||||||
*/
|
|
||||||
const fetching = ref(true);
|
|
||||||
|
|
||||||
const moreFetching = ref(false);
|
|
||||||
const more = ref(false);
|
|
||||||
const preventAppearFetchMore = ref(false);
|
|
||||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
|
||||||
const isBackTop = ref(false);
|
|
||||||
const empty = computed(() => items.value.size === 0);
|
|
||||||
const error = ref(false);
|
|
||||||
const {
|
|
||||||
enableInfiniteScroll,
|
|
||||||
} = prefer.r;
|
|
||||||
|
|
||||||
const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
|
|
||||||
|
|
||||||
const visibility = useDocumentVisibility();
|
|
||||||
|
|
||||||
let isPausingUpdate = false;
|
|
||||||
let timerForSetPause: number | null = null;
|
|
||||||
const BACKGROUND_PAUSE_WAIT_SEC = 10;
|
|
||||||
|
|
||||||
// 先頭が表示されているかどうかを検出
|
|
||||||
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
|
||||||
const scrollObserver = ref<IntersectionObserver>();
|
|
||||||
|
|
||||||
watch([() => props.pagination.reversed, scrollableElement], () => {
|
|
||||||
if (scrollObserver.value) scrollObserver.value.disconnect();
|
|
||||||
|
|
||||||
scrollObserver.value = new IntersectionObserver(entries => {
|
|
||||||
backed.value = entries[0].isIntersecting;
|
|
||||||
}, {
|
|
||||||
root: scrollableElement.value,
|
|
||||||
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
|
|
||||||
threshold: 0.01,
|
|
||||||
});
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
watch(rootEl, () => {
|
|
||||||
scrollObserver.value?.disconnect();
|
|
||||||
nextTick(() => {
|
|
||||||
if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([backed, rootEl], () => {
|
function appearFetchMoreAhead() {
|
||||||
if (!backed.value) {
|
paginator.fetchMoreAhead();
|
||||||
if (!rootEl.value) return;
|
|
||||||
|
|
||||||
scrollRemove.value = props.pagination.reversed
|
|
||||||
? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
|
|
||||||
: onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
|
|
||||||
} else {
|
|
||||||
if (scrollRemove.value) scrollRemove.value();
|
|
||||||
scrollRemove.value = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
|
||||||
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
|
|
||||||
|
|
||||||
watch(queue, (a, b) => {
|
|
||||||
if (a.size === 0 && b.size === 0) return;
|
|
||||||
emit('queue', queue.value.size);
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
watch(error, (n, o) => {
|
|
||||||
if (n === o) return;
|
|
||||||
emit('status', n);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
|
||||||
items.value = new Map();
|
|
||||||
queue.value = new Map();
|
|
||||||
fetching.value = true;
|
|
||||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
|
||||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
|
||||||
...params,
|
|
||||||
limit: props.pagination.limit ?? 10,
|
|
||||||
allowPartial: true,
|
|
||||||
}).then(res => {
|
|
||||||
for (let i = 0; i < res.length; i++) {
|
|
||||||
const item = res[i];
|
|
||||||
if (i === 3) item._shouldInsertAd_ = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.length === 0 || props.pagination.noPaging) {
|
|
||||||
concatItems(res);
|
|
||||||
more.value = false;
|
|
||||||
} else {
|
|
||||||
if (props.pagination.reversed) moreFetching.value = true;
|
|
||||||
concatItems(res);
|
|
||||||
more.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = false;
|
|
||||||
fetching.value = false;
|
|
||||||
}, err => {
|
|
||||||
error.value = true;
|
|
||||||
fetching.value = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reload = (): Promise<void> => {
|
function appearFetchMore() {
|
||||||
return init();
|
paginator.fetchMore();
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMore = async (): Promise<void> => {
|
|
||||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
|
||||||
moreFetching.value = true;
|
|
||||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
|
||||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
|
||||||
...params,
|
|
||||||
limit: SECOND_FETCH_LIMIT,
|
|
||||||
...(props.pagination.offsetMode ? {
|
|
||||||
offset: items.value.size,
|
|
||||||
} : {
|
|
||||||
untilId: Array.from(items.value.keys()).at(-1),
|
|
||||||
}),
|
|
||||||
}).then(res => {
|
|
||||||
for (let i = 0; i < res.length; i++) {
|
|
||||||
const item = res[i];
|
|
||||||
if (i === 10) item._shouldInsertAd_ = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reverseConcat = _res => {
|
|
||||||
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
|
|
||||||
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
|
|
||||||
|
|
||||||
items.value = concatMapWithArray(items.value, _res);
|
|
||||||
|
|
||||||
return nextTick(() => {
|
|
||||||
if (scrollableElement.value) {
|
|
||||||
scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
|
|
||||||
} else {
|
|
||||||
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextTick();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (res.length === 0) {
|
|
||||||
if (props.pagination.reversed) {
|
|
||||||
reverseConcat(res).then(() => {
|
|
||||||
more.value = false;
|
|
||||||
moreFetching.value = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.value = concatMapWithArray(items.value, res);
|
|
||||||
more.value = false;
|
|
||||||
moreFetching.value = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (props.pagination.reversed) {
|
|
||||||
reverseConcat(res).then(() => {
|
|
||||||
more.value = true;
|
|
||||||
moreFetching.value = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.value = concatMapWithArray(items.value, res);
|
|
||||||
more.value = true;
|
|
||||||
moreFetching.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, err => {
|
|
||||||
moreFetching.value = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMoreAhead = async (): Promise<void> => {
|
|
||||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
|
||||||
moreFetching.value = true;
|
|
||||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
|
||||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
|
||||||
...params,
|
|
||||||
limit: SECOND_FETCH_LIMIT,
|
|
||||||
...(props.pagination.offsetMode ? {
|
|
||||||
offset: items.value.size,
|
|
||||||
} : {
|
|
||||||
sinceId: Array.from(items.value.keys()).at(-1),
|
|
||||||
}),
|
|
||||||
}).then(res => {
|
|
||||||
if (res.length === 0) {
|
|
||||||
items.value = concatMapWithArray(items.value, res);
|
|
||||||
more.value = false;
|
|
||||||
} else {
|
|
||||||
items.value = concatMapWithArray(items.value, res);
|
|
||||||
more.value = true;
|
|
||||||
}
|
|
||||||
moreFetching.value = false;
|
|
||||||
}, err => {
|
|
||||||
moreFetching.value = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
|
|
||||||
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
|
|
||||||
*/
|
|
||||||
const fetchMoreApperTimeoutFn = (): void => {
|
|
||||||
preventAppearFetchMore.value = false;
|
|
||||||
preventAppearFetchMoreTimer.value = null;
|
|
||||||
};
|
|
||||||
const fetchMoreAppearTimeout = (): void => {
|
|
||||||
preventAppearFetchMore.value = true;
|
|
||||||
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appearFetchMore = async (): Promise<void> => {
|
|
||||||
if (preventAppearFetchMore.value) return;
|
|
||||||
await fetchMore();
|
|
||||||
fetchMoreAppearTimeout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const appearFetchMoreAhead = async (): Promise<void> => {
|
|
||||||
if (preventAppearFetchMore.value) return;
|
|
||||||
await fetchMoreAhead();
|
|
||||||
fetchMoreAppearTimeout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
|
|
||||||
|
|
||||||
watch(visibility, () => {
|
|
||||||
if (visibility.value === 'hidden') {
|
|
||||||
timerForSetPause = window.setTimeout(() => {
|
|
||||||
isPausingUpdate = true;
|
|
||||||
timerForSetPause = null;
|
|
||||||
},
|
|
||||||
BACKGROUND_PAUSE_WAIT_SEC * 1000);
|
|
||||||
} else { // 'visible'
|
|
||||||
if (timerForSetPause) {
|
|
||||||
window.clearTimeout(timerForSetPause);
|
|
||||||
timerForSetPause = null;
|
|
||||||
} else {
|
|
||||||
isPausingUpdate = false;
|
|
||||||
if (isHead()) {
|
|
||||||
executeQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最新のものとして1つだけアイテムを追加する
|
|
||||||
* ストリーミングから降ってきたアイテムはこれで追加する
|
|
||||||
* @param item アイテム
|
|
||||||
*/
|
|
||||||
function prepend(item: MisskeyEntity): void {
|
|
||||||
if (items.value.size === 0) {
|
|
||||||
items.value.set(item.id, item);
|
|
||||||
fetching.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_DEV_) console.log(isHead(), isPausingUpdate);
|
|
||||||
|
|
||||||
if (isHead() && !isPausingUpdate) unshiftItems([item]);
|
|
||||||
else prependQueue(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
|
||||||
* @param newItems 新しいアイテムの配列
|
|
||||||
*/
|
|
||||||
function unshiftItems(newItems: MisskeyEntity[]) {
|
|
||||||
const length = newItems.length + items.value.size;
|
|
||||||
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
|
|
||||||
|
|
||||||
if (length >= props.displayLimit) more.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
|
|
||||||
* @param oldItems 古いアイテムの配列
|
|
||||||
*/
|
|
||||||
function concatItems(oldItems: MisskeyEntity[]) {
|
|
||||||
const length = oldItems.length + items.value.size;
|
|
||||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
|
|
||||||
|
|
||||||
if (length >= props.displayLimit) more.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeQueue() {
|
|
||||||
unshiftItems(Array.from(queue.value.values()));
|
|
||||||
queue.value = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
function prependQueue(newItem: MisskeyEntity) {
|
|
||||||
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* アイテムを末尾に追加する(使うの?)
|
|
||||||
*/
|
|
||||||
const appendItem = (item: MisskeyEntity): void => {
|
|
||||||
items.value.set(item.id, item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = (id: string) => {
|
|
||||||
items.value.delete(id);
|
|
||||||
queue.value.delete(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
|
|
||||||
const item = items.value.get(id);
|
|
||||||
if (item) items.value.set(id, replacer(item));
|
|
||||||
|
|
||||||
const queueItem = queue.value.get(id);
|
|
||||||
if (queueItem) queue.value.set(id, replacer(queueItem));
|
|
||||||
};
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
isBackTop.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDeactivated(() => {
|
|
||||||
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
function toBottom() {
|
|
||||||
scrollToBottom(rootEl.value!);
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
|
||||||
init().then(() => {
|
|
||||||
if (props.pagination.reversed) {
|
|
||||||
nextTick(() => {
|
|
||||||
window.setTimeout(toBottom, 800);
|
|
||||||
|
|
||||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
|
||||||
// more = trueを遅らせる
|
|
||||||
window.setTimeout(() => {
|
|
||||||
moreFetching.value = false;
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (timerForSetPause) {
|
|
||||||
window.clearTimeout(timerForSetPause);
|
|
||||||
timerForSetPause = null;
|
|
||||||
}
|
|
||||||
if (preventAppearFetchMoreTimer.value) {
|
|
||||||
window.clearTimeout(preventAppearFetchMoreTimer.value);
|
|
||||||
preventAppearFetchMoreTimer.value = null;
|
|
||||||
}
|
|
||||||
scrollObserver.value?.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
items,
|
paginator: paginator,
|
||||||
queue,
|
|
||||||
backed: backed.value,
|
|
||||||
more,
|
|
||||||
reload,
|
|
||||||
prepend,
|
|
||||||
append: appendItem,
|
|
||||||
removeItem,
|
|
||||||
updateItem,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -4,38 +4,48 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()">
|
<MkPullToRefresh :refresher="() => reloadTimeline()">
|
||||||
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)">
|
<MkLoading v-if="paginator.fetching.value"/>
|
||||||
<template #empty>
|
|
||||||
|
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
|
||||||
|
|
||||||
|
<div v-else-if="paginator.items.value.size === 0" key="_empty_">
|
||||||
|
<slot name="empty">
|
||||||
<div class="_fullinfo">
|
<div class="_fullinfo">
|
||||||
<img :src="infoImageUrl" draggable="false"/>
|
<img :src="infoImageUrl" draggable="false"/>
|
||||||
<div>{{ i18n.ts.noNotes }}</div>
|
<div>{{ i18n.ts.noNotes }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #default="{ items: notes }">
|
<div v-else ref="rootEl">
|
||||||
<component
|
<component
|
||||||
:is="prefer.s.animation ? TransitionGroup : 'div'"
|
:is="prefer.s.animation ? TransitionGroup : 'div'"
|
||||||
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]"
|
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]"
|
||||||
:enterActiveClass="$style.transition_x_enterActive"
|
:enterActiveClass="$style.transition_x_enterActive"
|
||||||
:leaveActiveClass="$style.transition_x_leaveActive"
|
:leaveActiveClass="$style.transition_x_leaveActive"
|
||||||
:enterFromClass="$style.transition_x_enterFrom"
|
:enterFromClass="$style.transition_x_enterFrom"
|
||||||
:leaveToClass="$style.transition_x_leaveTo"
|
:leaveToClass="$style.transition_x_leaveTo"
|
||||||
:moveClass=" $style.transition_x_move"
|
:moveClass=" $style.transition_x_move"
|
||||||
tag="div"
|
tag="div"
|
||||||
>
|
>
|
||||||
<template v-for="(note, i) in notes" :key="note.id">
|
<template v-for="(note, i) in paginator.items.value" :key="note.id">
|
||||||
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
|
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
|
||||||
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
|
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
|
||||||
<div :class="$style.ad">
|
<div :class="$style.ad">
|
||||||
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
|
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
</div>
|
||||||
</template>
|
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
||||||
</component>
|
</template>
|
||||||
</template>
|
</component>
|
||||||
</MkPagination>
|
<div v-show="paginator.canFetchMore.value" key="_more_">
|
||||||
|
<MkButton v-if="!paginator.moreFetching.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.moreFetching.value" primary rounded @click="paginator.fetchMore">
|
||||||
|
{{ i18n.ts.loadMore }}
|
||||||
|
</MkButton>
|
||||||
|
<MkLoading v-else/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</MkPullToRefresh>
|
</MkPullToRefresh>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -44,7 +54,8 @@ import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
import type { BasicTimelineType } from '@/timelines.js';
|
import type { BasicTimelineType } from '@/timelines.js';
|
||||||
import type { Paging } from '@/components/MkPagination.vue';
|
import type { PagingCtx } from '@/use/use-pagination.js';
|
||||||
|
import { usePagination } from '@/use/use-pagination.js';
|
||||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
import * as sound from '@/utility/sound.js';
|
import * as sound from '@/utility/sound.js';
|
||||||
|
@ -53,9 +64,9 @@ import { instance } from '@/instance.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
import MkNote from '@/components/MkNote.vue';
|
import MkNote from '@/components/MkNote.vue';
|
||||||
import MkPagination from '@/components/MkPagination.vue';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { infoImageUrl } from '@/instance.js';
|
import { infoImageUrl } from '@/instance.js';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
|
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
|
||||||
|
@ -94,15 +105,17 @@ type TimelineQueryType = {
|
||||||
roleId?: string
|
roleId?: string
|
||||||
};
|
};
|
||||||
|
|
||||||
const prComponent = useTemplateRef('prComponent');
|
|
||||||
const pagingComponent = useTemplateRef('pagingComponent');
|
|
||||||
|
|
||||||
let tlNotesCount = 0;
|
let tlNotesCount = 0;
|
||||||
|
|
||||||
const POLLING_INTERVAL = 1000 * 10;
|
const POLLING_INTERVAL = 1000 * 10;
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(async () => {
|
||||||
// TODO
|
const notes = await misskeyApi(paginationQuery.endpoint, {
|
||||||
|
...paginationQuery.params,
|
||||||
|
limit: 10,
|
||||||
|
sinceId: Array.from(paginator.items.value.keys()).at(-1),
|
||||||
|
});
|
||||||
|
console.log(notes);
|
||||||
}, POLLING_INTERVAL, {
|
}, POLLING_INTERVAL, {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
afterMounted: true,
|
afterMounted: true,
|
||||||
|
@ -126,7 +139,7 @@ function prepend(note) {
|
||||||
|
|
||||||
let connection: Misskey.ChannelConnection | null = null;
|
let connection: Misskey.ChannelConnection | null = null;
|
||||||
let connection2: Misskey.ChannelConnection | null = null;
|
let connection2: Misskey.ChannelConnection | null = null;
|
||||||
let paginationQuery: Paging | null = null;
|
let paginationQuery: PagingCtx;
|
||||||
const noGap = !prefer.s.showGapBetweenNotesInTimeline;
|
const noGap = !prefer.s.showGapBetweenNotesInTimeline;
|
||||||
|
|
||||||
const stream = store.s.realtimeMode ? useStream() : null;
|
const stream = store.s.realtimeMode ? useStream() : null;
|
||||||
|
@ -258,19 +271,14 @@ function updatePaginationQuery() {
|
||||||
roleId: props.role,
|
roleId: props.role,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
endpoint = null;
|
throw new Error('Unrecognized timeline type: ' + props.src);
|
||||||
query = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpoint && query) {
|
paginationQuery = {
|
||||||
paginationQuery = {
|
endpoint: endpoint,
|
||||||
endpoint: endpoint,
|
limit: 10,
|
||||||
limit: 10,
|
params: query,
|
||||||
params: query,
|
};
|
||||||
};
|
|
||||||
} else {
|
|
||||||
paginationQuery = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshEndpointAndChannel() {
|
function refreshEndpointAndChannel() {
|
||||||
|
@ -292,17 +300,19 @@ watch(() => props.withSensitive, reloadTimeline);
|
||||||
// 初回表示用
|
// 初回表示用
|
||||||
refreshEndpointAndChannel();
|
refreshEndpointAndChannel();
|
||||||
|
|
||||||
|
const paginator = usePagination({
|
||||||
|
ctx: paginationQuery,
|
||||||
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disconnectChannel();
|
disconnectChannel();
|
||||||
});
|
});
|
||||||
|
|
||||||
function reloadTimeline() {
|
function reloadTimeline() {
|
||||||
return new Promise<void>((res) => {
|
return new Promise<void>((res) => {
|
||||||
if (pagingComponent.value == null) return;
|
|
||||||
|
|
||||||
tlNotesCount = 0;
|
tlNotesCount = 0;
|
||||||
|
|
||||||
pagingComponent.value.reload().then(() => {
|
paginator.reload().then(() => {
|
||||||
res();
|
res();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type MisskeyEntity = {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
_shouldInsertAd_?: boolean;
|
|
||||||
[x: string]: any;
|
|
||||||
};
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
|
||||||
|
const MAX_ITEMS = 20;
|
||||||
|
const FIRST_FETCH_LIMIT = 15;
|
||||||
|
const SECOND_FETCH_LIMIT = 30;
|
||||||
|
|
||||||
|
export type MisskeyEntity = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
_shouldInsertAd_?: boolean;
|
||||||
|
[x: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagingCtx<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
||||||
|
endpoint: E;
|
||||||
|
limit?: number;
|
||||||
|
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 検索APIのような、ページング不可なエンドポイントを利用する場合
|
||||||
|
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
|
||||||
|
*/
|
||||||
|
noPaging?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* items 配列の中身を逆順にする(新しい方が最後)
|
||||||
|
*/
|
||||||
|
reversed?: boolean;
|
||||||
|
|
||||||
|
offsetMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MisskeyEntityMap = Map<string, MisskeyEntity>;
|
||||||
|
|
||||||
|
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
||||||
|
return entities.map(en => [en.id, en]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||||
|
return new Map([...map, ...arrayToEntries(entities)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagination<T>(props: {
|
||||||
|
ctx: PagingCtx;
|
||||||
|
}) {
|
||||||
|
/**
|
||||||
|
* 表示するアイテムのソース
|
||||||
|
* 最新が0番目
|
||||||
|
*/
|
||||||
|
const items = ref<MisskeyEntityMap>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
||||||
|
*/
|
||||||
|
const fetching = ref(true);
|
||||||
|
|
||||||
|
const moreFetching = ref(false);
|
||||||
|
const canFetchMore = ref(false);
|
||||||
|
const error = ref(false);
|
||||||
|
|
||||||
|
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
||||||
|
watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true });
|
||||||
|
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
items.value = new Map();
|
||||||
|
fetching.value = true;
|
||||||
|
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
|
||||||
|
await misskeyApi<MisskeyEntity[]>(props.ctx.endpoint, {
|
||||||
|
...params,
|
||||||
|
limit: props.ctx.limit ?? FIRST_FETCH_LIMIT,
|
||||||
|
allowPartial: true,
|
||||||
|
}).then(res => {
|
||||||
|
for (let i = 0; i < res.length; i++) {
|
||||||
|
const item = res[i];
|
||||||
|
if (i === 3) item._shouldInsertAd_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.length === 0 || props.ctx.noPaging) {
|
||||||
|
concatItems(res);
|
||||||
|
canFetchMore.value = false;
|
||||||
|
} else {
|
||||||
|
if (props.ctx.reversed) moreFetching.value = true;
|
||||||
|
concatItems(res);
|
||||||
|
canFetchMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = false;
|
||||||
|
fetching.value = false;
|
||||||
|
}, err => {
|
||||||
|
error.value = true;
|
||||||
|
fetching.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reload = (): Promise<void> => {
|
||||||
|
return init();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMore = async (): Promise<void> => {
|
||||||
|
if (!canFetchMore.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||||
|
moreFetching.value = true;
|
||||||
|
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
|
||||||
|
await misskeyApi<MisskeyEntity[]>(props.ctx.endpoint, {
|
||||||
|
...params,
|
||||||
|
limit: SECOND_FETCH_LIMIT,
|
||||||
|
...(props.ctx.offsetMode ? {
|
||||||
|
offset: items.value.size,
|
||||||
|
} : {
|
||||||
|
untilId: Array.from(items.value.keys()).at(-1),
|
||||||
|
}),
|
||||||
|
}).then(res => {
|
||||||
|
for (let i = 0; i < res.length; i++) {
|
||||||
|
const item = res[i];
|
||||||
|
if (i === 10) item._shouldInsertAd_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.length === 0) {
|
||||||
|
items.value = concatMapWithArray(items.value, res);
|
||||||
|
canFetchMore.value = false;
|
||||||
|
moreFetching.value = false;
|
||||||
|
} else {
|
||||||
|
items.value = concatMapWithArray(items.value, res);
|
||||||
|
canFetchMore.value = true;
|
||||||
|
moreFetching.value = false;
|
||||||
|
}
|
||||||
|
}, err => {
|
||||||
|
moreFetching.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMoreAhead = async (): Promise<void> => {
|
||||||
|
if (!canFetchMore.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||||
|
moreFetching.value = true;
|
||||||
|
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
|
||||||
|
await misskeyApi<MisskeyEntity[]>(props.ctx.endpoint, {
|
||||||
|
...params,
|
||||||
|
limit: SECOND_FETCH_LIMIT,
|
||||||
|
...(props.ctx.offsetMode ? {
|
||||||
|
offset: items.value.size,
|
||||||
|
} : {
|
||||||
|
sinceId: Array.from(items.value.keys()).at(-1),
|
||||||
|
}),
|
||||||
|
}).then(res => {
|
||||||
|
if (res.length === 0) {
|
||||||
|
items.value = concatMapWithArray(items.value, res);
|
||||||
|
canFetchMore.value = false;
|
||||||
|
} else {
|
||||||
|
items.value = concatMapWithArray(items.value, res);
|
||||||
|
canFetchMore.value = true;
|
||||||
|
}
|
||||||
|
moreFetching.value = false;
|
||||||
|
}, err => {
|
||||||
|
moreFetching.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新着アイテムをitemsの先頭に追加し、MAX_ITEMSを適用する
|
||||||
|
* @param newItems 新しいアイテムの配列
|
||||||
|
*/
|
||||||
|
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||||
|
const length = newItems.length + items.value.size;
|
||||||
|
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, MAX_ITEMS));
|
||||||
|
|
||||||
|
if (length >= MAX_ITEMS) canFetchMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 古いアイテムをitemsの末尾に追加し、MAX_ITEMSを適用する
|
||||||
|
* @param oldItems 古いアイテムの配列
|
||||||
|
*/
|
||||||
|
function concatItems(oldItems: MisskeyEntity[]) {
|
||||||
|
const length = oldItems.length + items.value.size;
|
||||||
|
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, MAX_ITEMS));
|
||||||
|
|
||||||
|
if (length >= MAX_ITEMS) canFetchMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* アイテムを末尾に追加する(使うの?)
|
||||||
|
*/
|
||||||
|
const appendItem = (item: MisskeyEntity): void => {
|
||||||
|
items.value.set(item.id, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items.value.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
|
||||||
|
const item = items.value.get(id);
|
||||||
|
if (item) items.value.set(id, replacer(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
fetching,
|
||||||
|
moreFetching,
|
||||||
|
canFetchMore,
|
||||||
|
init,
|
||||||
|
reload,
|
||||||
|
fetchMore,
|
||||||
|
fetchMoreAhead,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue