Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

818
gaia/apps/email/js/vscroll.js Executable file
View file

@ -0,0 +1,818 @@
'use strict';
define(function(require, exports, module) {
var evt = require('evt'),
slice = Array.prototype.slice,
useTransform = false;
/**
* Indirection for setting the top of a node. Used to allow
* experimenting with either a transform or using top
*/
function setTop(node, value) {
if (useTransform) {
node.style.transform = 'translateY(' + value + 'px)';
} else {
node.style.top = value + 'px';
}
}
// VScroll --------------------------------------------------------
/**
* Creates a new VScroll instance. Needs .setData() called on it
* to actually show content, the constructor just wires up nodes
* and sets starting state.
*
* @param {Node} container the DOM node that will show the items.
*
* @param {Node} scrollingContainer the scrolling DOM node, which
* contains the `container` node. Note that in email, there are
* other nodes in the scrollingContainer besides just container.
*
* @param {Node} template a DOM node that is cloned to provide
* the DOM node to use for an item that is shown on the screen.
* The clones of this node are cached and reused for multiple
* data items.
*
* @param {Object} defaultData a placeholder data object to use
* if list(index) does not return an object. Usually shows up when
* the scroll gets to a place in the list that does not have data
* loaded yet from the back end.
*/
function VScroll(container, scrollingContainer, template, defaultData) {
evt.Emitter.call(this);
this.container = container;
this.scrollingContainer = scrollingContainer;
this.template = template;
this.defaultData = defaultData;
this._inited = false;
// Because the FxOS keyboard works by resizing our window, we/our caller
// need to be careful about when we sample things involving the screen size.
// So, we want to only capture this once and do it separably from other
// things.
this._capturedScreenMetrics = false;
/**
* What is the first/lowest rendered index? Tracked so the HTML
* cache logic can know if we've got the data for it to be able to
* render the first N nodes.
*/
this.firstRenderedIndex = 0;
this._limited = false;
/**
* The list of reused Element nodes. Their order in this list has
* no correlation with their display position. If you decide to
* reorder them you may break/hurt _nextAvailableNode.
*/
this.nodes = [];
/**
* Maps data indexes to their reusable Element nodes if currently
* rendered, or -1 if previously (but not currently rendered).
* Populated as nodes are rendered so not being in the map is
* effectively the same as having a value of -1.
*
* Maintained by _setNodeDataIndex and accessed by
* _getNodeFromDataIndex. Use those methods and do not touch this
* map directly.
*/
this.nodesDataIndices = {};
/** Internal state variable of _nextAvailableNode for efficiency. */
this.nodesIndex = -1;
this.scrollTop = 0;
/**
* Any visible height offset to where container sits in relation
* to scrollingContainer. Expected to be set by owner of the
* VScroll instance. In email, the search box height is an
* example of a visibleOffset.
*/
this.visibleOffset = 0;
/**
* The old list size is used for display purposes, to know if new data would
* affect the scroll offset or if the total display height needs to be
* adjusted.
*/
this.oldListSize = 0;
this._lastEventTime = 0;
// Bind to this to make reuse in functional APIs easier.
this.onEvent = this.onEvent.bind(this);
this.onChange = this.onChange.bind(this);
this._scrollTimeoutPoll = this._scrollTimeoutPoll.bind(this);
}
VScroll.nodeClassName = 'vscroll-node';
/**
* Given a node that is handled by VScroll, trim it down for use
* in a string cache, like email's html cache. Modifies the
* node in place.
* @param {Node} node the containerNode that is bound to
* a VScroll instance.
* @param {Number} itemLimit number of items to cache. If greater
* than the length of items in a NodeCache, the NodeCache item
* length will be used.
*/
VScroll.trimMessagesForCache = function(container, itemLimit) {
// Find the NodeCache that is at the top
var nodes = slice.call(container.querySelectorAll(
'.' + VScroll.nodeClassName));
nodes.forEach(function(node) {
var index = parseInt(node.dataset.index, 10);
// None of the clones need this value after we read it off, so reduce
// the size of the cache by clearing it.
delete node.dataset.index;
if (index > itemLimit - 1) {
container.removeChild(node);
}
});
};
VScroll.prototype = {
/**
* rate limit for event handler, in milliseconds, so that
* it does not do work for every event received. If set to
* zero, it means always do the work for every scroll event.
* If this code continues to use 0, then the onEvent/onChange
* duality could be removed, and just use onChange directly.
* A non-zero value, like 50 subjectively seems to result in
* more checkerboarding of half the screen every so often.
*/
eventRateLimitMillis: 0,
/**
* The maximum number of items visible on the screen at once
* (derived from available space and rounded up).
*/
itemsPerScreen: undefined,
/**
* The number of screens worth of items to pre-render in the
* direction we are scrolling beyond the current screen.
*/
prerenderScreens: 3,
/**
* The number of screens worth of items to prefetch (but not
* render!) beyond what we prerender.
*/
prefetchScreens: 2,
/**
* The number of extra screens worth of rendered items to keep
* around beyond what is required for prerendering. When
* scrolling in a single direction, this ends up being the number
* of screens worth of items to keep rendered behind us. If this
* is less than the value of `prerenderScreens` then a user just
* jiggling the screen up and down by even a pixel will cause us
* work as we move the delta back and forth.
*
* In other words, don't have this be less than
* `prerenderScreens`, but you can have it be more. (Although
* having it be more is probably wasteful since extra DOM nodes
* the user is moving away from don't help us.)
*/
retainExtraRenderedScreens: 3,
/**
* When recalculating, pre-render this many screens of messages on
* each side of the current screen. This may be a fractional
* value (we round up).
*
* In the initial case we very much want to minimize rendering
* latency, so it makes sense for this to be smaller than
* `prerenderScreens`.
*
* In the non-initial case we wait for scrolling to have quiesced,
* so there's no overriding need to bias in either direction.
*/
recalculatePaddingScreens: 1.5,
/**
* Track when the last time vscroll manually changed the scrollTop
* of the scrolling container. Useful for when knowing if a recent
* scroll event was triggered by this component or by user action.
* The value resets to 0 periodically to avoid interested code from
* doing too many timestamp checks on every scroll event.
*/
lastScrollTopSetTime: 0,
/**
* The number of items to prerender (computed).
*/
prerenderItemCount: undefined,
/**
* The number of items to prefetch (computed).
*/
prefetchItemCount: undefined,
/**
* The number of items to render when (non-initial) recalculating.
*/
recalculatePaddingItemCount: undefined,
/**
* The class to find items that have their default data set,
* in the case where a scroll into a cache has skipped updates
* because a previous fast scroll skipped the updates since they
* were not visible at the time of that fast scroll.
*/
itemDefaultDataClass: 'default-data',
/**
* Hook that is implemented by the creator of a VScroll instance.
* Called when the VScroll thinks it will need the next set of
* data, but before the VScroll actually shows that section of
* data. Passed the inclusive high absolute index for which it
* wants data. ASSUMES data sources that only need to grow
* downward.
*/
prepareData: function(highAbsoluteIndex) {},
/**
* Hook that is implemented by the creator of a VScroll instance.
* Called when the VScroll wants to bind a model object to a
* display node.
*/
bindData: function(model, node) {},
/**
* Sets the list data source, and then triggers a recalculate
* since the data changed.
* @param {Function} list the list data source.
*/
setData: function(list) {
this.list = list;
if (this._inited) {
if (!this.waitingForRecalculate) {
this._recalculate(0);
}
this.emit('dataChanged');
} else {
this._init();
this.renderCurrentPosition();
}
},
/**
* Called by code that created the VScroll instance, when that
* code has data fetched and wants to let the VScroll know
* about it. This is useful from removing the display of
* defaultData and showing the finally fetched data.
* @param {Number} index the list item index for which the
* data update is available
* @param {Array} dataList the list of data items that are
* now available. The first item in that list corresponds to
* the data list index given in the first argument.
* @param {number} removedCount the count of any items removed.
* Used mostly to know if a recalculation needs to be done.
*/
updateDataBind: function(index, dataList, removedCount) {
if (!this._inited) {
return;
}
// If the list data set length is different from before, that
// indicates state is now invalid and a recalculate is needed,
// but wait until scrolling stops. This can happen if items
// were removed, or if new things were added to the list.
if (this.oldListSize !== this.list.size() || removedCount) {
if (!this.waitingForRecalculate) {
this.waitingForRecalculate = true;
this.once('scrollStopped', function() {
this._recalculate(index);
}.bind(this));
}
return;
}
// Not a list data size change, just an update to existing
// data items, so update them in place.
for (var i = 0; i < dataList.length; i++) {
var absoluteIndex = index + i;
var node = this._getNodeFromDataIndex(absoluteIndex);
if (node) {
this.bindData(dataList[i], node);
}
}
},
/**
* Handles events fired, and allows rate limiting the work if
* this.eventRateLimitMillis has been set. Otherwise just calls
* directly to onChange.
*/
onEvent: function() {
this._lastEventTime = Date.now();
if (!this.eventRateLimitMillis) {
this.onChange();
return;
}
if (this._limited) {
return;
}
this._limited = true;
setTimeout(this.onChange, this.eventRateLimitMillis);
},
/**
* Process a scroll event (possibly delayed).
*/
onChange: function() {
// Rate limit is now expired since doing actual work.
this._limited = false;
if (!this._inited) {
return;
}
if (this.lastScrollTopSetTime) {
// Keep the last scroll time for about a second, which should
// be enough time for interested parties to check the value.
if (this.lastScrollTopSetTime + 1000 < Date.now()) {
this.lastScrollTopSetTime = 0;
}
}
var startIndex,
endIndex,
scrollTop = this.scrollingContainer.scrollTop,
scrollingDown = scrollTop >= this.scrollTop;
this.scrollTop = scrollTop;
// must get after updating this.scrollTop since it uses that
var visibleRange = this.getVisibleIndexRange();
if (scrollingDown) {
// both _render and prepareData clamp appropriately
startIndex = visibleRange[0];
endIndex = visibleRange[1] + this.prerenderItemCount;
this.prepareData(endIndex + this.prefetchItemCount);
} else {
// scrolling up
startIndex = visibleRange[0] - this.prerenderItemCount;
endIndex = visibleRange[1];
// no need to prepareData; it's already there!
}
this._render(startIndex, endIndex);
this._startScrollStopPolling();
},
/**
* Called when the vscroll becomes visible. In cases where the vscroll
* may have been intially created for an element that is not visible,
* the sizing information would not be correct and the vscroll instance
* would not be initialized correctly. So the instance needs to know
* when it should check again to properly initialize. Otherwise, there
* may not be any new data signals from the the list data that a display
* needs to be tried.
*/
nowVisible: function() {
// Only do work if not initialized and have data.
if (!this._inited && this.list) {
this._init();
this.onChange();
}
},
/**
* Renders the list at the current scroll position.
*/
renderCurrentPosition: function() {
if (!this._inited) {
return;
}
var scrollTop = this.scrollingContainer.scrollTop;
this.scrollTop = scrollTop;
var visibleRange = this.getVisibleIndexRange();
// (_render clamps these values for sanity; we don't have to)
var startIndex = visibleRange[0] - this.recalculatePaddingItemCount;
var endIndex = visibleRange[1] + this.recalculatePaddingItemCount;
this._render(startIndex, endIndex);
// make sure we have at least enough data to cover what we want
// to display
this.prepareData(endIndex);
},
/**
* Determine what data index is at the given scroll position.
* @param {Number} position scroll position
* @return {Number} the data index.
*/
indexAtScrollPosition: function (position) {
var top = position - this.visibleOffset;
if (top < 0) {
top = 0;
}
return this.itemHeight ? Math.floor(top / this.itemHeight) : 0;
},
/**
* Returns the start index and end index of the list items that
* are currently visible to the user using the currently cached
* scrollTop value.
* @return {Array} first and last index. Array could be undefined
* if the VScroll is not in a position to show data yet.
*/
getVisibleIndexRange: function() {
// Do not bother if itemHeight has not bee initialized yet.
if (this.itemHeight === undefined) {
return undefined;
}
var top = this.scrollTop;
return [
this.indexAtScrollPosition(top),
this.indexAtScrollPosition(top + this.innerHeight)
];
},
/**
* Given the list index, scroll to the top of that item.
* @param {Number} index the list item index.
*/
jumpToIndex: function(index) {
this._setContainerScrollTop((index * this.itemHeight) +
this.visibleOffset);
},
/**
* Removes items from display in the container. Just a visual
* change, does not change data in any way. Data-related
* elements, like the positions of this.nodes, are reset in
* the data entry points that follow a clearDisplay, like
* _init() or recalculate().
*/
clearDisplay: function() {
// Clear the HTML content.
this.container.innerHTML = '';
this.container.style.height = '0px';
// Also clear the oldListSize, since it used for height/scroll offset
// updates, and now that the container does not have any children, this
// property should be reset to zero. If this is not done, it is possible
// for an update that matches the same size as the previous data will not
// end up showing items. This happened for search in bug 1081403.
this.oldListSize = 0;
},
/**
* Call this method before the VScroll instance will be destroyed.
* Used to clean up the VScroll.
*/
destroy: function() {
this.scrollingContainer.removeEventListener('scroll', this.onEvent);
if (this._scrollTimeoutPoll) {
clearTimeout(this._scrollTimeoutPoll);
this._scrollTimeoutPoll = 0;
}
},
_setContainerScrollTop: function(value) {
this.scrollingContainer.scrollTop = value;
// Opt for using a property set instead of an event emitter, since the
// timing of that event emit is not guaranteed to get to listeners before
// scroll events.
this.lastScrollTopSetTime = Date.now();
},
/**
* Ensure that we are rendering at least all messages in the
* inclusive range [startIndex, endIndex]. Already rendered
* messages outside this range may be reused but will not be
* removed or de-rendered unless they are needed.
*
*
* @param {Number} startIndex first inclusive index in this.list's
* data that should be used. Will be clamped to the bounds of
* this.list but what's visible on the screen is not considered
* @param {Number} endIndex last inclusive index in this.list's
* data that should be used. Clamped like startIndex.
*/
_render: function(startIndex, endIndex) {
var i,
listSize = this.list.size();
// Paranoia clamp the inputs; we depend on callers to deal with
// the visible range.
if (startIndex < 0) {
startIndex = 0;
}
if (endIndex >= listSize) {
endIndex = listSize - 1;
}
this.firstRenderedIndex = startIndex;
if (!this._inited) {
this._init();
}
for (i = startIndex; i <= endIndex; i++) {
// If node already bound and placed correctly, skip it.
if (this._getNodeFromDataIndex(i)) {
continue;
}
var node = this._nextAvailableNode(startIndex, endIndex),
data = this.list(i);
if (!data) {
data = this.defaultData;
}
// Remove the node while doing updates in positioning to
// avoid extra layers from being created which really slows
// down scrolling.
if (node.parentNode) {
node.parentNode.removeChild(node);
}
setTop(node, i * this.itemHeight);
this._setNodeDataIndex(this.nodesIndex, i);
this.bindData(data, node);
this.container.appendChild(node);
}
},
_setNodeDataIndex: function(nodesIndex, dataIndex) {
// Clear dataIndices map for old dataIndex value.
var oldDataIndex = this.nodes[nodesIndex].vScrollDataIndex;
if (oldDataIndex > -1) {
this.nodesDataIndices[oldDataIndex] = -1;
}
var node = this.nodes[nodesIndex];
node.vScrollDataIndex = dataIndex;
// Expose the index into the DOM so that the cache logic can
// consider them since only the underlying DOM node is cloned by
// cloneNode(). (vScrollDataIndex is an "expando" property on
// the JS wrapper on the native DOM object.)
node.dataset.index = dataIndex;
this.nodesDataIndices[dataIndex] = nodesIndex;
},
_getNodeFromDataIndex: function (dataIndex) {
var index = this.nodesDataIndices[dataIndex];
if (index === undefined) {
index = -1;
}
return index === -1 ? null : this.nodes[index];
},
captureScreenMetrics: function() {
if (this._capturedScreenMetrics) {
return;
}
this.innerHeight = this.scrollingContainer.getBoundingClientRect().height;
if (this.innerHeight > 0) {
this._capturedScreenMetrics = true;
}
},
/**
* Handles final initialization, once the VScroll is expected
* to actually show data.
*
* XXX eventually consume 'resize' events. Right now we are
* assuming that the email app only supports a single orientation
* (portrait) and that the only time a resize event will trigger
* is if the keyboard is shown or hidden. When used in the
* message_list's search mode, it explicitly calls _init on us
* prior to causing the keyboard to be displayed, which currently
* saves us from getting super confused.
*/
_init: function() {
if (this._inited) {
return;
}
// Clear out any previous container contents. For example, a
// cached HTML of a previous card may have been used to init
// this VScroll instance.
this.container.innerHTML = '';
// Get the height of an item node.
var node = this.template.cloneNode(true);
this.container.appendChild(node);
this.itemHeight = node.clientHeight;
this.container.removeChild(node);
// Set up all the bounds used in scroll calculations
this.captureScreenMetrics();
// The instance is not visible yet, so cannot finish initialization.
// Wait for the next instance API call to see if initialization can
// complete.
if (!this.itemHeight || !this.innerHeight) {
return;
}
this.scrollingContainer.addEventListener('scroll', this.onEvent);
this.itemsPerScreen = Math.ceil(this.innerHeight / this.itemHeight);
this.prerenderItemCount =
Math.ceil(this.itemsPerScreen * this.prerenderScreens);
this.prefetchItemCount =
Math.ceil(this.itemsPerScreen * this.prefetchScreens);
this.recalculatePaddingItemCount =
Math.ceil(this.itemsPerScreen * this.recalculatePaddingScreens);
this.nodeCount = this.itemsPerScreen + this.prerenderItemCount +
Math.ceil(this.retainExtraRenderedScreens *
this.itemsPerScreen);
// Fill up the pool of nodes to use for data items.
for (var i = 0; i < this.nodeCount; i++) {
node = this.template.cloneNode(true);
node.classList.add(VScroll.nodeClassName);
setTop(node, (-1 * this.itemHeight));
this.nodes.push(node);
this._setNodeDataIndex(i, -1);
}
this._calculateTotalHeight();
this._inited = true;
this.emit('inited');
},
/**
* Finds the next node in the pool to use in the visible area.
* Uses a hidden persistent index to provide efficient lookup for
* repeated calls using the same stratIndex/endIndex as long as
* there are at least (endIndex - beginIndex + 1) * 2 nodes.
*
* @param {Number} beginIndex the starting data index for the
* range of already visible data indices. They should be
* avoided as choices since they are already in visible area.
* @param {Number} endIndex the ending data index for the
* range of already visible data indices.
* @return {Node} the DOM node that can be used next for display.
*/
_nextAvailableNode: function(beginIndex, endIndex) {
var i, node, vScrollDataIndex,
count = 0;
// Loop over nodes finding the first one that is out of visible
// range, making sure to loop back to the beginning of the
// nodes if cycling over the end of the list.
for (i = this.nodesIndex + 1; count < this.nodes.length; count++, i++) {
// Loop back to the beginning if past the end of the nodes.
if (i > this.nodes.length - 1) {
i = 0;
}
node = this.nodes[i];
vScrollDataIndex = node.vScrollDataIndex;
if (vScrollDataIndex < beginIndex || vScrollDataIndex > endIndex) {
this.nodesIndex = i;
break;
}
}
return node;
},
/**
* Recalculates the size of the container, and resets the
* display of items in the container. Maintains the scroll
* position inside the list.
* @param {Number} refIndex a reference index that spawned
* the recalculate. If that index is "above" the targeted
* computed index found by recalculate, then it means the
* the absolute scroll position may need to change.
*/
_recalculate: function(refIndex) {
if (!this._inited) {
return;
}
var node,
index = this.indexAtScrollPosition(this.scrollTop),
remainder = this.scrollTop % this.itemHeight,
sizeDiff = this.list.size() - this.oldListSize;
// If this recalculate was spawned from the top and more
// items, then new messages from the top, and account for
// them so the scroll position does not jump. Only do this
// though if old size was not 0, which is common on first
// folder sync, or if the reference index that spawned the
// recalculate is "above" the target index, since that
// means the contents above the target index shifted.
if (refIndex && refIndex < index && sizeDiff > 0 &&
this.oldListSize !== 0 && index !== 0) {
index += sizeDiff;
}
console.log('VSCROLL scrollTop: ' + this.scrollTop +
', RECALCULATE: ' + index + ', ' + remainder);
this._calculateTotalHeight();
// Now clear the caches from the visible area
for (var i = 0; i < this.nodeCount; i++) {
node = this.nodes[i];
setTop(node, (-1 * this.itemHeight));
this._setNodeDataIndex(i, -1);
}
this.waitingForRecalculate = false;
this._setContainerScrollTop((this.itemHeight * index) + remainder);
this.renderCurrentPosition();
this.emit('recalculated', index === 0, refIndex);
},
/**
* Sets the total height of the container.
*/
_calculateTotalHeight: function() {
// Size the scrollable area to the full height if all items
// were rendered inside of it, so that there is no weird
// scroll bar grow/shrink effects and so that inertia
// scrolling is not artificially truncated.
var newListSize = this.list.size();
// Do not bother if same size, or if the container was set to 0 height,
// most likely by a clearDisplay.
if (this.oldListSize !== newListSize ||
parseInt(this.container.style.height, 10) === 0) {
this.totalHeight = this.itemHeight * newListSize;
this.container.style.height = this.totalHeight + 'px';
this.oldListSize = newListSize;
}
},
/**
* Handles checking for the end of a scroll, based on a time
* delay since the last scroll event.
*/
_scrollTimeoutPoll: function() {
this._scrollStopTimeout = 0;
if (Date.now() > this._lastEventTime + 300) {
this.emit('scrollStopped');
} else {
this._scrollStopTimeout = setTimeout(this._scrollTimeoutPoll, 300);
}
},
/**
* Starts checking for the end of scroll events.
*/
_startScrollStopPolling: function() {
if (!this._scrollStopTimeout) {
// "this" binding for _scrollTimeoutPoll done in constructor
this._scrollStopTimeout = setTimeout(this._scrollTimeoutPoll, 300);
}
}
};
evt.mix(VScroll.prototype);
// Override on() to allow for a lazy firing of scrollStopped,
// particularly when the list is not scrolling, so the stop
// polling is not currently running. This is useful for "once"
// listeners that just want to be sure to do work when scroll
// is not in action.
var originalOn = VScroll.prototype.on;
VScroll.prototype.on = function(id, fn) {
if (id === 'scrollStopped') {
this._startScrollStopPolling();
}
return originalOn.apply(this, slice.call(arguments));
};
// Introspection tools --------------------------------------------
// uncomment this section to use them. Useful for tracing how the
// code is called.
/*
require('debug_trace_methods')(VScroll.prototype, module.id);
*/
return VScroll;
});