//
// SPDX-License-Identifier: AGPL-3.0
-window.models = window.models || {}
-window.models.Pager = function(loadFunc) {
- // loadFunc(filters) must return a promise for a page of results.
- var pager = this
- Object.assign(pager, {
- done: false,
- items: m.stream(),
+// MultipageLoader retrieves a multi-page result set from the
+// server. The constructor initiates the first page load.
+//
+// config.loadFunc is a function that accepts an array of
+// paging-related filters, and returns a promise for the API
+// response. loadFunc() must retrieve results in "modified_at desc"
+// order.
+//
+// state is:
+// * 'loading' if a network request is in progress;
+// * 'done' if there are no more items to load;
+// * 'ready' otherwise.
+//
+// items is a stream that resolves to an array of all items retrieved so far.
+//
+// loadMore() loads the next page, if any.
+window.MultipageLoader = function(config) {
+ var loader = this
+ Object.assign(loader, config, {
+ state: 'ready',
+ DONE: 'done',
+ LOADING: 'loading',
+ READY: 'ready',
+
+ items: m.stream([]),
thresholdItem: null,
loadMore: function() {
- // Get the next page, if there are any more items to get.
- if (pager.done)
+ if (loader.state == loader.DONE || loader.state == loader.LOADING)
return
- var filters = pager.thresholdItem ? [
- ["modified_at", "<=", pager.thresholdItem.modified_at],
- ["uuid", "!=", pager.thresholdItem.uuid],
+ var filters = loader.thresholdItem ? [
+ ["modified_at", "<=", loader.thresholdItem.modified_at],
+ ["uuid", "!=", loader.thresholdItem.uuid],
] : []
- loadFunc(filters).then(function(resp) {
- var items = pager.items() || []
+ loader.state = loader.LOADING
+ loader.loadFunc(filters).then(function(resp) {
+ var items = loader.items()
Array.prototype.push.apply(items, resp.items)
- if (resp.items.length == 0)
- pager.done = true
- else
- pager.thresholdItem = resp.items[resp.items.length-1]
- pager.items(items)
+ if (resp.items.length == 0) {
+ loader.state = loader.DONE
+ } else {
+ loader.thresholdItem = resp.items[resp.items.length-1]
+ loader.state = loader.READY
+ }
+ loader.items(items)
+ }).catch(function(err) {
+ loader.err = err
+ loader.state = loader.READY
})
},
})
+ loader.loadMore()
}
-// MultisiteLoader loads pages of results from multiple API sessions
-// and merges them into a single result set.
+// MergingLoader merges results from multiple loaders (given in the
+// config.children array) into a single result set.
//
-// The constructor implicitly starts an initial page load for each
-// session.
+// new MergingLoader({children: [loader, loader, ...]})
//
-// new MultisiteLoader({loadFunc: function(session, filters){...},
-// sessionDB: new window.models.SessionDB()}
-//
-// At any given time, ml.loadMore will be either false (meaning a page
-// load is in progress or there are no more results to fetch) or a
-// function that starts loading more results.
-//
-// loadFunc() must retrieve results in "modified_at desc" order.
-window.models = window.models || {}
-window.models.MultisiteLoader = function(config) {
+// The children must retrieve results in "modified_at desc" order.
+window.MergingLoader = function(config) {
var loader = this
- if (!(config.loadFunc && config.sessionDB))
- throw new Error("MultisiteLoader constructor requires loadFunc and sessionDB")
Object.assign(loader, config, {
- // Sorted items ready to display, merged from all pagers.
- displayable: [],
- done: false,
- pagers: {},
- loadMore: false,
+ // Sorted items ready to display, merged from all children.
+ items: m.stream([]),
+ state: 'ready',
+ DONE: 'done',
+ LOADING: 'loading',
+ READY: 'ready',
+ loadable: function() {
+ // Return an array of children that we could call
+ // loadMore() on. Update loader.state.
+ loader.state = loader.DONE
+ return loader.children.filter(function(child) {
+ if (child.state == child.DONE)
+ return false
+ if (child.state == child.LOADING) {
+ loader.state = loader.LOADING
+ return false
+ }
+ if (loader.state == loader.DONE)
+ loader.state = loader.READY
+ return true
+ })
+ },
+ loadMore: function() {
+ // Call loadMore() on children that have reached
+ // lowWaterMark.
+ loader.loadable().map(function(child) {
+ if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
+ loader.state = loader.LOADING
+ child.loadMore()
+ }
+ })
+ },
+ mergeItems: function() {
+ // We want to avoid moving items around on the screen once
+ // they're displayed.
+ //
+ // To this end, here we find the last safely displayable
+ // item ("cutoff") by getting the last item from each
+ // unfinished child, and taking the topmost (most recent)
+ // one of those.
+ //
+ // (If we were to display an item below that cutoff, the
+ // next page of results from an unfinished child could
+ // include items that get inserted above the cutoff,
+ // causing the cutoff item to move down.)
+ var cutoff
+ var cutoffUnknown = false
+ loader.children.forEach(function(child) {
+ if (child.state == child.DONE)
+ return
+ var items = child.items()
+ if (items.length == 0) {
+ // No idea what's coming in the next page.
+ cutoffUnknown = true
+ return
+ }
+ var last = items[items.length-1].modified_at
+ if (!cutoff || cutoff < last)
+ cutoff = last
+ })
+ if (cutoffUnknown)
+ return
+ var combined = []
+ loader.children.forEach(function(child) {
+ child.itemsDisplayed = 0
+ child.items().every(function(item) {
+ if (cutoff && item.modified_at < cutoff)
+ // Don't display this item or anything after
+ // it (see "cutoff" comment above).
+ return false
+ combined.push(item)
+ child.itemsDisplayed++
+ return true // continue
+ })
+ })
+ loader.items(combined.sort(function(a, b) {
+ return a.modified_at < b.modified_at ? 1 : -1
+ }))
+ },
// Number of undisplayed items to keep on hand for each result
// set. When hitting "load more", if a result set already has
// this many additional results available, we don't bother
// fetching a new page. This is the _minimum_ number of rows
- // that will be added to loader.displayable in each "load
- // more" event (except for the case where all items are
- // displayed).
+ // that will be added to loader.items in each "load more"
+ // event (except for the case where all items are displayed).
lowWaterMark: 23,
})
- var sessions = loader.sessionDB.loadActive()
- m.stream.merge(Object.keys(sessions).map(function(key) {
- var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
- loader.pagers[key] = pager
- pager.loadMore()
- // Resolve the stream with the session key when the results
- // arrive.
- return pager.items.map(function() { return key })
- })).map(function(keys) {
- // Top (most recent) of {bottom (oldest) entry of any pager
- // that still has more pages left to fetch}
- var cutoff
- keys.forEach(function(key) {
- var pager = loader.pagers[key]
- var items = pager.items()
- if (items.length == 0 || pager.done)
- return
- var last = items[items.length-1].modified_at
- if (!cutoff || cutoff < last)
- cutoff = last
- })
- var combined = []
- keys.forEach(function(key) {
- var pager = loader.pagers[key]
- pager.itemsDisplayed = 0
- pager.items().every(function(item) {
- if (cutoff && item.modified_at < cutoff)
- // Some other pagers haven't caught up to this
- // point, so don't display this item or anything
- // after it.
- return false
- item.session = sessions[key]
- combined.push(item)
- pager.itemsDisplayed++
- return true // continue
- })
- })
- loader.displayable = combined.sort(function(a, b) {
- return a.modified_at < b.modified_at ? 1 : -1
- })
- // Make a new loadMore function that hits the pagers (if
- // necessary according to lowWaterMark)... or set
- // loader.loadMore to false if there is nothing left to fetch.
- var loadable = []
- Object.keys(loader.pagers).map(function(key) {
- if (!loader.pagers[key].done)
- loadable.push(loader.pagers[key])
- })
- if (loadable.length == 0) {
- loader.done = true
- loader.loadMore = false
- } else
- loader.loadMore = function() {
- loader.loadMore = false
- loadable.map(function(pager) {
- if (pager.items().length - pager.itemsDisplayed < loader.lowWaterMark)
- pager.loadMore()
- })
- }
- })
+ var childrenReady = m.stream.merge(loader.children.map(function(child) {
+ return child.items
+ }))
+ childrenReady.map(loader.loadable)
+ childrenReady.map(loader.mergeItems)
}