+
-
-
-
-
-
-
+
+
+
diff --git a/src/app/modules/data/list/data-list.scss b/src/app/modules/data/list/data-list.scss
index f6539af5..086552c5 100644
--- a/src/app/modules/data/list/data-list.scss
+++ b/src/app/modules/data/list/data-list.scss
@@ -1,10 +1,21 @@
+/*!
+ * Copyright (C) 2022 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
cdk-virtual-scroll-viewport {
height: 100%;
width: 100%;
-
- ion-list {
- background: transparent;
- }
}
::ng-deep {
diff --git a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts
new file mode 100644
index 00000000..c56468f0
--- /dev/null
+++ b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+import {Directive, forwardRef, Input, Output, ViewChild} from '@angular/core';
+import {CdkVirtualForOf, VIRTUAL_SCROLL_STRATEGY} from '@angular/cdk/scrolling';
+import {ScThingListItemVirtualScrollStrategy} from './sc-thing-list-item-virtual-scroll-strategy';
+
+/**
+ *
+ */
+function factory(directive: SCThingListItemVirtualScrollStrategyDirective) {
+ return directive.scrollStrategy;
+}
+
+@Directive({
+ selector: 'cdk-virtual-scroll-viewport[scThingListItemVirtualScrollStrategy]',
+ providers: [
+ {
+ provide: VIRTUAL_SCROLL_STRATEGY,
+ useFactory: factory,
+ deps: [forwardRef(() => SCThingListItemVirtualScrollStrategyDirective)],
+ },
+ ],
+})
+export class SCThingListItemVirtualScrollStrategyDirective {
+ scrollStrategy = new ScThingListItemVirtualScrollStrategy();
+
+ @ViewChild(CdkVirtualForOf) virtualForOf: CdkVirtualForOf;
+
+ @Output() readonly loadMore = this.scrollStrategy.loadMore;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ @Input() set trackGroupBy(value: (item: any) => unknown) {
+ this.scrollStrategy.trackGroupBy = value;
+ }
+
+ @Input() set minimumHeight(value: number) {
+ this.scrollStrategy.approximateItemHeight = value;
+ }
+
+ @Input() set buffer(value: number) {
+ this.scrollStrategy.buffer = value;
+ }
+
+ @Input() set gap(value: number) {
+ this.scrollStrategy.gap = value;
+ }
+
+ @Input() set itemRenderTimeout(value: number) {
+ this.scrollStrategy.itemRenderTimeout = value;
+ }
+}
diff --git a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts
new file mode 100644
index 00000000..76387a29
--- /dev/null
+++ b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+import {CdkVirtualForOf, CdkVirtualScrollViewport, VirtualScrollStrategy} from '@angular/cdk/scrolling';
+import {BehaviorSubject, Subject, Subscription, takeUntil, timer} from 'rxjs';
+import {debounceTime, distinctUntilChanged, tap} from 'rxjs/operators';
+import {SCThingType} from '@openstapps/core';
+
+export class ScThingListItemVirtualScrollStrategy implements VirtualScrollStrategy {
+ private viewport?: CdkVirtualScrollViewport;
+
+ private virtualForOf?: CdkVirtualForOf;
+
+ private index$ = new Subject();
+
+ private heights = new Map();
+
+ private groupHeights = new Map();
+
+ private offsets: number[] = [];
+
+ private totalHeight = 0;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private _items?: readonly unknown[];
+
+ private _groups?: readonly unknown[];
+
+ /**
+ * We use this to track loadMore
+ */
+ private currentLength = 0;
+
+ private dataStreamSubscription?: Subscription;
+
+ private mutationObserver?: MutationObserver;
+
+ approximateItemHeight = 67;
+
+ approximateGroupSizes: Map = new Map([[SCThingType.AcademicEvent, 139]]);
+
+ buffer = 4000;
+
+ gap = 8;
+
+ itemRenderTimeout = 1000;
+
+ heightUpdateDebounceTime = 25;
+
+ heightSetDebounceTime = 100;
+
+ loadMore = new Subject();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ trackGroupBy: (value: any) => unknown = it => it.type;
+
+ attach(viewport: CdkVirtualScrollViewport): void {
+ this.viewport = viewport;
+ // @ts-expect-error private property
+ this.virtualForOf = viewport._forOf;
+ this.dataStreamSubscription = this.virtualForOf?.dataStream.subscribe(items => {
+ this.items = items;
+ });
+
+ this.mutationObserver = new MutationObserver(() => {
+ const renderedItems = this.renderedItems;
+ if (!renderedItems) {
+ this.mutationObserver?.disconnect();
+ return this.onPrematureDisconnect();
+ }
+
+ this.intersectionObserver?.disconnect();
+ this.intersectionObserver = new IntersectionObserver(this.observeIntersection.bind(this), {
+ rootMargin: `${this.buffer - 64}px`,
+ threshold: 1,
+ });
+
+ for (const node of renderedItems) {
+ const [item, group] = this.getItemByNode(node, renderedItems);
+
+ if (!this.heights.has(item)) {
+ this.intersectionObserver.observe(node);
+ } else {
+ node.style.height = `${this.getHeight(item, group)}px`;
+ }
+ }
+ });
+
+ const contentWrapper = this.contentWrapper;
+ if (!contentWrapper) return this.onPrematureDisconnect();
+ this.mutationObserver.observe(contentWrapper, {childList: true, subtree: true});
+
+ this.setTotalContentSize();
+ }
+
+ detach(): void {
+ this.index$.complete();
+ this.dataStreamSubscription?.unsubscribe();
+ this.mutationObserver?.disconnect();
+ delete this.viewport;
+ }
+
+ private getHeight(item: unknown, group: unknown) {
+ return (
+ this.heights.get(item) ||
+ this.groupHeights.get(group) ||
+ this.approximateGroupSizes.get(group) ||
+ this.approximateItemHeight
+ );
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private set items(value: readonly unknown[]) {
+ const trackBy = this.virtualForOf?.cdkVirtualForTrackBy;
+ const tracks = value.map((it, i) => (trackBy ? trackBy(i, it) : it));
+ if (
+ this._items &&
+ tracks.length === this._items.length &&
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ tracks.every((it, i) => it === this._items![i])
+ )
+ return;
+
+ this._items = tracks;
+ this._groups = value.map(this.trackGroupBy);
+ this.currentLength = tracks.length;
+
+ this.updateHeights();
+ }
+
+ scrolledIndexChange = this.index$.pipe(distinctUntilChanged());
+
+ private getOffsetFromIndex(index: number): number {
+ return this.offsets[index];
+ }
+
+ private getIndexFromOffset(offset: number): number {
+ return Math.max(
+ 0,
+ this.offsets.findIndex((it, i, array) => it >= offset || i === array.length - 1),
+ );
+ }
+
+ private updateHeights() {
+ if (!this._items) return;
+ const heights = this._items.map((it, i) => this.getHeight(it, this._groups![i]) + this.gap);
+ this.offsets = Array.from({length: heights.length}, () => 0);
+ this.totalHeight = heights.reduce((a, b, index) => (this.offsets[index + 1] = a + b), 0) + this.gap;
+
+ this.setTotalContentSize();
+ this.updateRenderedRange();
+ }
+
+ private updateRenderedRange() {
+ if (!this.viewport) return;
+ const offset = this.viewport.measureScrollOffset('top');
+ const viewportSize = this.viewport.getViewportSize();
+ const firstVisibleIndex = Math.max(0, this.getIndexFromOffset(offset) - 1);
+ const range = {
+ start: this.getIndexFromOffset(Math.max(0, offset - this.buffer)),
+ end: this.getIndexFromOffset(offset + viewportSize + this.buffer),
+ };
+ const {start, end} = this.viewport.getRenderedRange();
+ if (range.start === start && range.end === end) return;
+
+ if (this.currentLength !== 0 && range.end === this.currentLength) {
+ this.currentLength++;
+ this.loadMore.next();
+ }
+
+ this.viewport.setRenderedRange(range);
+ this.viewport.setRenderedContentOffset(this.getOffsetFromIndex(range.start), 'to-start');
+
+ this.index$.next(firstVisibleIndex);
+ }
+
+ private setTotalContentSize() {
+ this.viewport?.setTotalContentSize(this.totalHeight);
+ // @ts-expect-error TODO
+ this.viewport?._measureViewportSize();
+ }
+
+ observeIntersection(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
+ const renderedItems = this.renderedItems;
+ if (!renderedItems) return this.onPrematureDisconnect();
+
+ const update = new Subject();
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+
+ const outerNode = entry.target as HTMLElement;
+ const [item] = this.getItemByNode(outerNode, renderedItems);
+ const node = outerNode.firstChild! as HTMLElement;
+
+ const height = new BehaviorSubject(node.offsetHeight);
+ const resizeObserver = new ResizeObserver(() => {
+ const renderedItems = this.renderedItems;
+ if (!renderedItems) {
+ resizeObserver.disconnect();
+ return this.onPrematureDisconnect();
+ }
+ const [newItem] = this.getItemByNode(node, renderedItems);
+ if (newItem !== item) {
+ this.heights.delete(item);
+ resizeObserver.disconnect();
+ return;
+ }
+ height.next(node.offsetHeight);
+ });
+ resizeObserver.observe(node);
+ height
+ .pipe(
+ distinctUntilChanged(),
+ debounceTime(this.heightSetDebounceTime),
+ takeUntil(timer(this.itemRenderTimeout)),
+ tap({complete: () => resizeObserver.disconnect()}),
+ )
+ .subscribe(height => {
+ this.heights.set(item, height);
+ outerNode.style.height = `${height}px`;
+ update.next();
+ });
+
+ observer.unobserve(node);
+ }
+ update
+ .pipe(debounceTime(this.heightUpdateDebounceTime), takeUntil(timer(this.itemRenderTimeout)))
+ .subscribe(() => {
+ this.updateHeights();
+ });
+ }
+
+ intersectionObserver?: IntersectionObserver;
+
+ get contentWrapper() {
+ return this.viewport?._contentWrapper.nativeElement;
+ }
+
+ get renderedItems() {
+ const contentWrapper = this.contentWrapper;
+ return contentWrapper
+ ? // eslint-disable-next-line unicorn/prefer-spread
+ (Array.from(contentWrapper.children).filter(it => it instanceof HTMLElement) as HTMLElement[])
+ : undefined;
+ }
+
+ getItemByNode(node: HTMLElement, renderedItems: HTMLElement[]) {
+ const {start} = this.viewport!.getRenderedRange();
+ const index = renderedItems.indexOf(node) + start;
+ return [this._items![index], this._groups![index]];
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ onContentRendered(): void {}
+
+ onContentScrolled() {
+ this.updateRenderedRange();
+ }
+
+ onPrematureDisconnect() {
+ console.warn('Virtual Scroll strategy was disconnected unexpectedly', new Error('foo').stack);
+ }
+
+ onDataLengthChanged(): void {
+ this.setTotalContentSize();
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ onRenderedOffsetChanged(): void {}
+
+ scrollToIndex(index: number, behavior: ScrollBehavior): void {
+ this.viewport?.scrollToOffset(this.getOffsetFromIndex(index), behavior);
+ }
+}
diff --git a/src/app/modules/data/list/search-page.component.ts b/src/app/modules/data/list/search-page.component.ts
index 138557ca..0fb4ceb1 100644
--- a/src/app/modules/data/list/search-page.component.ts
+++ b/src/app/modules/data/list/search-page.component.ts
@@ -216,18 +216,20 @@ export class SearchPageComponent implements OnInit, OnDestroy {
const result = await this.dataProvider.search(searchOptions);
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
if (append) {
- let items = await this.items;
// append results
- items = [...items, ...result.data];
- this.items = (async () => items)();
+ this.items = this.items.then(it =>
+ // fix for some very short results
+ it.length === result.pagination.total ? it : [...it, ...result.data],
+ );
} else {
// override items with results
- this.items = (async () => {
- this.updateContextFilter(result.facets);
-
- return result.data;
- })();
+ this.updateContextFilter(result.facets);
+ this.items = Promise.resolve(result.data);
}
+
+ this.items.then(it => {
+ if (it.length === result.pagination.total) console.log('final page loaded');
+ });
} catch (error) {
this.logger.error(error);
} finally {
diff --git a/src/app/modules/favorites/favorites.service.ts b/src/app/modules/favorites/favorites.service.ts
index 849d5574..24423907 100644
--- a/src/app/modules/favorites/favorites.service.ts
+++ b/src/app/modules/favorites/favorites.service.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2022 StApps
+ * Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -15,15 +15,15 @@
import {Injectable} from '@angular/core';
import {
SCFacet,
+ SCFavorite,
+ SCIndexableThings,
+ SCSaveableThing,
SCSearchBooleanFilter,
SCSearchFilter,
SCSearchSort,
SCSearchValueFilter,
SCThings,
- SCFavorite,
- SCSaveableThing,
SCThingType,
- SCIndexableThings,
SCUuid,
} from '@openstapps/core';
import {StorageProvider} from '../storage/storage.provider';
@@ -32,7 +32,7 @@ import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {TranslateService} from '@ngx-translate/core';
import {ThingTranslateService} from '../../translation/thing-translate.service';
import {BehaviorSubject, Observable} from 'rxjs';
-import {map} from 'rxjs/operators';
+import {debounceTime, map} from 'rxjs/operators';
/**
* Service handling favorites
@@ -48,7 +48,8 @@ export class FavoritesService {
favorites = new BehaviorSubject