feat: rewrite MkPagination for fold

This commit is contained in:
Lhcfl 2024-04-26 22:39:58 +08:00
parent bfcadaa094
commit 564eb08386
6 changed files with 274 additions and 274 deletions

View file

@ -26,7 +26,6 @@
: notification.reaction : notification.reaction
" "
:custom-emojis="notification.note.emojis" :custom-emojis="notification.note.emojis"
:no-style="true"
/> />
<XReactionIcon <XReactionIcon
v-else-if=" v-else-if="
@ -73,7 +72,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Connection } from "firefish-js/src/streaming"; import type { Connection } from "firefish-js/src/streaming";
import type { Channels } from "firefish-js/src/streaming.types"; import type { Channels } from "firefish-js/src/streaming.types";
import XReactionIcon from "@/components/MkReactionIcon.vue"; import XReactionIcon from "@/components/MkReactionIcon.vue";
@ -116,8 +115,10 @@ const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReact
? instance.defaultReaction ? instance.defaultReaction
: "⭐"; : "⭐";
const users = ref(props.notification.users.slice(0, 5)); const users = computed(() => props.notification.users.slice(0, 5));
const userleft = ref(props.notification.users.length - users.value.length); const userleft = computed(
() => props.notification.users.length - users.value.length,
);
let readObserver: IntersectionObserver | undefined; let readObserver: IntersectionObserver | undefined;
let connection: Connection<Channels["main"]> | null = null; let connection: Connection<Channels["main"]> | null = null;

View file

@ -1,5 +1,9 @@
<template> <template>
<MkPagination ref="pagingComponent" :pagination="pagination"> <MkPagination
ref="pagingComponent"
:pagination="pagination"
:folder="convertNotification"
>
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img <img
@ -11,9 +15,9 @@
</div> </div>
</template> </template>
<template #default="{ items: notifications }"> <template #default="{ foldedItems: notifications }">
<XList <XList
:items="convertNotification(notifications)" :items="notifications"
v-slot="{ item: notification }" v-slot="{ item: notification }"
class="elsfgstc" class="elsfgstc"
:no-gap="true" :no-gap="true"
@ -92,7 +96,7 @@ const pagination = Object.assign(
}, },
shouldFold shouldFold
? { ? {
limit: FETCH_LIMIT, limit: 50,
secondFetchLimit: FETCH_LIMIT, secondFetchLimit: FETCH_LIMIT,
} }
: { : {
@ -134,11 +138,11 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined; let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(n: entities.Notification[]) { function convertNotification(ns: entities.Notification[]) {
if (shouldFold) { if (shouldFold) {
return foldNotifications(n, FETCH_LIMIT); return foldNotifications(ns);
} else { } else {
return n; return ns;
} }
} }

View file

@ -38,7 +38,7 @@
</MkButton> </MkButton>
<MkLoading v-else class="loading" /> <MkLoading v-else class="loading" />
</div> </div>
<slot :items="items"></slot> <slot :items="items" :foldedItems="foldedItems"></slot>
<div <div
v-show="!pagination.reversed && more" v-show="!pagination.reversed && more"
key="_more_" key="_more_"
@ -66,8 +66,8 @@
</transition> </transition>
</template> </template>
<script lang="ts" setup generic="E extends PagingKey"> <script lang="ts" setup generic="E extends PagingKey, Fold extends PagingAble">
import type { ComponentPublicInstance, ComputedRef } from "vue"; import type { ComponentPublicInstance, ComputedRef, Ref } from "vue";
import { import {
computed, computed,
isRef, isRef,
@ -79,12 +79,7 @@ import {
} from "vue"; } from "vue";
import type { Endpoints, TypeUtils } from "firefish-js"; import type { Endpoints, TypeUtils } from "firefish-js";
import * as os from "@/os"; import * as os from "@/os";
import { import { isTopVisible, onScrollTop } from "@/scripts/scroll";
getScrollContainer,
getScrollPosition,
isTopVisible,
onScrollTop,
} from "@/scripts/scroll";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
@ -105,11 +100,15 @@ export type MkPaginationType<
reload: () => Promise<void>; reload: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
prepend: (item: Item) => Promise<void>; prepend: (item: Item) => Promise<void>;
append: (item: Item) => Promise<void>; append: (...item: Item[]) => Promise<void>;
removeItem: (finder: (item: Item) => boolean) => boolean; removeItem: (finder: (item: Item) => boolean) => boolean;
updateItem: (id: string, replacer: (old: Item) => Item) => boolean; updateItem: (id: string, replacer: (old: Item) => Item) => boolean;
}; };
export type PagingAble = {
id: string;
};
export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>; export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>;
// biome-ignore lint/suspicious/noExplicitAny: Used Intentionally // biome-ignore lint/suspicious/noExplicitAny: Used Intentionally
export type PagingKey = PagingKeyOf<any>; export type PagingKey = PagingKeyOf<any>;
@ -142,13 +141,18 @@ export interface Paging<E extends PagingKey = PagingKey> {
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>; export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
type Item = Endpoints[E]["res"][number];
type Param = Endpoints[E]["req"] | Record<string, never>;
const SECOND_FETCH_LIMIT_DEFAULT = 30; const SECOND_FETCH_LIMIT_DEFAULT = 30;
const FIRST_FETCH_LIMIT_DEFAULT = 10;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
pagination: Paging<E>; pagination: Paging<E>;
disableAutoLoad?: boolean; disableAutoLoad?: boolean;
displayLimit?: number; displayLimit?: number;
folder?: (i: Item[]) => Fold[];
}>(), }>(),
{ {
displayLimit: 30, displayLimit: 30,
@ -156,7 +160,7 @@ const props = withDefaults(
); );
const slots = defineSlots<{ const slots = defineSlots<{
default(props: { items: Item[] }): unknown; default(props: { items: Item[]; foldedItems: Fold[] }): unknown;
empty(props: Record<string, never>): never; empty(props: Record<string, never>): never;
}>(); }>();
@ -165,13 +169,59 @@ const emit = defineEmits<{
(ev: "status", hasError: boolean): void; (ev: "status", hasError: boolean): void;
}>(); }>();
type Param = Endpoints[E]["req"] | Record<string, never>;
type Item = Endpoints[E]["res"][number];
const rootEl = ref<HTMLElement>(); const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const foldedItems = ref([]) as Ref<Fold[]>;
// To improve performance, we do not use vues `computed` here
function calculateItems() {
function getItems<T>(folder: (ns: Item[]) => T[]) {
const res = [
folder(prepended.value.toReversed()),
...arrItems.value.map((arr) => folder(arr)),
folder(appended.value),
].flat(1);
if (props.pagination.reversed) {
res.reverse();
}
return res;
}
items.value = getItems((x) => x);
if (props.folder) foldedItems.value = getItems(props.folder);
}
const queue = ref<Item[]>([]); const queue = ref<Item[]>([]);
/**
* The cached elements inserted front by `prepend` function
*/
const prepended = ref<Item[]>([]);
/**
* The array of "frozen" items
*/
const arrItems = ref<Item[][]>([]);
/**
* The cached elements inserted back by `append` function
*/
const appended = ref<Item[]>([]);
const idMap = new Map<string, boolean>();
const offset = ref(0); const offset = ref(0);
type PagingByParam =
| {
offset: number;
}
| {
sinceId: string;
}
| {
untilId: string;
}
| Record<string, never>;
let nextPagingBy: PagingByParam = {};
const fetching = ref(true); const fetching = ref(true);
const moreFetching = ref(false); const moreFetching = ref(false);
const more = ref(false); const more = ref(false);
@ -184,54 +234,14 @@ const init = async (): Promise<void> => {
queue.value = []; queue.value = [];
fetching.value = true; fetching.value = true;
const params = props.pagination.params ? unref(props.pagination.params) : {}; await fetch(true);
await os
.api(props.pagination.endpoint, {
...params,
limit: props.pagination.noPaging
? props.pagination.limit || 10
: (props.pagination.limit || 10) + 1,
...(props.pagination.ascending
? {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
}
: {}),
})
.then(
(res: Item[]) => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
}
if (
!props.pagination.noPaging &&
res.length > (props.pagination.limit || 10)
) {
res.pop();
items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = true;
} else {
items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = false;
}
offset.value = res.length;
error.value = false;
fetching.value = false;
},
(_err) => {
error.value = true;
fetching.value = false;
},
);
}; };
const reload = (): Promise<void> => { const reload = (): Promise<void> => {
items.value = []; arrItems.value = [];
appended.value = [];
prepended.value = [];
idMap.clear();
return init(); return init();
}; };
@ -240,30 +250,18 @@ const refresh = async (): Promise<void> => {
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
limit: items.value.length + 1, limit: (items.value.length || foldedItems.value.length) + 1,
offset: 0, offset: 0,
}) })
.then( .then(
(res: Item[]) => { (res: Item[]) => {
const ids = items.value.reduce( appended.value = [];
(a, b) => { prepended.value = [];
a[b.id] = true;
return a;
},
{} as Record<string, boolean>,
);
for (let i = 0; i < res.length; i++) { // appended should be inserted into arrItems to fix the element position
const item = res[i]; arrItems.value = [res];
if (!updateItem(item.id, (_old) => item)) {
append(item);
}
delete ids[item.id];
}
for (const id in ids) { calculateItems();
removeItem((i) => i.id === id);
}
}, },
(_err) => { (_err) => {
error.value = true; error.value = true;
@ -272,155 +270,145 @@ const refresh = async (): Promise<void> => {
); );
}; };
const fetchMore = async (): Promise<void> => { async function fetch(firstFetching?: boolean) {
if ( let limit: number;
!more.value ||
fetching.value || if (firstFetching) {
moreFetching.value || limit = props.pagination.noPaging
items.value.length === 0 ? props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT
) : (props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT) + 1;
return;
moreFetching.value = true; if (props.pagination.ascending) {
backed.value = true; nextPagingBy = {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
};
}
} else {
if (
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
backed.value = true;
limit =
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1;
}
const params = props.pagination.params ? unref(props.pagination.params) : {}; const params = props.pagination.params ? unref(props.pagination.params) : {};
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
limit: limit,
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1, ...nextPagingBy,
...(props.pagination.offsetMode
? {
offset: offset.value,
}
: props.pagination.reversed
? {
sinceId: items.value[0].id,
}
: props.pagination.ascending
? {
sinceId: items.value[items.value.length - 1].id,
}
: {
untilId: items.value[items.value.length - 1].id,
}),
}) })
.then( .then(
(res: Item[]) => { (res: Item[]) => {
for (let i = 0; i < res.length; i++) { if (!props.pagination.reversed)
const item = res[i]; for (let i = 0; i < res.length; i++) {
if (props.pagination.reversed) { const item = res[i];
if (i === res.length - 9) item._shouldInsertAd_ = true; if (props.pagination.reversed) {
} else { if (i === res.length - (firstFetching ? 2 : 9))
if (i === 10) item._shouldInsertAd_ = true; item._shouldInsertAd_ = true;
} else {
if (i === (firstFetching ? 3 : 10)) item._shouldInsertAd_ = true;
}
} }
} if (!props.pagination.noPaging && res.length > limit - 1) {
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
res.pop(); res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = false; more.value = false;
} }
offset.value += res.length; offset.value += res.length;
error.value = false;
fetching.value = false;
moreFetching.value = false; moreFetching.value = false;
const lastRes = res[res.length - 1];
if (props.pagination.offsetMode) {
nextPagingBy = {
offset: offset.value,
};
} else if (props.pagination.ascending) {
nextPagingBy = {
sinceId: lastRes?.id,
};
} else {
nextPagingBy = {
untilId: lastRes?.id,
};
}
if (firstFetching && props.folder != null) {
// In this way, prepended has some initial values for folding
prepended.value = res.toReversed();
} else {
// For ascending and offset modes, append and prepend may cause item duplication
// so they need to be filtered out.
if (props.pagination.offsetMode || props.pagination.ascending) {
for (const item of appended.value) {
idMap.set(item.id, true);
}
// biome-ignore lint/style/noParameterAssign: assign it intentially
res = res.filter((item) => {
if (idMap.has(item)) return false;
idMap.set(item, true);
return true;
});
}
// appended should be inserted into arrItems to fix the element position
arrItems.value.push(appended.value);
arrItems.value.push(res);
appended.value = [];
}
calculateItems();
}, },
(_err) => { (_err) => {
error.value = true;
fetching.value = false;
moreFetching.value = false; moreFetching.value = false;
}, },
); );
}
const fetchMore = async (): Promise<void> => {
await fetch();
}; };
const fetchMoreAhead = async (): Promise<void> => { const fetchMoreAhead = async (): Promise<void> => {
if ( await fetch();
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit:
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
...(props.pagination.offsetMode
? {
offset: offset.value,
}
: props.pagination.reversed
? {
untilId: items.value[0].id,
}
: {
sinceId: items.value[items.value.length - 1].id,
}),
})
.then(
(res: Item[]) => {
if (
res.length >
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
) {
res.pop();
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = true;
} else {
items.value = props.pagination.reversed
? res.toReversed().concat(items.value)
: items.value.concat(res);
more.value = false;
}
offset.value += res.length;
moreFetching.value = false;
},
(_err) => {
moreFetching.value = false;
},
);
}; };
const prepend = (item: Item): void => { const prepend = (...item: Item[]): void => {
// If there are too many prepended, merge them into arrItems
if (
prepended.value.length >
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
) {
arrItems.value.unshift(prepended.value.toReversed());
prepended.value = [];
// We don't need to calculate here because it won't cause any changes in items
}
if (props.pagination.reversed) { if (props.pagination.reversed) {
if (rootEl.value) { prepended.value.push(...item);
const container = getScrollContainer(rootEl.value); calculateItems();
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 { } else {
// unshiftOK // When displaying for the first time, just do this is OK
if (!rootEl.value) { if (!rootEl.value) {
items.value.unshift(item); prepended.value.push(...item);
calculateItems();
return; return;
} }
@ -429,52 +417,63 @@ const prepend = (item: Item): void => {
(document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) { if (isTop) {
// Prepend the item prepended.value.push(...item);
items.value.unshift(item); calculateItems();
//
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 { } else {
queue.value.push(item); queue.value.push(...item);
onScrollTop(rootEl.value, () => { onScrollTop(rootEl.value, () => {
for (const queueItem of queue.value) { prepend(...queue.value);
prepend(queueItem);
}
queue.value = []; queue.value = [];
}); });
} }
} }
}; };
const append = (item: Item): void => { const append = (...items: Item[]): void => {
items.value.push(item); appended.value.push(...items);
calculateItems();
};
const _removeItem = (arr: Item[], finder: (item: Item) => boolean): boolean => {
const i = arr.findIndex(finder);
if (i === -1) {
return false;
}
arr.splice(i, 1);
return true;
};
const _updateItem = (
arr: Item[],
id: Item["id"],
replacer: (old: Item) => Item,
): boolean => {
const i = arr.findIndex((item) => item.id === id);
if (i === -1) {
return false;
}
arr[i] = replacer(arr[i]);
return true;
}; };
const removeItem = (finder: (item: Item) => boolean): boolean => { const removeItem = (finder: (item: Item) => boolean): boolean => {
const i = items.value.findIndex(finder); const res =
if (i === -1) { _removeItem(prepended.value, finder) ||
return false; _removeItem(appended.value, finder) ||
} arrItems.value.filter((arr) => _removeItem(arr, finder)).length > 0;
calculateItems();
items.value.splice(i, 1); return res;
return true;
}; };
const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => { const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
const i = items.value.findIndex((item) => item.id === id); const res =
if (i === -1) { _updateItem(prepended.value, id, replacer) ||
return false; _updateItem(appended.value, id, replacer) ||
} arrItems.value.filter((arr) => _updateItem(arr, id, replacer)).length > 0;
calculateItems();
items.value[i] = replacer(items.value[i]); return res;
return true;
}; };
if (props.pagination.params && isRef<Param>(props.pagination.params)) { if (props.pagination.params && isRef<Param>(props.pagination.params)) {

View file

@ -338,7 +338,7 @@ defineExpose({
content: ""; content: "";
position: absolute; position: absolute;
inset: -2px 0; inset: -2px 0;
border: 2px solid var(--accentDarken); border-bottom: 2px solid var(--accentDarken);
mask: linear-gradient( mask: linear-gradient(
to right, to right,
transparent, transparent,

View file

@ -20,7 +20,6 @@ interface FoldOption {
*/ */
export function foldItems<ItemFolded, Item>( export function foldItems<ItemFolded, Item>(
ns: Item[], ns: Item[],
fetch_limit: number,
classfier: (n: Item, index: number) => string, classfier: (n: Item, index: number) => string,
aggregator: (ns: Item[], key: string) => ItemFolded, aggregator: (ns: Item[], key: string) => ItemFolded,
_options?: FoldOption, _options?: FoldOption,
@ -30,55 +29,48 @@ export function foldItems<ItemFolded, Item>(
const options: FoldOption = _options ?? {}; const options: FoldOption = _options ?? {};
options.skipSingleElement ??= true; options.skipSingleElement ??= true;
for (let i = 0; i < ns.length; i += fetch_limit) { const toAppendKeys: string[] = [];
const toFold = ns.slice(i, i + fetch_limit); const foldMap = new Map<string, Item[]>();
const toAppendKeys: string[] = [];
const foldMap = new Map<string, Item[]>();
for (const [index, n] of toFold.entries()) { for (const [index, n] of ns.entries()) {
const key = classfier(n, index); const key = classfier(n, index);
const arr = foldMap.get(key); const arr = foldMap.get(key);
if (arr != null) { if (arr != null) {
arr.push(n); arr.push(n);
} else { } else {
foldMap.set(key, [n]); foldMap.set(key, [n]);
toAppendKeys.push(key); toAppendKeys.push(key);
}
} }
res = res.concat(
toAppendKeys.map((key) => {
const arr = foldMap.get(key)!;
if (arr?.length === 1 && options?.skipSingleElement) {
return arr[0];
}
return aggregator(arr, key);
}),
);
} }
res = toAppendKeys.map((key) => {
const arr = foldMap.get(key)!;
if (arr?.length === 1 && options?.skipSingleElement) {
return arr[0];
}
return aggregator(arr, key);
});
return res; return res;
} }
export function foldNotifications( export function foldNotifications(ns: entities.Notification[]) {
ns: entities.Notification[], // By the implement of MkPagination, lastId is unique and is safe for key
fetch_limit: number, const lastId = ns[ns.length - 1]?.id ?? "prepend";
) {
return foldItems( return foldItems(
ns, ns,
fetch_limit,
(n) => { (n) => {
switch (n.type) { switch (n.type) {
case "renote": case "renote":
return `renote-of:${n.note.renote.id}`; return `renote-${n.note.renote.id}`;
case "reaction": case "reaction":
return `reaction:${n.reaction}:of:${n.note.id}`; return `reaction-${n.reaction}-of-${n.note.id}`;
default: { default: {
return `${n.id}`; return `${n.id}`;
} }
} }
}, },
(ns) => { (ns, key) => {
const represent = ns[0]; const represent = ns[0];
function check( function check(
ns: entities.Notification[], ns: entities.Notification[],
@ -94,6 +86,7 @@ export function foldNotifications(
userIds: ns.map((nn) => nn.userId), userIds: ns.map((nn) => nn.userId),
users: ns.map((nn) => nn.user), users: ns.map((nn) => nn.user),
notifications: ns!, notifications: ns!,
id: `G-${lastId}-${key}`,
} as NotificationFolded; } as NotificationFolded;
}, },
); );

View file

@ -4,6 +4,9 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
if (el == null) return null; if (el == null) return null;
const overflow = window.getComputedStyle(el).getPropertyValue("overflow-y"); const overflow = window.getComputedStyle(el).getPropertyValue("overflow-y");
if (overflow === "scroll" || overflow === "auto") { if (overflow === "scroll" || overflow === "auto") {
if (el.tagName === "HTML") {
return null;
}
return el; return el;
} else { } else {
return getScrollContainer(el.parentElement); return getScrollContainer(el.parentElement);