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

View file

@ -0,0 +1,127 @@
'use strict';
/* global GaiaGrid */
/* global GridIconRenderer */
/* global MozActivity */
/* jshint nonew: false */
(function(exports) {
const TYPE = 'bookmark';
/**
* Represents a single bookmark icon on the homepage.
*/
function Bookmark(record, features) {
this.detail = record;
this.features = features || {};
this.detail.type = TYPE;
}
Bookmark.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
* Bookmarks use a custom icon renderer because they are likely much
* smaller than a standard icon.
*/
renderer: GridIconRenderer.TYPE.FAVICON,
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
get name() {
return this.detail.name;
},
get icon() {
return this.detail.icon || this.defaultIcon;
},
get identifier() {
return this.detail.id;
},
update: GaiaGrid.GridItem.prototype.updateFromDatastore,
/**
* Bookmarks are always editable unless noted otherwise in features.
*/
isEditable: function() {
return this.features && this.features.isEditable !== false;
},
/**
* Bookmarks are always removable unless noted otherwise in features.
*/
isRemovable: function() {
return this.features && this.features.isRemovable !== false;
},
/**
* Bookmarks are always draggable unless noted otherwise in features.
*/
isDraggable: function() {
return this.features && this.features.isDraggable !== false;
},
/**
* This method overrides the GridItem.render function.
*/
render: function() {
GaiaGrid.GridItem.prototype.render.call(this);
this.element.classList.add('bookmark');
if (this.isEditable()) {
this.element.classList.add('editable');
}
},
/**
* Launches the bookmark in a browser window.
*/
launch: function() {
var features = {
name: this.name,
icon: this.icon,
remote: true
};
var url = this.detail.url;
if (this.features.search) {
features.searchName = this.name;
features.searchUrl = url;
}
window.open(url, '_samescope', Object.keys(features)
.map(function eachFeature(key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(features[key]);
}).join(','));
},
/**
* Opens a web activity to edit the bookmark.
*/
edit: function() {
new MozActivity({
name: 'save-bookmark',
data: {
type: 'url',
url: this.detail.id
}
});
}
};
exports.GaiaGrid.Bookmark = Bookmark;
}(window));

View file

@ -0,0 +1,73 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* Represents a single divider on the homepage.
*/
function Divider() {
this.detail = {
type: 'divider',
index: 0
};
}
Divider.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
x: 0,
y: 0,
headerHeight: 0,
/**
* Height in pixels of each divider.
*/
get pixelHeight() {
return (this.grid.layout.cols > 3) ? 50 : 60;
},
/**
* Width in grid units for each divider.
*/
gridWidth: 4,
scale: 1,
/**
* Renders the icon to the grid component.
*/
render: function() {
// Generate the content if we need to
if (!this.element) {
// Divider is a <section> and the rest of items are <div> containers
// in order to hide the last divider via :last-of-type pseudo-class
var divider = this.element = document.createElement('section');
divider.className = 'divider';
var span = document.createElement('span');
span.className = 'spacer';
divider.appendChild(span);
this.grid.element.appendChild(divider);
}
this.element.style.transform = 'translate(0 ,' + this.y + 'px)';
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
},
isDraggable: function() {
return false;
}
};
exports.GaiaGrid.Divider = Divider;
}(window));

View file

@ -0,0 +1,666 @@
'use strict';
/* global GridIconRenderer */
/* global Promise */
/* global UrlHelper */
(function(exports) {
// event names
const ICON_BLOB_DECORATED_EVENT = 'iconblobdecorated';
const ICON_BLOB_ERROR_EVENT = 'gaiagrid-iconbloberror';
const FETCH_XHR_TIMEOUT = 10000;
/**
* XHR wrapper for fetching blobs with timeout logic.
*
* @param {String} uri to fetch.
* @return {Promise[Blob]}
*/
function fetchBlob(uri) {
return new Promise(function(accept, reject) {
var xhr = new XMLHttpRequest({
mozAnon: true,
mozSystem: true
});
xhr.open('GET', uri, true);
xhr.responseType = 'blob';
xhr.timeout = FETCH_XHR_TIMEOUT;
// remember that send can throw for some non http protocols. The promise
// wrapper here protects us.
xhr.send();
xhr.onload = function() {
var status = xhr.status;
if (status !== 0 && status !== 200) {
reject(
new Error('Got HTTP status ' + status + ' trying to load ' + uri)
);
return;
}
accept(xhr.response);
};
xhr.onerror = xhr.ontimeout = function() {
reject(new Error('Error while HTTP GET: ', uri));
};
});
}
/**
* Represents a generic grid item from which other items can inherit from.
*/
function GridItem() {
this.detail = {};
}
GridItem.prototype = {
x: 0,
y: 0,
/**
* Height in pixels of each item.
*/
pixelHeight: 0,
/**
* Width in grid units for each item.
*/
gridWidth: 1,
scale: 1,
/**
* Whether or not this icon will persist to the database.
*/
persistToDB: true,
/**
* Cached values of the last transform of the element, to avoid redundant
* style changes.
*/
lastX: null,
lastY: null,
lastScale: null,
/**
* Whether or not this item has a cached icon or not.
*/
get hasCachedIcon() {
return this.detail && this.detail.decoratedIconBlob;
},
/**
* The icon renderer to use. Sub-classes may override this, and individual
* objects may as well with a custom detail.renderer property.
* Bookmarks use this for example to render both web results from E.me,
* and favicons from the web with different styles.
*/
renderer: GridIconRenderer.TYPE.STANDARD,
/**
* Every grid item has a desired icon (over the network) if we fail to fetch
* or render it for some reason we display another. This string keeps track
* of the states:
*
*
* none: no action has been taken yet to display or fetch the icon.
* pending: fetching the icon
* success: icon was fetched and displayed
* error: icon could not be fetched or displayed
*
*/
iconState: 'none',
/**
* Default icon url to use when no other icon can be downloaded
*/
get defaultIcon() {
// XXX: ideally this would be relative to the component
return '/shared/elements/gaia_grid/images/default_icon.png';
},
/**
* Returns a reference to the current grid.
* We can currently only have one grid per page.
*/
get grid() {
return document.getElementsByTagName('gaia-grid')[0]._grid;
},
/**
* Returns true if this item is removable.
*/
isRemovable: function() {
return false;
},
/**
* Returns true if this item is editable.
*/
isEditable: function() {
return false;
},
/**
* Returns true if this item is draggable.
*/
isDraggable: function() {
return true;
},
/**
* Returns true if the icon is hosted at an origin.
*/
isIconFromOrigin: function() {
return this.icon.startsWith('http') || this.icon.startsWith('app:');
},
/**
* Sets the item position
*/
setPosition: function(position) {
this.detail.index = position;
},
/**
* Sets the item coordinates
*/
setCoordinates: function(x, y) {
this.x = x;
this.y = y;
},
/**
* Dispatches a gaiagrid-attention signal with the rect of the item.
*/
requestAttention: function() {
var rect = {
x: this.x,
y: this.y,
width: this.gridWidth * this.grid.layout.gridItemWidth,
height: this.pixelHeight
};
this.grid.element.dispatchEvent(
new CustomEvent('gaiagrid-attention', { detail: rect }));
},
closestIconSizeFromList: function(choices) {
if (!choices) {
return 0;
}
var sizes = Object.keys(choices);
if (!sizes.length) {
return 0;
}
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
var filter = (a) => a >= maxSize ? a : 0;
// Ideally, get an icon that is equal to our grid size, or else
// the smallest icon which is larger than our grid size.
var bestSize = Math.min(...sizes.map(filter));
// If no icons are larger than (or equal to ) our grid size, get
// the largest one.
bestSize = bestSize || Math.max(...sizes);
return bestSize || 0;
},
/**
* Given a list of icons that match a size, return the closest icon to
* reduce possible pixelation by picking a wrong size.
* @param {Object} choices An object mapping icon size to icon URL.
*/
closestIconFromList: function(choices) {
var icon;
// Check for W3C web manifest format for icons
if (Array.isArray(choices)) {
var manifest = {
'icons': choices
};
var maxSize = this.grid.layout.gridMaxIconSize; // The goal size
icon = window.IconsHelper.getBestIconFromWebManifest(manifest, maxSize);
return icon ? (new URL(icon, this.app.manifestURL)).href : null;
}
var accurateSize = this.closestIconSizeFromList(choices);
if (accurateSize === 0) {
return this.defaultIcon;
}
icon = choices[accurateSize];
// Handle relative URLs
if (!UrlHelper.hasScheme(icon)) {
icon = this.app.origin + icon;
}
return icon;
},
/**
* Renders the given image src as the icon (with processing).
*
* @param {String} url The image url to display.
*/
renderIconFromSrc: function(url) {
var background = new Image();
background.src = url;
background.onload = this._decorateIcon.bind(this, background);
},
/**
* Renders the given blob as the icon (with processing).
*
* @param {Blob} blob to render as icon.
*/
renderIconFromBlob: function(blob) {
var background = new Image();
background.src = URL.createObjectURL(blob);
background.onload = () => {
this._decorateIcon(background);
URL.revokeObjectURL(background.src);
};
background.onerror = () => {
URL.revokeObjectURL(background.src);
// If we already have a cached icon, do not overwrite it on error.
if (this.hasCachedIcon) {
this._stampElementWithIcon('blobcache');
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
return;
}
this.renderIconFromSrc(this.defaultIcon);
this._stampElementWithIcon(this.defaultIcon);
};
},
/**
* Begins decoration process for a given icon.
*
* @param {HTMLImageElement} img An image element to display from.
*/
_decorateIcon: function(img) {
var strategy = this.detail.renderer || this.renderer;
this.rendererInstance = new GridIconRenderer(this);
this.rendererInstance[strategy](img).
then(this._displayDecoratedIcon.bind(this));
},
/**
* Finishes decoration process and displays the augmented icon.
*
* @param {Blob} blob The image blob to display.
*/
_displayDecoratedIcon: function(blob, isCachedIcon) {
if (!this.element) {
// The icon could be removed while it is being decorated
return;
}
var style = this.element.style;
if (!style.backgroundSize) {
style.height = this.grid.layout.gridItemHeight + 'px';
// icon size + padding for shadows implemented in the icon renderer
var backgroundHeight =
((this.grid.layout.gridIconSize * (1 / this.grid.layout.percent)) +
GridIconRenderer.prototype.unscaledCanvasPadding);
if (this.grid.layout.gridItemHeight > backgroundHeight) {
style.backgroundSize = backgroundHeight + 'px';
} else {
style.backgroundSize = style.height;
}
}
if (isCachedIcon) {
var url = URL.createObjectURL(blob);
style.backgroundImage = 'url(' + url + ')';
this.element.dataset.backgroundImage = url;
var img = new Image();
img.onload = img.onerror = () => {
this.grid.element.dispatchEvent(
new CustomEvent('cached-icon-rendered')
);
};
img.src = url;
return;
}
this._compareBlobs(blob, this.detail.decoratedIconBlob).
then((equal) => {
if (equal) {
return;
}
style.backgroundImage = 'url(' + URL.createObjectURL(blob) + ')';
this.detail.decoratedIconBlob = blob;
this.grid.element.dispatchEvent(
new CustomEvent(ICON_BLOB_DECORATED_EVENT, {
detail: this
})
);
var bgImg = this.element.dataset.backgroundImage;
bgImg && URL.revokeObjectURL(bgImg);
});
},
/**
* It compares two blobs.
*/
_compareBlobs: function(blob1, blob2) {
return new Promise(function(resolve) {
if (!blob1 || !blob2 || blob1.type !== blob2.type ||
blob1.size !== blob2.size) {
resolve(false);
return;
}
// We skip the first bytes that typically are headers
var startBytes = 127;
var bytesHash = 16;
var reader = new FileReader();
reader.onloadend = function() {
var result1 = reader.result;
reader = new FileReader();
reader.onloadend = reader.onerror = function() {
resolve(result1 === reader.result);
};
reader.readAsDataURL(blob2.slice(startBytes, startBytes + bytesHash));
};
reader.onerror = function() {
resolve(false);
};
reader.readAsDataURL(blob1.slice(startBytes, startBytes + bytesHash));
});
},
/**
* Fetch an icon blob by a URI. This can be used as an extension hook to
* implement caching logic into the fetching process.
*
* @return {Promise[Null|Blob]}
*/
fetchIconBlob: function() {
return fetchBlob(this.icon);
},
/**
* Helper method to stamp the element with an icon uri. This stamp is for
* debugging/testing only and has no other effect.
*/
_stampElementWithIcon: function(uri) {
// ensure we don't stamp URI
if (!this.element || uri.startsWith('data:')) {
return;
}
// do not allow long strings on the stamp
if (uri.length > 255) {
uri = uri.slice(0, 255);
}
// test prefix used to indicate this has no functional value.
this.element.dataset.testIcon = uri;
},
/**
Fetch and render the desired icon (`.icon`) if possible otherwise fallback
to the default icon.
XXX: This method is not concurrency safe one image may override another
without ordering.
*/
renderIcon: function(renderCachedIcon) {
var icon = this.icon;
this.iconState = 'pending';
if (!icon) {
return;
}
if (renderCachedIcon && this.hasCachedIcon) {
// Display cached icons before trying to get icons again.
this._displayDecoratedIcon(this.detail.decoratedIconBlob, true);
var resolveIcon = () => {
this.grid.element.removeEventListener('cached-icons-rendered',
resolveIcon);
this.doRenderIcon(icon);
};
this.grid.element.addEventListener('cached-icons-rendered',
resolveIcon);
} else {
this.doRenderIcon(icon);
}
},
doRenderIcon: function(icon) {
// fast path if the icon is not from an origin display it outright
if (!this.isIconFromOrigin()) {
// XXX: Should we convert data uri(s) to blobs?
this._stampElementWithIcon(icon);
this.renderIconFromSrc(icon);
// state set after in case of some exception or whatever
this.iconState = 'success';
// noop
return Promise.resolve();
}
// root element of the web component
var eventTarget = this.grid.element;
// handle any cases where the blob is null or the icon cannot be
// fetched.
var blobNotFound = () => {
this.iconState = 'error';
if (!this.hasCachedIcon) {
this.renderIconFromSrc(this.defaultIcon);
this._stampElementWithIcon(this.defaultIcon);
} else {
this._stampElementWithIcon('blobcache');
}
eventTarget.dispatchEvent(
new CustomEvent(ICON_BLOB_ERROR_EVENT, {
detail: this
})
);
};
return this.fetchIconBlob().
then((blob) => {
// if the fetch was a success but no blob could be found short
// short circuit... note that being offline is _not_ an error.
if (!blob) {
return blobNotFound();
}
this.renderIconFromBlob(blob);
this.iconState = 'success';
this._stampElementWithIcon(icon);
}).
catch((err) => {
blobNotFound();
});
},
/**
Safely remove this item from the grid and DOM.
*/
removeFromGrid: function() {
var idx = this.grid.items.indexOf(this);
// This should never happen but is remotely possible item is not in the
// grid.
if (idx === -1) {
console.error('Attempting to remove self before item has been added!');
return;
}
// update the state of the grid and DOM so this item is no longer
// referenced.
this.grid.items.splice(idx, 1);
delete this.grid.icons[this.identifier];
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
// ensure we don't end up with empty cruft..
this.grid.render({ from: idx - 1 });
},
/**
Removes item from the dom and dispatches a removeitem event.
*/
remove: function() {
this.grid.element.dispatchEvent(new CustomEvent('removeitem', {
detail: this
}));
},
/**
* Renders the icon to the container.
*/
render: function() {
var scale = this.grid.layout.percent * this.scale;
// Generate an element if we need to
if (!this.element) {
var tile = document.createElement('div');
tile.className = 'icon';
tile.dataset.identifier = this.identifier;
tile.dataset.isDraggable = this.isDraggable();
tile.setAttribute('role', 'link');
tile.setAttribute('tabindex', 0);
tile.style.width = (this.grid.layout.constraintSize / 3) + 'px';
// This <p> has been added in order to place the title with respect
// to this container via CSS without touching JS.
var nameContainerEl = document.createElement('p');
nameContainerEl.style.marginTop = ((this.grid.layout.gridIconSize *
(1 / this.grid.layout.percent)) +
(GridIconRenderer.prototype.unscaledCanvasPadding / 2)) + 'px';
tile.appendChild(nameContainerEl);
var nameEl = document.createElement('span');
nameEl.className = 'title';
nameEl.setAttribute('dir', 'auto');
nameContainerEl.appendChild(nameEl);
// Add delete link if this icon is removable
if (this.isRemovable()) {
var removeEl = document.createElement('span');
removeEl.className = 'remove';
tile.appendChild(removeEl);
}
this.element = tile;
this.updateTitle();
this.renderIcon(true);
this.grid.element.appendChild(tile);
}
this.transform(this.x, this.y, scale);
},
/**
* Positions and scales an icon.
*/
transform: function(x, y, scale, element) {
scale = scale || 1;
if (!element) {
if (x === this.lastX && y === this.lastY && scale === this.lastScale) {
return;
}
element = this.element;
this.lastX = x;
this.lastY = y;
this.lastScale = scale;
}
element.style.transform =
'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
},
/**
* Set whether the item is being dragged.
*/
setActive: function(active) {
if (active) {
this.element.classList.add('active');
this.active = true;
} else {
this.element.classList.remove('active');
this.active = false;
}
},
/**
Updates the title of the icon on the grid.
*/
updateTitle: function() {
// it is remotely possible that we have not .rendered yet
if (!this.element) {
return;
}
var nameEl = this.element.querySelector('.title');
if (this.asyncName) {
this.asyncName().then(function(name) {
nameEl.textContent = name;
});
} else {
nameEl.textContent = this.name;
}
},
/**
* Updates an icon on the page from a datastore record.
* Used for bookmarks.
* @param {Object} record The datastore record.
*/
updateFromDatastore: function(record) {
var iconChanged = record.icon !== this.icon;
var nameChanged = record.name !== this.name;
var type = this.detail.type;
var lastIcon = this.icon;
record.type = type;
this.detail = record;
if (nameChanged) {
this.updateTitle();
}
if (iconChanged && record.icon) {
this.renderIcon();
} else if (!record.icon) {
this.detail.icon = lastIcon;
}
}
};
exports.GaiaGrid.GridItem = GridItem;
}(window));

View file

@ -0,0 +1,430 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* The relative size of icons when in a collapsed group.
*/
const COLLAPSE_RATIO = 0.33;
/**
* Maximum number of icons that are visible in a collapsed group.
*/
const COLLAPSED_GROUP_SIZE = 6;
/**
* Space to be reserved at the sides of collapsed group items, in pixels.
*/
const COLLAPSED_GROUP_MARGIN_LEFT = 23;
const COLLAPSED_GROUP_MARGIN_RIGHT = 53;
/**
* Time, in ms, before removing the 'toggling' class from the expand/collapse
* toggle element.
*/
const TOGGLE_TIMEOUT = 250;
/**
* A replacement for the default Divider class that implements group
* collapsing and provides convenience functions for group drag'n'drop.
*/
function Group(detail) {
this.detail = detail || {};
this.detail.type = 'divider';
this.detail.index = 0;
this.pendingCollapse = this.detail.collapsed = !!this.detail.collapsed;
}
Group.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
gridWidth: 4,
/**
* Height in pixels of the header part of the group.
*/
get headerHeight() {
return this.detail.collapsed ? 20 : 30;
},
/**
* The collapsed state. Used to defer the actual state change of
* collapse/expand so that anything re-rendering the grid between setting
* the state and the re-render caused by the state change doesn't cause
* the group to be positioned incorrectly.
*/
pendingCollapse: false,
pendingCollapseTimeout: null,
/**
* Height in pixels of the background of the group.
*/
backgroundHeight: 0,
lastBackgroundHeight: null,
/**
* Height in pixels of the separator at the bottom of the group.
*/
separatorHeight: 0,
/**
* The scale used for collapsed icons, taking into account column size.
*/
collapseRatio: COLLAPSE_RATIO,
/**
* Height in pixels of the group. When collapsed, this includes the
* icons associated with the group.
*/
get pixelHeight() {
return this.detail.collapsed ?
this.backgroundHeight :
this.separatorHeight;
},
/**
* Returns the number of items in the group. Relies on the item index
* being correct.
*/
get size() {
var size = 0;
for (var i = this.detail.index - 1, items = this.grid.items;
i >= 0 && items[i].detail.type !== 'divider'; i--) {
size++;
}
return size;
},
/**
* Creates the element and its children for this group item.
*/
_createElement: function() {
var group = this.element = document.createElement('section');
group.className = 'divider group newly-created';
// Create the background (only seen in edit mode)
var span = document.createElement('span');
span.className = 'background';
group.appendChild(span);
this.backgroundSpanElement = span;
// Create an element for a drop-shadow (only seen when active)
span = document.createElement('span');
span.className = 'shadow';
group.appendChild(span);
this.shadowSpanElement = span;
// Create the header (container for the move gripper and
// expand/collapse toggle)
span = document.createElement('span');
span.className = 'header';
group.appendChild(span);
this.headerSpanElement = span;
// Create the move gripper
span = document.createElement('span');
span.className = 'gripper';
this.headerSpanElement.appendChild(span);
// Create the expand/collapse toggle
var button = document.createElement('button');
button.className = 'toggle';
button.dataset.l10nId = 'gaia-grid-toggle-expanded';
this.headerSpanElement.appendChild(button);
this.toggleElement = button;
// Create the group separator (only seen in non-edit mode)
span = document.createElement('span');
span.className = 'separator';
group.appendChild(span);
this.dividerSpanElement = span;
// Create a child span for the separator to act as the indicator for
// dropping icons/groups during editing.
this.dividerSpanElement.appendChild(document.createElement('span'));
this.grid.element.appendChild(group);
this.separatorHeight = this.dividerSpanElement.clientHeight;
this.lastBackgroundHeight = null;
},
/**
* Renders the apps within the group. If the group is not collapsed, this
* just validates the style class of the group.
*/
_renderChildren: function(nApps) {
var isRTL = (document.documentElement.dir === 'rtl');
if (!this.detail.collapsed) {
this.element.classList.remove('collapsed');
return;
}
// If collapsed, we need to style and position the icons in the group
// into one row.
this.element.classList.add('collapsed');
var y = this.y;
var index = this.detail.index;
var width = Math.round(
(this.grid.layout.constraintSize -
COLLAPSED_GROUP_MARGIN_LEFT - COLLAPSED_GROUP_MARGIN_RIGHT) /
COLLAPSED_GROUP_SIZE);
var x = isRTL ?
(this.grid.layout.constraintSize - COLLAPSED_GROUP_MARGIN_RIGHT) :
COLLAPSED_GROUP_MARGIN_LEFT;
y += this.headerHeight;
var maxGridItemWidth =
this.grid.layout.constraintSize / this.grid.layout.minIconsPerRow;
this.collapseRatio =
(maxGridItemWidth / this.grid.layout.gridItemWidth) * COLLAPSE_RATIO;
for (var i = index - nApps; i < index; i++) {
var item = this.grid.items[i];
if (this.detail.collapsed) {
item.scale = this.collapseRatio;
var itemVisible = (i - (index - nApps)) < COLLAPSED_GROUP_SIZE;
if (!itemVisible) {
item.setCoordinates(isRTL ? x + width : x - width, y);
} else {
item.setCoordinates(x, y);
x += isRTL ? -width: width;
}
item.render();
item.element.classList.add('collapsed');
if (!itemVisible) {
item.element.classList.add('hidden');
}
}
}
},
/**
* Gets the y-position of the group. When the group is expanded, its y
* position is actually the y-position of the separator underneath the
* group. This gets the visible y-position of the group.
*/
getRealYPosition: function(nApps) {
if (this.detail.collapsed) {
return this.y;
}
return this.grid.items[this.detail.index - nApps].y -
this.headerHeight;
},
/**
* Renders the icon to the grid component.
*/
render: function() {
// Generate the content if we need to
var createdElements = false;
if (!this.element) {
createdElements = true;
this._createElement();
}
// Render children
var nApps = this.size;
this._renderChildren(nApps);
// Calculate group position.
// If we're not collapsed, the group's position will be underneath its
// icons, but we want it to display above.
var y = this.getRealYPosition(nApps);
if (y !== this.lastY) {
// Place the header span
this.headerSpanElement.style.transform =
'translate(0px, ' + y + 'px)';
}
if (this.toggleElement) {
var toggleLabel = this.detail.collapsed ? 'collapsed' : 'expanded';
this.toggleElement.dataset.l10nId = 'gaia-grid-toggle-' + toggleLabel;
}
// Calculate the height of the background span
if (this.detail.collapsed) {
this.backgroundHeight =
Math.round(this.collapseRatio * this.grid.layout.gridIconSize * 1.5);
} else {
var height = Math.ceil(nApps / this.grid.layout.cols);
this.backgroundHeight = (height || 1) * this.grid.layout.gridItemHeight;
}
this.backgroundHeight += this.headerHeight;
if (y != this.lastY ||
this.backgroundHeight !== this.lastBackgroundHeight) {
// Place and size the background span element
this.backgroundSpanElement.style.transform =
'translate(0px, ' + y + 'px) scale(1, ' + this.backgroundHeight + ')';
// Place and size the shadow span element
this.shadowSpanElement.style.transform =
'translateY(' + y + 'px)';
this.shadowSpanElement.style.height = this.backgroundHeight + 'px';
// Place the divider after this point
this.dividerSpanElement.style.transform =
'translate(0px, ' + (y + this.backgroundHeight) + 'px)';
}
// Update the cached size values
this.lastBackgroundHeight = this.backgroundHeight;
this.lastY = y;
// Now include the separator in the background height
this.backgroundHeight += this.separatorHeight;
// Fade in newly-created groups
if (createdElements) {
// Force a reflow to make sure the transform transition doesn't play
this.element.clientTop;
this.element.classList.remove('newly-created');
}
},
forEachItem: function(callback) {
var nApps = this.size;
var index = this.detail.index;
for (var i = index - nApps; i < index; i++) {
callback(this.grid.items[i]);
}
},
setActive: function(active) {
GaiaGrid.GridItem.prototype.setActive.call(this, active);
// Make sure we're collapsed
this.collapse();
// Mark our child items as active/inactive with us so they pick up the
// right style when dragged.
var callback = active ?
function(item) { item.element.classList.add('active'); } :
function(item) { item.element.classList.remove('active'); };
this.forEachItem(callback);
},
/*
* As the start of a collapse/expand animation can involve a lot of work,
* the response isn't always instant. To help alleviate this, we show a
* quick response on the toggle element first before initiating the rest
* of the work.
*/
_rerenderOnToggle: function() {
if (this.pendingCollapseTimeout) {
clearTimeout(this.pendingCollapseTimeout);
this.pendingCollapseTimeout = null;
}
if (this.toggleElement) {
this.toggleElement.classList.add('toggling');
setTimeout(() => { this.toggleElement.classList.remove('toggling'); },
TOGGLE_TIMEOUT);
}
// Ideally, this setTimeout would be a requestAnimationFrame, but the
// above class-adding seems to end up coalesced with the code inside
// requestAnimationFrame, rather than it happening on the next frame.
// The same thing happens with any setTimeout lower than about 20ms too.
this.pendingCollapseTimeout = setTimeout(() => {
this.detail.collapsed = this.pendingCollapse;
this.pendingCollapseTimeout = null;
if (!this.detail.collapsed) {
// Remove collapsed styling from all icons
this.forEachItem(function(item) {
item.scale = 1;
if (item.element) {
item.element.classList.remove('collapsed');
item.element.classList.remove('hidden');
}
});
}
// If the item is active, re-render it so that its pixel-height is
// correct when the rest of the grid is rendered.
if (this.active) {
this.render();
}
this.grid.render();
var dragging = this.grid.dragdrop && this.grid.dragdrop.inDragAction;
if (dragging) {
// If we're dragging, make sure to reposition the icon in the correct
// place, as the render call won't redraw us
this.grid.dragdrop.updateIconPosition();
} else {
// If we're not dragging, save the collapsed state
window.dispatchEvent(new CustomEvent('gaiagrid-saveitems'));
// Request attention so that we're as visible as we can be after
// expanding/collapsing
this.requestAttention();
}
}, 20);
},
collapse: function() {
if (this.detail.collapsed) {
return;
}
this.pendingCollapse = true;
this._rerenderOnToggle();
},
expand: function() {
if (!this.detail.collapsed) {
return;
}
this.pendingCollapse = false;
this._rerenderOnToggle();
},
launch: function(target) {
if (target !== this.toggleElement) {
return;
}
if (this.detail.collapsed) {
this.expand();
} else {
this.collapse();
}
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
},
isDraggable: function() {
return true;
},
requestAttention: function() {
var rect = {
x: 0,
y: this.getRealYPosition(this.size),
width: this.gridWidth * this.grid.layout.gridItemWidth,
height: this.backgroundHeight
};
this.grid.element.dispatchEvent(
new CustomEvent('gaiagrid-attention', { detail: rect }));
}
};
exports.GaiaGrid.Divider = Group; // Override the non-grouping divider
}(window));

View file

@ -0,0 +1,51 @@
'use strict';
/* global GaiaGrid */
/* global GridIconRenderer */
/* global MozActivity */
/* jshint nonew: false */
(function(exports) {
const TYPE = 'marketplace-app';
/**
* Represents a single bookmark icon on the homepage.
*/
function MarketPlaceApp(record) {
this.detail = record;
this.detail.type = TYPE;
}
MarketPlaceApp.prototype = {
__proto__: GaiaGrid.Bookmark.prototype,
renderer: GridIconRenderer.TYPE.STANDARD,
/**
* Returns the icon image path.
*/
get icon() {
var icon = this._accurateIcon;
if (!icon) {
icon = this._accurateIcon = this.closestIconFromList(
this.detail.icons);
}
return icon;
},
launch: function() {
new MozActivity({
name: 'marketplace-app',
data: {
slug: this.detail.slug
}
});
},
};
exports.GaiaGrid.MarketPlaceApp = MarketPlaceApp;
}(window));

View file

@ -0,0 +1,369 @@
'use strict';
/* global ConfirmDialogHelper, GaiaGrid */
(function(exports) {
const IDENTIFIER_SEP = '-';
var MOZAPP_EVENTS = [
'downloadsuccess',
'downloaderror',
'downloadavailable',
'downloadapplied',
'progress'
];
var APP_LOADING = 'loading';
var APP_ERROR = 'error';
var APP_UNRECOVERABLE = 'unrecoverable';
var APP_PAUSED = 'paused';
var APP_READY = 'ready';
function localizeString(str) {
var userLang = document.documentElement.lang;
// We want to make sure that we translate only if we're using
// a runtime pseudolocale.
// mozL10n.ctx.qps contains only runtime pseudolocales
if (navigator.mozL10n &&
navigator.mozL10n.ctx.qps.indexOf(userLang) !== -1) {
return navigator.mozL10n.qps[userLang].translate(str);
}
return str;
}
/**
* Represents single app icon on the homepage.
*/
function Mozapp(app, entryPoint, details) {
this.app = app;
this.entryPoint = entryPoint;
this.detail = {
type: 'app',
manifestURL: app.manifestURL,
entryPoint: entryPoint,
index: 0,
// XXX: Somewhat ugly hack around the constructor args
decoratedIconBlob: details && details.decoratedIconBlob
};
// Yes this is clobbering all other listeners to the app MozApp
// should be in charge here otherwise the icon will be incorrect.
MOZAPP_EVENTS.forEach(function(type) {
this.app['on' + type] = this.handleEvent.bind(this);
}, this);
// Determine and set the initial state of the app when it is inserted.
this.setAppState(this._determineState(app));
}
/**
`app.manifest.role`s which should not be displayed on the grid.
*/
Mozapp.HIDDEN_ROLES = [
'system', 'input', 'homescreen', 'search', 'addon', 'langpack'
];
Mozapp.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
Determine if this application is supposed to be hidden.
@return Boolean
*/
_isHiddenRole: function() {
var manifest = this.app.manifest;
// don't know what the role is so it is not hidden
if (!manifest || !manifest.role) {
return null;
}
// if it is in the hidden role list return true
return Mozapp.HIDDEN_ROLES.indexOf(manifest.role) !== -1;
},
/**
Figure out which state the app is in
*/
_determineState: function(app) {
// is there is a pending download but we cannot download then this app is
// in an unrecoverable state due to some fatal error.
if (
// Must be pending (meaning not launchable)
app.installState === 'pending' &&
// Without any option for downloading the app
!app.downloadAvailable &&
// And not be currently applying a download
!app.readyToApplyDownload
) {
return APP_UNRECOVERABLE;
}
// If the app is currently downloading life is good and we should display
// loading.
if (app.downloading) {
return APP_LOADING;
}
// Bug 1027347 - downloadError is always present even if there is no error
// so we check both for the error and that the error has a
// name.
if (app.downloadError && app.downloadError.name) {
// If the app was paused while the homescreen was running the
// downloaderror event will fire with the DOWNLOAD_CANCELED error.
if (app.downloadError.name === 'DOWNLOAD_CANCELED') {
return APP_PAUSED;
}
return APP_ERROR;
}
// Note that this logic is correct because app.downloading is caught above
// separately.
if (app.installState === 'pending') {
return APP_PAUSED;
}
return APP_READY;
},
handleEvent: function(event) {
switch (event.type) {
case 'progress':
case 'downloaderror':
case 'downloadsuccess':
this.setAppState(this._determineState(this.app));
break;
case 'downloadapplied':
// Ensure that a hidden app has not somehow been added to the grid...
if (this._isHiddenRole()) {
console.warn('Removing hidden app from the grid', this.name);
return this.removeFromGrid();
}
// we may have updated icons so recalculate the correct icon.
delete this._accurateIcon;
delete this._accurateIconSize;
// Need to set app state here to correctly handle the pause/resume
// case.
this.setAppState(this._determineState(this.app));
// XXX: This introduces a potential race condition.
// GridItem.renderIcon is not concurrency safe one image may override
// another without ordering.
this.renderIcon();
window.dispatchEvent(new CustomEvent('downloadapplied', {
detail: {
id: this.app.manifestURL
}
}));
break;
}
},
// Overrides GridItem.fetchIconBlob
fetchIconBlob: function() {
if (!this._accurateIconSize) {
this._accurateIconSize =
this.closestIconSizeFromList(this.descriptor.icons);
}
var result = this._accurateIconSize ?
navigator.mozApps.mgmt.getIcon(this.app, this._accurateIconSize,
this.entryPoint || '') :
GaiaGrid.GridItem.prototype.fetchIconBlob.call(this);
return result;
},
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
get name() {
var userLang = document.documentElement.lang;
if (navigator.mozL10n && userLang in navigator.mozL10n.qps) {
return navigator.mozL10n.qps[userLang].
translate(this.descriptor.short_name || this.descriptor.name);
}
var locales = this.descriptor.locales;
var localized =
locales && locales[userLang] &&
(locales[userLang].short_name || locales[userLang].name);
return localized || this.descriptor.short_name || this.descriptor.name;
},
asyncName: function() {
var userLang = document.documentElement.lang;
var ep = this.entryPoint || undefined;
if (!this.app.getLocalizedValue) {
return new Promise((resolve, reject) => { reject(); });
}
return this.app.getLocalizedValue('short_name', userLang, ep).then(
shortName => localizeString(shortName),
this.app.getLocalizedValue.bind(this.app, 'name', userLang, ep)).then(
name => localizeString(name),
() => this.name
);
},
/**
* Returns the icon image path.
*/
get icon() {
var icon = this._accurateIcon;
if (!icon) {
icon = this._accurateIcon = this.closestIconFromList(
this.descriptor.icons);
}
return icon;
},
get descriptor() {
var manifest = this.app.manifest || this.app.updateManifest;
if (this.entryPoint && manifest.entry_points &&
manifest.entry_points[this.entryPoint]) {
return manifest.entry_points[this.entryPoint];
}
return manifest;
},
identifierSeparator: IDENTIFIER_SEP,
get identifier() {
var identifier = [this.app.manifestURL];
if (this.entryPoint) {
identifier.push(this.entryPoint);
}
return identifier.join(IDENTIFIER_SEP);
},
/**
* Returns true if this app is removable.
*/
isRemovable: function() {
return this.app.removable;
},
/**
Show a dialog to handle unrecoverable errors.
*/
unrecoverableError: function() {
navigator.mozApps.mgmt.uninstall(this.app);
},
cancel: function() {
var dialog = new ConfirmDialogHelper({
type: 'pause',
title: {id: 'gaia-grid-stop-download-title', args: { name: this.name }},
body: 'gaia-grid-stop-download-body',
cancel: {
title: 'gaia-grid-cancel'
},
confirm: {
title: 'gaia-grid-stop-download-action',
type: 'danger',
cb: () => this.app.cancelDownload()
}
});
dialog.show(document.body);
},
resume: function() {
var dialog = new ConfirmDialogHelper({
type: 'resume',
title: 'gaia-grid-resume-download-title',
body: {id: 'gaia-grid-resume-download-body', args: { name: this.name }},
cancel: {
title: 'gaia-grid-cancel'
},
confirm: {
title: 'gaia-grid-resume-download-action',
cb: () => {
// enter the loading state optimistically
this.setAppState(APP_LOADING);
this.app.download();
}
}
});
dialog.show(document.body);
},
setAppState: function(state) {
this.appState = state;
if (this.element) {
this.element.dataset.appState = state;
}
},
render: function() {
// If there is no element before we can render we need to update the
// app state. Render may also be called to reposition elements.
var needsStateSet = !this.element;
GaiaGrid.GridItem.prototype.render.apply(this, arguments);
if (needsStateSet) {
// ensure the newly rendered item has an app state
this.setAppState(this.appState);
}
},
/**
* Resolves click action.
*/
launch: function() {
var app = this.app;
switch (this._determineState(app)) {
case APP_UNRECOVERABLE:
return this.unrecoverableError();
case APP_ERROR:
case APP_PAUSED:
return this.resume();
case APP_LOADING:
return this.cancel();
}
window.performance.mark('appLaunch@' + app.origin);
if (this.entryPoint) {
return app.launch(this.entryPoint);
}
// Default action is to launch the app.
return app.launch();
}
};
exports.GaiaGrid.Mozapp = Mozapp;
}(window));

View file

@ -0,0 +1,73 @@
'use strict';
/* global GaiaGrid */
(function(exports) {
/**
* The placeholder represents an empty place on the grid.
* This is generally used to detect distance from an empty spot on the grid.
*/
function Placeholder() {
this.detail = {
type: 'placeholder',
index: 0
};
}
Placeholder.prototype = {
__proto__: GaiaGrid.GridItem.prototype,
/**
* Returns the height in pixels of each icon.
*/
get pixelHeight() {
return this.grid.layout.gridItemHeight;
},
/**
* When the placeholder is rendered in the last row, for group creation
* purposes, there is special handling required for height.
*/
createsGroupOnDrop: false,
/**
* Width in grid units for each icon.
*/
gridWidth: 1,
/**
* Placeholders do not save. They are re-generated on render calls.
*/
persistToDB: false,
get name() {
return '';
},
/**
* Renders a transparent placeholder.
*/
render: function() {
// Generate an element if we need to
if (!this.element) {
var tile = document.createElement('div');
tile.className = 'icon placeholder';
tile.style.height = this.pixelHeight + 'px';
this.element = tile;
this.grid.element.appendChild(tile);
}
this.transform(this.x, this.y, this.grid.layout.percent);
},
remove: function() {
if (this.element) {
this.element.parentNode.removeChild(this.element);
}
}
};
exports.GaiaGrid.Placeholder = Placeholder;
}(window));