diff --git a/config/config-base.yaml b/config/config-base.yaml
index 00a4319..d7f7f1d 100644
--- a/config/config-base.yaml
+++ b/config/config-base.yaml
@@ -65,6 +65,20 @@ sections:
type: number
value: 30
description: Timeout of user cache in minutes. Set to 0 to disable.
+ - setting: web_cache_async_timeout
+ name: User search cache asynchronous timeout (minutes)
+ requires_restart: true
+ advanced: true
+ type: number
+ value: 1
+ description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
+ - setting: web_cache_sync_timeout
+ name: User search cache synchronous timeout (minutes)
+ requires_restart: true
+ advanced: true
+ type: number
+ value: 10
+ description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
- setting: type
name: Server type
requires_restart: true
diff --git a/ts/admin.ts b/ts/admin.ts
index 45aea1a..a8d054d 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -192,7 +192,7 @@ login.onLogin = () => {
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
reloadProfileNames();
- setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
+ setInterval(() => { window.invites.reload(); accounts.reloadIfNotInScroll(); }, 30*1000);
// Triggers pre and post funcs, even though we're already on that page
window.tabs.switch(window.tabs.current);
}
diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts
index 46f50b3..22cda3d 100644
--- a/ts/modules/accounts.ts
+++ b/ts/modules/accounts.ts
@@ -1,11 +1,13 @@
-import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText, toClipboard } from "../modules/common.js";
-import { templateEmail } from "../modules/settings.js";
+import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText, toClipboard } from "../modules/common"
+import { templateEmail } from "../modules/settings"
import { Marked } from "@ts-stack/markdown";
-import { stripMarkdown } from "../modules/stripmd.js";
-import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
-import { Search, SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
-import { HiddenInputField } from "./ui.js";
-import { PaginatedList } from "./list.js";
+import { stripMarkdown } from "../modules/stripmd"
+import { DiscordUser, newDiscordSearch } from "../modules/discord"
+import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttribute } from "../modules/search"
+import { HiddenInputField } from "./ui"
+import { PaginatedList } from "./list"
+
+// FIXME: Find and define a threshold after which searches are no longer performed on input (or just in general by the browser).
declare var window: GlobalWindow;
@@ -780,7 +782,10 @@ class user implements User, SearchableItem {
});
get id() { return this._id; }
- set id(v: string) { this._id = v; }
+ set id(v: string) {
+ this._id = v;
+ this._row.setAttribute(SearchableItemDataAttribute, v);
+ }
update = (user: User) => {
@@ -821,10 +826,8 @@ interface UsersDTO extends paginatedDTO {
users: User[];
}
-interface UsersReqDTO extends PaginatedReqDTO {};
-
export class accountsList extends PaginatedList {
- private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
+ protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement;
@@ -973,6 +976,9 @@ export class accountsList extends PaginatedList {
});
this._populateNumbers();
+ // FIXME: Remove!
+ (window as any).s = this;
+
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
sortingByButton: this._sortingByButton,
@@ -1253,7 +1259,7 @@ export class accountsList extends PaginatedList {
set selectAll(state: boolean) {
let count = 0;
for (let id in this.users) {
- if (this._table.contains(this.users[id].asElement())) { // Only select visible elements
+ if (this._container.contains(this.users[id].asElement())) { // Only select visible elements
this.users[id].selected = state;
count++;
}
@@ -1268,7 +1274,7 @@ export class accountsList extends PaginatedList {
for (let id of this._search.ordering) {
if (!(inRange || id == startID)) continue;
inRange = true;
- if (!(this._table.contains(this.users[id].asElement()))) continue;
+ if (!(this._container.contains(this.users[id].asElement()))) continue;
this.users[id].selected = true;
if (id == endID) return;
}
@@ -1300,7 +1306,7 @@ export class accountsList extends PaginatedList {
} else {
let visibleCount = 0;
for (let id in this.users) {
- if (this._table.contains(this.users[id].asElement())) {
+ if (this._container.contains(this.users[id].asElement())) {
visibleCount++;
}
}
@@ -1403,7 +1409,7 @@ export class accountsList extends PaginatedList {
private _collectUsers = (): string[] => {
let list: string[] = [];
for (let id in this.users) {
- if (this._table.contains(this.users[id].asElement()) && this.users[id].selected) { list.push(id); }
+ if (this._container.contains(this.users[id].asElement()) && this.users[id].selected) { list.push(id); }
}
return list;
}
@@ -2065,20 +2071,7 @@ export class accountsList extends PaginatedList {
this._displayExpiryDate();
window.modals.extendExpiry.show();
}
-
-
- setVisibility = (users: string[], visible: boolean) => {
- console.log(`setting ${users.length} users as visible: ${visible}`);
- this._table.textContent = "";
- for (let id of this._search.ordering) {
- if (visible && users.indexOf(id) != -1) {
- this._table.appendChild(this.users[id].asElement());
- } else if (!visible && users.indexOf(id) == -1) {
- this._table.appendChild(this.users[id].asElement());
- }
- }
- }
-
+
private _populateAddUserProfiles = () => {
this._addUserProfile.textContent = "";
let innerHTML = ``;
diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts
index 7f63017..5eb0387 100644
--- a/ts/modules/activity.ts
+++ b/ts/modules/activity.ts
@@ -1,5 +1,5 @@
import { _get, _post, _delete, toDateString } from "../modules/common.js";
-import { SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
+import { SearchConfiguration, QueryType, SearchableItem, SearchableItems, SearchableItemDataAttribute } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js";
import { PaginatedList } from "./list.js";
@@ -353,7 +353,10 @@ export class Activity implements activity, SearchableItem {
}
get id(): string { return this._act.id; }
- set id(v: string) { this._act.id = v; }
+ set id(v: string) {
+ this._act.id = v;
+ this._card.setAttribute(SearchableItemDataAttribute, v);
+ }
get user_id(): string { return this._act.user_id; }
set user_id(v: string) { this._act.user_id = v; }
@@ -379,6 +382,7 @@ export class Activity implements activity, SearchableItem {
this._card = document.createElement("div");
this._card.classList.add("card", "@low", "my-2");
+
this._card.innerHTML = `
@@ -476,7 +480,7 @@ interface ActivitiesDTO extends paginatedDTO {
}
export class activityList extends PaginatedList {
- protected _activityList: HTMLElement;
+ protected _container: HTMLElement;
// protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
@@ -525,7 +529,7 @@ export class activityList extends PaginatedList {
}
});
- this._activityList = document.getElementById("activity-card-list")
+ this._container = document.getElementById("activity-card-list")
document.addEventListener("activity-reload", this.reload);
let searchConfig: SearchConfiguration = {
@@ -553,17 +557,6 @@ export class activityList extends PaginatedList {
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
}
- setVisibility = (activities: string[], visible: boolean) => {
- this._activityList.textContent = ``;
- for (let id of this._search.ordering) {
- if (visible && activities.indexOf(id) != -1) {
- this._activityList.appendChild(this.activities[id].asElement());
- } else if (!visible && activities.indexOf(id) == -1) {
- this._activityList.appendChild(this.activities[id].asElement());
- }
- }
- }
-
reload = () => {
this._reload();
}
diff --git a/ts/modules/common.ts b/ts/modules/common.ts
index 40b5ddd..e417431 100644
--- a/ts/modules/common.ts
+++ b/ts/modules/common.ts
@@ -306,3 +306,20 @@ export function unicodeB64Encode(s: string): string {
const bin = String.fromCodePoint(...encoded);
return btoa(bin);
}
+
+// Only allow running a function every n milliseconds.
+// Source: Clément Prévost at https://stackoverflow.com/questions/27078285/simple-throttle-in-javascript
+// function foo(bar: T): T {
+export function throttle (callback: () => void, limitMilliseconds: number): () => void {
+ var waiting = false; // Initially, we're not waiting
+ return function () { // We return a throttled function
+ if (!waiting) { // If we're not waiting
+ callback.apply(this, arguments); // Execute users function
+ waiting = true; // Prevent future invocations
+ setTimeout(function () { // After a period of time
+ waiting = false; // And allow future invocations
+ }, limitMilliseconds);
+ }
+ }
+}
+
diff --git a/ts/modules/list.ts b/ts/modules/list.ts
index 414b550..bac8acc 100644
--- a/ts/modules/list.ts
+++ b/ts/modules/list.ts
@@ -1,5 +1,5 @@
-import { _get, _post, addLoader, removeLoader } from "./common";
-import { Search, SearchConfiguration, SearchableItems } from "./search";
+import { _get, _post, addLoader, removeLoader, throttle } from "./common";
+import { Search, SearchConfiguration } from "./search";
declare var window: GlobalWindow;
@@ -93,7 +93,22 @@ export interface PaginatedListConfig {
export abstract class PaginatedList {
protected _c: PaginatedListConfig;
-
+
+ // Container to append items to.
+ protected _container: HTMLElement;
+ // List of visible IDs (i.e. those set with setVisibility).
+ protected _visible: string[];
+ protected _scroll = {
+ rowHeight: 0,
+ screenHeight: 0,
+ // Render this many screen's worth of content below the viewport.
+ renderNExtraScreensWorth: 3,
+ rowsOnPage: 0,
+ rendered: 0,
+ initialRenderCount: 0,
+ scrollLoading: false
+ };
+
protected _search: Search;
protected _counter: RecordCounter;
@@ -148,7 +163,6 @@ export abstract class PaginatedList {
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
// Since this.reload doesn't exist, we need an arrow function to wrap it.
- // FIXME: Make sure it works though!
this._c.refreshButton.onclick = () => this.reload();
}
@@ -198,7 +212,80 @@ export abstract class PaginatedList {
};
// Sets the elements with "name"s in "elements" as visible or not.
- public abstract setVisibility: (elements: string[], visible: boolean) => void;
+ setVisibilityNaive = (elements: string[], visible: boolean) => {
+ let timer = this._search.timeSearches ? performance.now() : null;
+ if (visible) this._visible = elements;
+ else this._visible = this._search.ordering.filter(v => !elements.includes(v));
+ const frag = document.createDocumentFragment()
+ for (let i = 0; i < this._visible.length; i++) {
+ frag.appendChild(this._search.items[this._visible[i]].asElement())
+ }
+ this._container.replaceChildren(frag);
+ if (this._search.timeSearches) {
+ const totalTime = performance.now() - timer;
+ console.log(`setVisibility took ${totalTime}ms`);
+ }
+ }
+
+ // FIXME: Call on window resize/zoom
+ // FIXME: On reload, load enough pages to fill required space.
+ // FIXME: Might have broken _counter.shown!
+ // Sets the elements with "name"s in "elements" as visible or not.
+ // appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset.
+ setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => {
+ let timer = this._search.timeSearches ? performance.now() : null;
+ if (visible) this._visible = elements;
+ else this._visible = this._search.ordering.filter(v => !elements.includes(v));
+ if (this._visible.length == 0) return;
+
+ this._scroll.screenHeight = Math.max(
+ document.documentElement.clientHeight,
+ window.innerHeight || 0
+ );
+
+ if (!appendedItems) {
+ // Wipe old elements and render 1 new one, so we can take the element height.
+ this._container.replaceChildren(this._search.items[this._visible[0]].asElement())
+ }
+
+ this.computeScrollInfo();
+
+ let baseIndex = 1;
+ if (appendedItems) {
+ baseIndex = this._scroll.rendered;
+ }
+ const frag = document.createDocumentFragment()
+ for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
+ frag.appendChild(this._search.items[this._visible[i]].asElement())
+ }
+ this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
+ // appendChild over replaceChildren because there's already elements on the DOM
+ this._container.appendChild(frag);
+
+ if (this._search.timeSearches) {
+ const totalTime = performance.now() - timer;
+ console.log(`setVisibility took ${totalTime}ms`);
+ }
+ }
+
+ // Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change.
+ computeScrollInfo = () => {
+ this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
+
+ // We want to have _scroll.renderNScreensWorth*_scroll.screenHeight or more elements rendered always.
+ this._scroll.rowsOnPage = Math.floor(this._scroll.screenHeight / this._scroll.rowHeight);
+
+ // Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it.
+ this._scroll.initialRenderCount = Math.min(this._visible.length, Math.max((this._scroll.renderNExtraScreensWorth+1)*this._scroll.rowsOnPage, this._c.itemsPerPage));
+ }
+
+ // returns the item index to render up to for the given scroll position.
+ // might return a value greater than this._visible.length, indicating a need for a page load.
+ maximumItemsToRender = (scrollY: number): number => {
+ const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight);
+ const bottomIdx = Math.floor(bottomScroll / this._scroll.rowsOnPage);
+ return bottomIdx;
+ }
// Removes all elements, and reloads the first page.
// FIXME: Share more code between reload and loadMore, and go over the logic, it's messy.
@@ -309,26 +396,65 @@ export abstract class PaginatedList {
}
this._search.onSearchBoxChange(true, loadAll);
} else {
- this.setVisibility(this._search.ordering, true);
+ // Since results come to us ordered already, we can assume "ordering"
+ // will be identical to pre-page-load but with extra elements at the end,
+ // allowing infinite scroll to continue
+ this.setVisibility(this._search.ordering, true, true);
this._search.setNotFoundPanelVisibility(false);
}
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
}, true)
}
-
- // Should be assigned to window.onscroll whenever the list is in view.
- detectScroll = () => {
- if (!this._hasLoaded || this.lastPage) return;
- // console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
- if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
- // window.notifications.customSuccess("scroll", "Reached bottom.");
- // Wait .5s between loads
- if (this._lastLoad + 500 > Date.now()) return;
- this.loadMore(null, false);
+
+ loadNItems = (n: number) => {
+ const cb = () => {
+ if (this._counter.loaded > n) return;
+ this.loadMore(cb, false);
+ }
+ cb();
+ }
+
+ // As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially.
+ public reloadIfNotInScroll = () => {
+ if (this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) {
+ return this.reload();
}
}
+ _detectScroll = () => {
+ if (!this._hasLoaded || this._scroll.scrollLoading) return;
+ if (this._visible.length == 0) return;
+ const endIdx = this.maximumItemsToRender(window.scrollY);
+ // If you've scrolled back up, do nothing
+ if (endIdx <= this._scroll.rendered) return;
+
+ const realEndIdx = Math.min(endIdx, this._visible.length);
+ const frag = document.createDocumentFragment();
+ for (let i = this._scroll.rendered; i < realEndIdx; i++) {
+ frag.appendChild(this._search.items[this._visible[i]].asElement());
+ }
+ this._scroll.rendered = realEndIdx;
+ this._container.appendChild(frag);
+
+ if (endIdx >= this._visible.length) {
+ if (this.lastPage || this._lastLoad + 500 > Date.now()) return;
+ this._scroll.scrollLoading = true;
+ const cb = () => {
+ if (this._visible.length < endIdx && !this.lastPage) {
+ this.loadMore(cb, false)
+ return;
+ }
+ this._scroll.scrollLoading = false;
+ this._detectScroll();
+ };
+ cb();
+ return;
+ }
+ }
+
+ // Should be assigned to window.onscroll whenever the list is in view.
+ detectScroll = throttle(this._detectScroll, 200);
}
diff --git a/ts/modules/search.ts b/ts/modules/search.ts
index b0a2a18..b8d9388 100644
--- a/ts/modules/search.ts
+++ b/ts/modules/search.ts
@@ -269,8 +269,12 @@ export class DateQuery extends Query {
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
+ // FIXME: SearchableItem should really be ListItem or something, this isn't for search!
+ asElement: () => HTMLElement;
}
+export const SearchableItemDataAttribute = "data-search-item";
+
export type SearchableItems = { [id: string]: SearchableItem };
export class Search {
@@ -294,6 +298,9 @@ export class Search {
}
}
+ // Intended to be set from the JS console, if true searches are timed.
+ timeSearches: boolean = false;
+
private _serverSearchButtons: HTMLElement[];
static tokenizeSearch = (query: string): string[] => {
@@ -398,6 +405,7 @@ export class Search {
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
+ let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
let result: string[] = [...this._ordering];
@@ -460,6 +468,10 @@ export class Search {
this._queries = queries;
this._searchTerms = searchTerms;
+ if (this.timeSearches) {
+ const totalTime = performance.now() - timer;
+ console.log(`Search took ${totalTime}ms`);
+ }
return result;
}
@@ -528,10 +540,8 @@ export class Search {
this._c.notFoundLocallyText.classList.remove("unfocused");
}
if (visible) {
- console.log("showing not found panel");
this._c.notFoundPanel.classList.remove("unfocused");
} else {
- console.log("hiding not found panel");
this._c.notFoundPanel.classList.add("unfocused");
}
}
@@ -644,10 +654,6 @@ export class Search {
}
constructor(c: SearchConfiguration) {
- // FIXME: Remove!
- if (c.search.id.includes("activity")) {
- (window as any).s = this;
- }
this._c = c;
this._c.search.oninput = () => {