Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance(frontend): Better Timeline(MkPagination) Experience #11066

Merged
merged 8 commits into from
Jul 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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