Skip to content

Commit

Permalink
enhance(frontend): Better Timeline(MkPagination) Experience (#11066)
Browse files Browse the repository at this point in the history
* enhance(frontend): Better MkPagination Appearance

* fix

* fix

* 新規投稿が空でも先頭に戻ったらunshiftItemsする

* use Map

* refactor, 型エラー潰し

* refactor
  • Loading branch information
tamaina authored Jul 4, 2023
1 parent 526fa8b commit 92d9946
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 47 deletions.
170 changes: 125 additions & 45 deletions packages/frontend/src/components/MkPagination.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@

<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items" :fetching="fetching || moreFetching"></slot>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
Expand All @@ -50,6 +50,7 @@ import { i18n } from '@/i18n';
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;
Expand All @@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
pageEl?: HTMLElement;
};
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>
import { infoImageUrl } from '@/instance';
Expand All @@ -94,21 +105,38 @@ let backed = $ref(false);
let scrollRemove = $ref<(() => void) | null>(null);
const items = ref<MisskeyEntity[]>([]);
const queue = ref<MisskeyEntity[]>([]);
/**
* 表示するアイテムのソース
* 最新が0番目
*/
const items = ref<MisskeyEntityMap>(new Map());
/**
* タブが非アクティブなどの場合に更新を貯めておく
* 最新が0番目
*/
const queue = ref<MisskeyEntityMap>(new Map());
const offset = ref(0);
/**
* 初期化中かどうか(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.length === 0);
const empty = computed(() => items.value.size === 0);
const error = ref(false);
const {
enableInfiniteScroll,
} = defaultStore.reactiveState;
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
const scrollableElement = $computed(() => getScrollContainer(contentEl));
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
const visibility = useDocumentVisibility();
Expand All @@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
}, { immediate: true });
watch($$(rootEl), () => {
scrollObserver.disconnect();
scrollObserver?.disconnect();
nextTick(() => {
if (rootEl) scrollObserver.observe(rootEl);
if (rootEl) scrollObserver?.observe(rootEl);
});
});
Expand All @@ -155,12 +183,12 @@ if (props.pagination.params && isRef(props.pagination.params)) {
}
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
emit('queue', queue.value.length);
if (a.size === 0 && b.size === 0) return;
emit('queue', queue.value.size);
}, { deep: true });
async function init(): Promise<void> {
queue.value = [];
queue.value = new Map();
fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
Expand All @@ -173,11 +201,11 @@ async function init(): Promise<void> {
}
if (res.length === 0 || props.pagination.noPaging) {
items.value = res;
concatItems(res);
more.value = false;
} else {
if (props.pagination.reversed) moreFetching.value = true;
items.value = res;
concatItems(res);
more.value = true;
}
Expand All @@ -191,12 +219,13 @@ async function init(): Promise<void> {
}
const reload = (): Promise<void> => {
items.value = [];
items.value = new Map();
queue.value = new Map();
return init();
};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
Expand All @@ -205,7 +234,7 @@ const fetchMore = async (): Promise<void> => {
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
untilId: items.value[items.value.length - 1].id,
untilId: Array.from(items.value.keys())[items.value.size - 1],
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
Expand All @@ -217,7 +246,7 @@ const fetchMore = async (): Promise<void> => {
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
items.value = items.value.concat(_res);
items.value = concatMapWithArray(items.value, _res);
return nextTick(() => {
if (scrollableElement) {
Expand All @@ -237,7 +266,7 @@ const fetchMore = async (): Promise<void> => {
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
items.value = concatMapWithArray(items.value, res);
more.value = false;
moreFetching.value = false;
}
Expand All @@ -248,7 +277,7 @@ const fetchMore = async (): Promise<void> => {
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
items.value = concatMapWithArray(items.value, res);
more.value = true;
moreFetching.value = false;
}
Expand All @@ -260,7 +289,7 @@ const fetchMore = async (): Promise<void> => {
};
const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
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 os.api(props.pagination.endpoint, {
Expand All @@ -269,14 +298,14 @@ const fetchMoreAhead = async (): Promise<void> => {
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
sinceId: items.value[items.value.length - 1].id,
sinceId: Array.from(items.value.keys())[items.value.size - 1],
}),
}).then(res => {
if (res.length === 0) {
items.value = items.value.concat(res);
items.value = concatMapWithArray(items.value, res);
more.value = false;
} else {
items.value = items.value.concat(res);
items.value = concatMapWithArray(items.value, res);
more.value = true;
}
offset.value += res.length;
Expand All @@ -286,7 +315,32 @@ const fetchMoreAhead = async (): Promise<void> => {
});
};
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
/**
* 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 isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
Expand All @@ -308,49 +362,71 @@ watch(visibility, () => {
}
});
/**
* 最新のものとして1つだけアイテムを追加する
* ストリーミングから降ってきたアイテムはこれで追加する
* @param item アイテム
*/
const prepend = (item: MisskeyEntity): void => {
// 初回表示時はunshiftだけでOK
if (!rootEl) {
items.value.unshift(item);
if (items.value.size === 0) {
items.value.set(item.id, item);
fetching.value = false;
return;
}
if (isTop() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
};
/**
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
* @param newItems 新しいアイテムの配列
*/
function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.length;
items.value = [...newItems, ...items.value].slice(0, props.displayLimit);
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() {
if (queue.value.length === 0) return;
unshiftItems(queue.value);
queue.value = [];
unshiftItems(Array.from(queue.value.values()));
queue.value = new Map();
}
function prependQueue(newItem: MisskeyEntity) {
queue.value.unshift(newItem);
if (queue.value.length >= props.displayLimit) {
queue.value.pop();
}
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
}
/*
* アイテムを末尾に追加する(使うの?)
*/
const appendItem = (item: MisskeyEntity): void => {
items.value.push(item);
items.value.set(item.id, item);
};
const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
const i = items.value.findIndex(finder);
items.value.splice(i, 1);
const removeItem = (id: string) => {
items.value.delete(id);
queue.value.delete(id);
};
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]);
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));
};
const inited = init();
Expand All @@ -364,7 +440,7 @@ onDeactivated(() => {
});
function toBottom() {
scrollToBottom(contentEl);
scrollToBottom(contentEl!);
}
onMounted(() => {
Expand All @@ -388,7 +464,11 @@ onBeforeUnmount(() => {
clearTimeout(timerForSetPause);
timerForSetPause = null;
}
scrollObserver.disconnect();
if (preventAppearFetchMoreTimer.value) {
clearTimeout(preventAppearFetchMoreTimer.value);
preventAppearFetchMoreTimer.value = null;
}
scrollObserver?.disconnect();
});
defineExpose({
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/pages/admin/abuses.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const pagination = {
};
function resolved(reportId) {
reports.removeItem(item => item.id === reportId);
reports.removeItem(reportId);
}
const headerActions = $computed(() => []);
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/pages/custom-emojis-manager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const edit = (emoji) => {
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisPaginationComponent.value.removeItem(emoji.id);
}
},
}, 'closed');
Expand Down

1 comment on commit 92d9946

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chromatic detects changes. Please review the changes on Chromatic.

Please sign in to comment.