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,92 @@
'use strict';
/* global MozActivity */
/* global Bookmarks */
(function(exports) {
/**
* The search context menu handles bookmarking of search
* results when the users long presses them.
*/
function Contextmenu() {
this.menu = document.getElementById('contextmenu');
this.grid = document.getElementById('icons');
this.bookmarkButton = this.menu.querySelector('#add-to-homescreen');
this.grid.addEventListener('contextmenu', this);
this.bookmarkButton.addEventListener('click', this);
}
Contextmenu.prototype = {
/**
* The current icon target.
*/
icon: null,
handleEvent: function(e) {
switch(e.type) {
case 'contextmenu':
var identifier = e.target.dataset.identifier;
var icon = this.grid.getIcon(identifier);
// Only show for bookmark targets
if (!icon || icon.detail.type !== 'bookmark') {
return;
}
var url = icon.detail.url;
if (Bookmarks.get(url)) {
// Bookmark already installed in device
return;
}
// In order to benefit from the system contextmenu in such a way that
// it overlaps search bar, let's create a contextmenu attribute on
// the fly, and remove it once the event dispatching is done.
e.target.setAttribute('contextmenu', 'contextmenu');
// Stop the grid from listening to events to prevent it from
// receiving a touchend event and launching the icon. This is
// restored on next tick.
this.grid.stop();
setTimeout(() => {
e.target.removeAttribute('contextmenu');
this.grid.start();
});
this.icon = icon;
break;
case 'click':
/* jshint nonew: false */
/**
* We could have several cases for icons in the search grid:
* - Icons from e.me: this.icon.icon will be url
* - Icons from places: this.icon.icon can be:
* - a blob, cached icon
* - a icon url
* To solve this, added an extra feature attribute: iconUrl,
* so GaiaGridItems with this extra attribute in the detail
* will have a way to keep track of the original url.
*/
var iconUrl = this.icon.detail.iconUrl || this.icon.icon;
new MozActivity({
name: 'save-bookmark',
data: {
type: 'url',
url: this.icon.detail.url,
name: this.icon.name,
icon: iconUrl
}
});
break;
}
}
};
exports.Contextmenu = Contextmenu;
}(window));

197
gaia/apps/search/js/newtab.js Executable file
View file

@ -0,0 +1,197 @@
'use strict';
/* global asyncStorage, MozActivity, SettingsListener */
(function(exports) {
var isManuallyPrivate = window.location.search.includes('private=1');
var isManuallyRegular = window.location.search.includes('private=0');
var isPrivateByDefault = false;
SettingsListener.observe('browser.private.default', false, function (value) {
if (value) {
isPrivateByDefault = true;
} else {
isPrivateByDefault = false;
}
exports.newtab.togglePrivacyMode();
});
/**
* The main Newtab page object.
* Instantiates places to populate history and top sites.
*/
function Newtab() {
// Initialize the parent port connection
var self = this;
navigator.mozApps.getSelf().onsuccess = function() {
var app = this.result;
app.connect('search-results').then(function onConnAccepted(ports) {
ports.forEach(function(port) {
self._port = port;
});
self.init();
}, function onConnectionRejected(reason) {
console.log('Error connecting: ' + reason + '\n');
});
};
this.togglePrivacyMode();
}
Newtab.prototype = {
/**
* A reference to the Places provider.
*/
provider: null,
/**
* Initializes top sites and history.
*/
init: function() {
this.provider.init();
this.provider.searchObj = this;
},
/**
* Requests a screenshot of the page from the system app.
*/
requestScreenshot: function(url) {
this._port.postMessage({
'action': 'request-screenshot',
'url': url
});
},
/**
* Requests that the system app opens a new private window.
*/
requestPrivateWindow: function() {
this._port.postMessage({
'action': 'private-window'
});
},
/**
* Toggles the privacy mode.
*
* When private browsing _IS_ the default and we _ARE NOT_ manually being
* regular or when private browsing _IS NOT_ the default and we _ARE_
* manually bring private: Makes this window look like a private window by
* applying the necessary theme color and background. The window can't be
* truly private, otherwise we wouldn't have access to local history and
* bookmarks.
*
* When private browsing _IS_ the default and we _ARE_ manually regular:
* Makes this window look normal by removing private theme settings.
*/
togglePrivacyMode: function() {
var themeMeta = document.querySelector('meta[name="theme-color"]');
var themeColor = document.createElement('meta');
if ((isPrivateByDefault && !isManuallyRegular) ||
(!isPrivateByDefault && isManuallyPrivate)) {
if (themeMeta) {
document.head.removeChild(themeMeta);
}
themeColor.setAttribute('name', 'theme-color');
themeColor.setAttribute('content', '#392E54');
document.head.appendChild(themeColor);
document.body.classList.add('private');
// Show the private dialog if needed.
asyncStorage.getItem('shouldSuppressPrivateDialog', value => {
if (value) {
return;
}
this.privateBrowserDialog = document.getElementById(
'private-window-dialog');
this.privateBrowserDialog.removeAttribute('hidden');
this.privateBrowserDialogClose = document.getElementById(
'private-window-hide-dialog');
this.privateBrowserDialogClose.addEventListener('click', this);
this.privateBrowserLearnMore = document.getElementById(
'private-learn-more');
this.privateBrowserLearnMore.addEventListener('click', this);
});
}
if (isPrivateByDefault && isManuallyRegular) {
if (themeMeta) {
document.head.removeChild(themeMeta);
}
}
},
/**
* General event handler.
*/
handleEvent: function(e) {
switch(e.target) {
case this.privateBrowserDialogClose:
this.hidePrivateBrowserDialog();
break;
case this.privateBrowserLearnMore:
this.learnAboutPrivateBrowsing(e);
break;
}
},
/**
* Hides the private browser dialog.
*/
hidePrivateBrowserDialog: function() {
this.privateBrowserDialog.setAttribute('hidden', true);
// Set async storage value if we have checked the box so we don't
// attempt to show it again.
var checkbox = this.privateBrowserDialog.querySelector('gaia-checkbox');
if (checkbox.checked) {
asyncStorage.setItem('shouldSuppressPrivateDialog', true);
}
},
/**
* Opens the learn more dialog.
*/
learnAboutPrivateBrowsing: function(e) {
e.preventDefault();
/* jshint nonew: false */
new MozActivity({
name: 'view',
data: {
type: 'url',
url: e.target.href
}
});
}
};
exports.newtab = new Newtab();
/**
* Stub search object to populate providers.
* TODO: We should split up the places provider into some data layer where
* the search or newtab page could leverage it.
*/
exports.Search = {
provider: function(provider) {
exports.newtab.provider = provider;
},
/**
* Opens a browser to a URL.
* @param {String} url The url to navigate to
*/
navigate: function(url) {
new MozActivity({name:'view',data:{type:'url',url:url}});
}
};
})(window);

180
gaia/apps/search/js/places_idb.js Executable file
View file

@ -0,0 +1,180 @@
/* globals Promise, asyncStorage, IDBKeyRange */
(function(exports) {
'use strict';
var DB_NAME = 'places_idb_store';
var DB_VERSION = 3;
var PLACES_STORE = 'places';
var VISITS_STORE = 'visits';
function PlacesIdbStore() {}
PlacesIdbStore.prototype = {
db: null,
latestRevision: null,
init: function() {
var self = this;
return new Promise(function(resolve, reject) {
var req = window.indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = self.upgradeSchema;
req.onsuccess = function(e) {
self.db = e.target.result;
asyncStorage.getItem('latest-revision', function(value) {
self.latestRevision = value || 0;
resolve();
});
};
});
},
upgradeSchema: function(e) {
var db = e.target.result;
var fromVersion = e.oldVersion;
if (fromVersion < 1) {
var places = db.createObjectStore(PLACES_STORE, { keyPath: 'url' });
places.createIndex('frecency', 'frecency', { unique: false });
places.createIndex('visited', 'visited', { unique: false });
}
if (fromVersion < 2) {
asyncStorage.removeItem('latest-revision');
var visits = db.createObjectStore(VISITS_STORE, { keyPath: 'date' });
visits.createIndex('date', 'date', { unique: true });
}
if (fromVersion < 3) {
var oStore = e.target.transaction.objectStore(VISITS_STORE);
oStore.createIndex('url', 'url', { unique: false });
}
},
add: function(id, data, rev) {
return new Promise((resolve, reject) => {
var txn = this.db
.transaction([PLACES_STORE, VISITS_STORE], 'readwrite');
if (data.visits && data.visits.length === 0) {
// Removed via browsing history clearing, let's remove it.
this._removeUrl(id).then(this.saveAndResolve(rev, resolve));
} else {
txn.objectStore(PLACES_STORE).put(data);
if (!data.visits) {
data.visits = Number.isFinite(data.visited) ? [data.visited] : [];
}
var visitsStore = txn.objectStore(VISITS_STORE);
data.visits.filter(Number.isFinite).forEach(function(date) {
visitsStore.put({
date: date,
url: data.url,
title: data.title,
icons: data.icons
});
});
txn.oncomplete = this.saveAndResolve(rev, resolve);
}
});
},
addPlace: function(place) {
return new Promise((resolve, reject) => {
var txn = this.db.transaction([PLACES_STORE], 'readwrite');
txn.objectStore(PLACES_STORE).put(place);
txn.oncomplete = resolve;
});
},
remove: function(id, rev) {
return new Promise((resolve, reject) => {
this._removeUrl(id).then(this.saveAndResolve(rev, resolve));
});
},
_removeUrl: function(id) {
return Promise.all([
// Remove the url from places store.
new Promise((resolve, reject) => {
var txn = this.db.transaction([PLACES_STORE], 'readwrite');
txn.objectStore(PLACES_STORE).delete(id);
txn.oncomplete = resolve;
}),
// Clear visits from this url.
new Promise((resolve, reject) => {
var txn = this.db.transaction([VISITS_STORE], 'readwrite');
var visitsStore = txn.objectStore(VISITS_STORE);
var urlIndex = visitsStore.index('url');
var req = urlIndex.openCursor(IDBKeyRange.only(id));
req.onsuccess = function() {
var cursor = req.result;
if (cursor) {
visitsStore.delete(cursor.primaryKey);
cursor.continue();
} else {
resolve();
}
};
})
]);
},
clear: function(rev) {
return new Promise((resolve, reject) => {
var txn = this.db
.transaction([PLACES_STORE, VISITS_STORE], 'readwrite');
txn.objectStore(PLACES_STORE).clear();
txn.objectStore(VISITS_STORE).clear();
txn.oncomplete = this.saveAndResolve(rev, resolve);
});
},
saveAndResolve: function(rev, resolve) {
return function() {
asyncStorage.setItem('latest-revision', rev);
resolve();
};
},
readStore: function(store, index, limit, done, filter) {
var results = [];
var txn = this.db.transaction(store, 'readonly');
var oStore = txn.objectStore(store);
oStore.index(index).openCursor(null, 'prev').onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
if (!filter || filter(cursor.value)) {
results.push(cursor.value);
}
if (results.length < limit) {
cursor.continue();
}
}
};
txn.oncomplete = function() {
done(results);
};
},
read: function(index, limit, done, filter) {
this.readStore(PLACES_STORE, index, limit, done, filter);
},
readVisits: function(limit, done, filter) {
this.readStore(VISITS_STORE, 'date', limit, done, filter);
}
};
exports.PlacesIdbStore = PlacesIdbStore;
})(window);

View file

@ -0,0 +1,72 @@
/* globals DataGridProvider, GaiaGrid, Search,
Promise, SyncDataStore, InMemoryStore */
(function(exports) {
'use strict';
var STORE_NAME = 'bookmarks_store';
function formatBookmark(bookmark) {
return {
data: new GaiaGrid.Bookmark({
id: bookmark.url,
name: bookmark.name || bookmark.url,
url: bookmark.url,
icon: bookmark.icon
})
};
}
function Bookmarks() {}
Bookmarks.prototype = {
__proto__: DataGridProvider.prototype,
name: 'Bookmarks',
init: function() {
DataGridProvider.prototype.init.apply(this, arguments);
this.persistStore = new InMemoryStore();
this.syncStore = new SyncDataStore(STORE_NAME, this.persistStore, 'url');
return this.syncStore.sync();
},
get: function(url) {
var results = this.persistStore.results;
for (var idx in results) {
var item = results[idx];
if (item.url === url) {
return formatBookmark(item);
}
}
return null;
},
search: function(filter) {
var self = this;
return new Promise(function(resolve, reject) {
var renderResults = [];
filter = filter.toLowerCase();
for(var elem in self.persistStore.results) {
var bookmark = self.persistStore.results[elem];
var name = ('name' in bookmark) && bookmark.name.toLowerCase();
if ((!bookmark.url || bookmark.url.indexOf(filter) == -1) &&
(!name || name.indexOf(filter) == -1)) {
continue;
}
renderResults.push(formatBookmark(bookmark));
}
resolve(renderResults);
});
}
};
exports.Bookmarks = new Bookmarks();
Search.provider(exports.Bookmarks);
}(window));

View file

@ -0,0 +1,77 @@
(function() {
'use strict';
/* global MozActivity, Promise, Provider, Search */
function Contacts() {
}
Contacts.prototype = {
__proto__: Provider.prototype,
name: 'Contacts',
click: function(e) {
var target = e.target;
Search.close();
/* jshint nonew: false */
new MozActivity({
name: 'open',
data: {
type: 'webcontacts/contact',
params: {
'id': target.dataset.contactId
}
}
});
},
search: function(input) {
return new Promise((resolve, reject) => {
var options = {
filterValue: input,
filterBy: ['givenName'],
filterOp: 'startsWith'
};
this.clear();
var request = navigator.mozContacts.find(options);
request.onsuccess = (function() {
var results = request.result;
if (!results.length) {
return reject();
}
var renderResults = [];
for (var i = 0; i < results.length; i++) {
var result = results[i];
for (var j = 0; j < result.name.length; j++) {
var renderObj = {
title: result.name[j],
dataset: {
contactId: result.id
}
};
if (result.photo) {
renderObj.icon = result.photo[0];
}
renderResults.push(renderObj);
}
}
resolve(renderResults);
}).bind(this);
request.onerror = function() {
};
});
}
};
Search.provider(new Contacts());
}());

View file

@ -0,0 +1,33 @@
/* global Provider */
'use strict';
function DataGridProvider() {
}
DataGridProvider.prototype = {
__proto__: Provider.prototype,
name: 'DataGridProvider',
isGridProvider: true,
init: function() {
this.grid = document.getElementById('icons');
},
clear: function() {
this.grid.clear();
},
render: function(results) {
results.forEach(function(config, index) {
this.grid.add(config.data);
}, this);
this.grid.render({
skipDivider: true
});
}
};

View file

@ -0,0 +1,170 @@
/* global Search, DataGridProvider, GaiaGrid, Promise */
(function() {
'use strict';
function LocalApps() {
this.apps = {};
this.appListing = [];
this.blacklist;
var mozApps = navigator.mozApps.mgmt;
mozApps.oninstall = e => {
var app = e.application;
var processApp = () => {
this.apps[app.manifestURL] = app;
this.createAppListing();
};
if (app.downloading) {
app.ondownloadapplied = processApp;
} else {
processApp();
}
};
mozApps.onuninstall = e => {
delete this.apps[e.application.manifestURL];
this.createAppListing();
};
mozApps.getAll().onsuccess = e => {
e.target.result.forEach(app => {
this.apps[app.manifestURL] = app;
});
this.createBlacklist().then(() => {
this.createAppListing();
});
};
}
LocalApps.prototype = {
__proto__: DataGridProvider.prototype,
name: 'LocalApps',
dedupes: true,
dedupeStrategy: 'exact',
createBlacklist: function() {
return new Promise((resolve, reject) => {
var self = this;
var key = 'app.launch_path.blacklist';
var req = navigator.mozSettings.createLock().get(key);
req.onsuccess = function onsuccess() {
self.blacklist = req.result[key] || [];
resolve();
};
});
},
createAppListing: function() {
var appListing = [];
var blacklist = this.blacklist;
var manifestURLs = Object.keys(this.apps);
manifestURLs.forEach(function eachManifest(manifestURL) {
var app = this.apps[manifestURL];
var manifest = app.manifest || app.updateManifest;
var HIDDEN_ROLES = [
'system', 'input', 'homescreen', 'search', 'theme', 'addon',
'langpack'
];
if (HIDDEN_ROLES.indexOf(manifest.role) !== -1) {
return;
}
var entryPoints = manifest.entry_points;
if (entryPoints) {
for (var i in entryPoints) {
var entry = entryPoints[i];
entry.entryPoint = i;
entry.manifestURL = app.manifestURL;
appListing.push(entry);
}
} else {
manifest.manifestURL = app.manifestURL;
appListing.push(manifest);
}
if (blacklist) {
blacklist.forEach(function(blackentry) {
for (var i in appListing) {
var app = appListing[i];
if (app.launch_path === blackentry) {
appListing.splice(i, 1);
}
}
});
}
}, this);
this.appListing = appListing;
},
removeAppListing: function(app) {
for (var i in this.appListing) {
var manifest = this.appListing[i];
if (manifest.manifestURL === app.manifestURL) {
this.appListing.splice(i, 1);
}
}
},
search: function(input) {
return new Promise((resolve, reject) => {
var results = this.find(input);
var formatted = [];
results.forEach(function eachResult(result) {
formatted.push({
dedupeId: result.app.manifestURL,
data: new GaiaGrid.Mozapp(result.app, result.entryPoint)
});
}, this);
resolve(formatted);
});
},
/**
* Checks if an app manifest matches a query.
*/
matches: function(manifest, query) {
query = query.toLowerCase();
// Get the localized name from the query.
var shortName = manifest.short_name || '';
var userLang = document.documentElement.lang;
var locales = manifest.locales;
var localized = locales && locales[userLang] && locales[userLang].name;
localized = localized || '';
return shortName.toLowerCase().indexOf(query) != -1 ||
manifest.name.toLowerCase().indexOf(query) != -1 ||
localized.toLowerCase().indexOf(query) != -1;
},
find: function(query) {
var results = [];
this.appListing.forEach(function(manifest) {
if (this.matches(manifest, query)) {
results.push({
app: this.apps[manifest.manifestURL],
entryPoint: manifest.entryPoint
});
}
}, this);
return results;
}
};
Search.provider(new LocalApps());
}());

View file

@ -0,0 +1,85 @@
/* global Search, DataGridProvider, GaiaGrid, Promise */
(function() {
'use strict';
const NUM_DISPLAY = 4;
var apiUrl = null;
function Marketplace() {}
Marketplace.prototype = {
__proto__: DataGridProvider.prototype,
name: 'Marketplace',
dedupes: true,
dedupeStrategy: 'exact',
remote: true,
init: function() {
DataGridProvider.prototype.init.apply(this, arguments);
var urlKey = 'search.marketplace.url';
var req = navigator.mozSettings.createLock().get(urlKey);
req.onsuccess = function () {
if (req.result[urlKey]) {
apiUrl = req.result[urlKey]
.replace('{limit}', NUM_DISPLAY)
.replace('{lang}', document.documentElement.lang);
}
};
},
search: function(input) {
return new Promise((resolve, reject) => {
this.abort();
if (!input || !apiUrl) {
return resolve([]);
}
var req = new XMLHttpRequest();
req.open('GET', apiUrl.replace('{q}', encodeURIComponent(input)), true);
req.onload = (function onload() {
var results = JSON.parse(req.responseText);
if (!results.length) {
resolve([]); return;
}
var length = Math.min(NUM_DISPLAY, results.length);
var formatted = [];
for (var i = 0; i < length; i++) {
var app = results[i];
formatted.push({
dedupeId: app.manifest_url,
data: new GaiaGrid.MarketPlaceApp({
id: app.manifest_url,
name: navigator.mozL10n.get('install-marketplace-title', {
title: app.name
}),
icons: app.icons,
slug: app.slug
})
});
}
resolve(formatted);
}).bind(this);
req.onerror = function onerror() {
console.log('Marketplace error.');
reject();
};
req.ontimeout = function ontimeout() {
console.log('Marketplace timeout.');
reject();
};
req.send();
this.request = req;
});
}
};
Search.provider(new Marketplace());
}());

View file

@ -0,0 +1,400 @@
/* globals Provider, SyncDataStore, Promise, IconsHelper */
/* globals Search, PlacesIdbStore */
/* globals DateHelper */
/* globals asyncStorage */
/* globals LazyLoader */
(function(exports) {
'use strict';
var _ = navigator.mozL10n.get;
// Maximum number of results to show show for a single query
var MAX_AWESOME_RESULTS = 4;
var MAX_HISTORY_RESULTS = 20;
var MAX_TOPSITES_RESULTS = 6;
// Name of the datastore we pick up places from
var STORE_NAME = 'places';
var topSitesWrapper = document.getElementById('top-sites');
var historyWrapper = document.getElementById('history');
// These elements are only included in the newtab page.
if (topSitesWrapper && historyWrapper) {
topSitesWrapper.addEventListener('click', itemClicked);
historyWrapper.addEventListener('click', itemClicked);
}
var cachedLink = document.createElement('a');
function parseUrl(url) {
cachedLink.href = url;
return cachedLink;
}
// Storage for locally fetched icons
var icons = {};
var iconUrls = {};
function getIcon(place) {
if (place.url in icons && icons[place.url]) {
return icons[place.url];
}
IconsHelper.getIcon(place.url, null, place).then(icon => {
saveIcon(place.url, icon);
});
return false;
}
function saveIcon(key, url) {
if (key in icons) {
return;
}
icons[key] = null;
iconUrls[key] = url;
fetchIcon(url, function(err, icon) {
if (!err) {
icons[key] = icon;
}
});
}
function fetchIcon(uri, callback) {
var xhr = new XMLHttpRequest({mozSystem: true});
xhr.open('GET', uri, true);
xhr.responseType = 'blob';
xhr.addEventListener('load', function() {
// Check icon was successfully downloded
// 0 is due to https://bugzilla.mozilla.org/show_bug.cgi?id=716491
if (!(xhr.status === 200 || xhr.status === 0)) {
return callback(new Error('error_downloading'));
}
var blob = xhr.response;
if (blob.size > this.MAX_ICON_SIZE) {
return callback(new Error('image_to_large'));
}
// Only save the icon if it can be loaded as an image bigger than 0px
var img = document.createElement('img');
img.src = window.URL.createObjectURL(blob);
img.onload = function() {
window.URL.revokeObjectURL(img.src);
if (img.naturalWidth <= 0) {
return callback(new Error('Cannot load image'));
}
callback(null, blob);
};
img.onerror = function() {
window.URL.revokeObjectURL(img.src);
return callback(new Error('Cannot load image'));
};
});
xhr.onerror = function(err) {
return callback(new Error('Cannot load uri'));
};
xhr.send();
}
function itemClicked(e) {
if (e.target.dataset.url) {
Search.navigate(e.target.dataset.url);
}
}
var listTemplate = createList();
function createList() {
var list = document.createElement('ul');
list.setAttribute('role', 'listbox');
return list;
}
function incrementHistoryThreshold(timestamp, currentThreshold, thresholds) {
var newThreshold = currentThreshold += 1;
if (timestamp < thresholds[newThreshold]) {
return incrementHistoryThreshold(timestamp, newThreshold, thresholds);
}
return newThreshold;
}
function drawHistoryHeading(parent, threshold, timestamp) {
var LABELS = [
'future',
'today',
'yesterday',
'last-7-days',
'this-month',
'last-6-months',
'older-than-6-months'
];
var text = '';
// Special case for month headings
if (threshold == 5 && timestamp) {
var date = new Date(timestamp);
var now = new Date();
text = _('month-' + date.getMonth());
if (date.getFullYear() != now.getFullYear()) {
text += ' ' + date.getFullYear();
}
} else {
text = _(LABELS[threshold]);
}
var h3 = document.createElement('h3');
var spanNode = document.createElement('span');
spanNode.textContent = text;
var ul = listTemplate.cloneNode(true);
h3.appendChild(spanNode);
parent.appendChild(h3);
parent.appendChild(ul);
}
function updateIcon(visit, iconWrapper) {
var iconDom = iconWrapper.querySelector('img');
IconsHelper.getIcon(visit.url, null, visit).then((icon) => {
if (icon && iconDom) {
iconDom.onload = function () {
iconWrapper.classList.remove('empty');
iconDom.style.display = 'block';
};
iconDom.src = icon;
}
});
}
function buildHistory(visits) {
var thresholds = [
Date.now(), // 0. Now
DateHelper.todayStarted(), // 1. Today
DateHelper.yesterdayStarted(), // 2. Yesterday
DateHelper.thisWeekStarted(), // 3. This week
DateHelper.thisMonthStarted(), // 4. This month
DateHelper.lastSixMonthsStarted(), // 5. Six months
0 // 6. Epoch!
];
var threshold = 0;
var month = null;
var year = null;
var fragment = document.createDocumentFragment();
visits.forEach(function(visit) {
// Draw new heading if new threshold reached
if (visit.date > 0 && visit.date < thresholds[threshold]) {
threshold = incrementHistoryThreshold(visit.date,
threshold, thresholds);
// Special case for month headings
if (threshold != 5) {
drawHistoryHeading(fragment, threshold);
}
}
if (threshold === 5) {
var timestampDate = new Date(visit.date);
if (timestampDate.getMonth() != month ||
timestampDate.getFullYear() != year) {
month = timestampDate.getMonth();
year = timestampDate.getFullYear();
drawHistoryHeading(fragment, threshold, visit.date);
}
}
visit.meta = visit.url;
visit.dataset = { url: visit.url };
var dom = exports.Places.buildResultsDom([visit]);
var iconDom = dom.querySelector('.icon');
fragment.appendChild(dom);
updateIcon(visit, iconDom);
});
return fragment;
}
function showStartPage() {
if (!topSitesWrapper || !historyWrapper) {
return;
}
var store = exports.Places.persistStore;
var urls = [];
store.readVisits(MAX_HISTORY_RESULTS, function(results) {
var docFragment = buildHistory(results);
historyWrapper.innerHTML = '';
historyWrapper.appendChild(docFragment);
}, function filter(visit) {
var isStored = visit.url in urls;
urls[visit.url] = true;
return !isStored;
});
store.read('frecency', MAX_TOPSITES_RESULTS, function(results) {
var docFragment = document.createDocumentFragment();
results.forEach(function(x) {
docFragment.appendChild(formatTopResult(x));
});
topSitesWrapper.innerHTML = '';
topSitesWrapper.appendChild(docFragment);
});
}
function formatTopResult(result) {
var div = document.createElement('div');
var span = document.createElement('span');
if (result.title) {
span.setAttribute('dir', 'auto');
span.textContent = result.title;
} else {
span.setAttribute('dir', 'ltr');
span.textContent = result.url;
}
div.dataset.url = result.url;
div.classList.add('top-site');
div.appendChild(span);
div.setAttribute('role', 'link');
if (result.screenshot || result.tile) {
var img = result.screenshot || result.tile;
var imgUrl = (typeof img === 'string') ? img : URL.createObjectURL(img);
div.style.backgroundImage = 'url(' + imgUrl + ')';
}
return div;
}
function matchesFilter(value, filter) {
return !filter || (value && value.match(new RegExp(filter, 'i')) !== null);
}
function formatPlace(placeObj, filter) {
var result = {
'title': placeObj.title,
'meta': placeObj.url,
'icon': getIcon(placeObj),
'dataset': {
'url': placeObj.url
},
'label': placeObj.title
};
return result;
}
function Places() {}
Places.prototype = {
__proto__: Provider.prototype,
name: 'Places',
click: itemClicked,
init: function() {
// If not on new tab page, set places div as container
if (!topSitesWrapper || !historyWrapper) {
this.header = document.getElementById(this.name.toLowerCase() +
'-header');
this.container = document.getElementById(this.name.toLowerCase());
this.container.addEventListener('click', this.click.bind(this));
}
this.persistStore = new PlacesIdbStore();
this.persistStore.init().then(() => {
this.syncStore =
new SyncDataStore(STORE_NAME, this.persistStore, 'url');
this.syncStore.filter = function(place) {
return place.url.startsWith('app://') ||
place.url === 'about:blank';
};
this.syncStore.onChange = function() {
showStartPage();
};
// Make init return a promise, so we know when
// we did the sync. Used right now for testing
// porpoises.
var rev = this.persistStore.latestRevision || 0;
return this.syncStore.sync(rev).then(() => {
return new Promise(resolve => {
function done() {
showStartPage();
resolve();
}
asyncStorage.getItem('have-preloaded-sites', (havePreloaded) => {
if (!havePreloaded) {
this.preloadTopSites().then(() => {
asyncStorage.setItem('have-preloaded-sites', true);
done();
});
} else {
done();
}
});
});
});
});
},
saveSites: function(sites) {
return Promise.all(sites.map(site => {
site.frecency = -2;
return this.persistStore.addPlace(site);
}));
},
preloadTopSites: function() {
// load default build top sites
return LazyLoader.getJSON('/js/inittopsites.json').then(sites => {
return this.saveSites(sites);
});
},
search: function(filter) {
return new Promise((resolve, reject) => {
var matchedOrigins = {};
this.persistStore.read('frecency', MAX_AWESOME_RESULTS, (results) => {
resolve(results.map(function(result) {
return formatPlace(result, filter);
}));
}, function filterFun(result) {
if (result.frecency <= 0) {
return false;
}
var url = parseUrl(result.url);
var matches = !(url.hostname in matchedOrigins) &&
(matchesFilter(result.title, filter) ||
matchesFilter(result.url, filter));
if (matches) {
matchedOrigins[url.hostname] = true;
}
return matches;
});
});
}
};
exports.Places = new Places();
Search.provider(exports.Places);
}(window));

View file

@ -0,0 +1,168 @@
/* globals IconsHelper */
'use strict';
/**
* Base Provider class
*/
function Provider() {
}
Provider.prototype = {
/**
* Name of the provider
* Overridden at the child provider level
*/
name: 'Provider',
/**
* Whether or not this provider dedupes results.
*/
dedupes: false,
/**
* Initializes the provider container and adds listeners
*/
init: function(searchObj) {
this.searchObj = searchObj;
this.container = document.getElementById(this.name.toLowerCase());
this.container.addEventListener('click', this.click.bind(this));
},
/**
* Clears the rendered results of this provider from the app grid
*/
clear: function() {
this.container.innerHTML = '';
},
/**
* Handler when a result is clicked
*/
click: function() {},
/**
* Aborts any in-progress request.
*/
abort: function() {
if (this.request && this.request.abort) {
this.request.abort();
}
},
/**
* Update an icon once it has been loaded.
*
* @param {Object} config A configuration object.
* @param {Object} iconWrapper An iconWrapper DOM object.
*/
updateIcon: function(config, iconWrapper) {
var iconDom = iconWrapper.querySelector('img');
IconsHelper.getIcon(config.dataset.url, null, config).then((icon) => {
if (icon && iconDom) {
iconDom.onload = function () {
iconWrapper.classList.remove('empty');
iconDom.style.display = 'block';
};
iconDom.src = icon;
}
});
},
/**
* Renders a set of results.
* Each result may contain the following attributes:
* - title: The title of the app.
* - meta: Secondary content to show for the result.
* - icon: The icon of the result.
* - dataset: Data attributes to apply to the result.
* - label: Aria-label for the result.
* - description: Additional description for the result.
*/
buildResultsDom: function(results) {
//<div class="result" data-url="mozilla.com" role="link"
// aria-label="My Urlasljd alskdja lsdjka sldjk"
// aria-describedby="description-0">
// <div class="icon">
// <img role="presentation" src="..." />
// </div>
// <div class="urlwrapper">
// <span class="title">My Urlasljd <span class="highlight">alskd</span>
// ja lsdjka sldjk</span>
// <small id="description-0" class="url"
// aria-label="http://url.com">http://url.com</small>
// </div>
//</div>
var frag = document.createDocumentFragment();
results.forEach(function(config, index) {
var result = document.createElement('div');
var iconWrapper = document.createElement('div');
var icon = document.createElement('img');
var description = document.createElement('div');
var title = document.createElement('span');
var meta = document.createElement('small');
result.classList.add('result');
iconWrapper.classList.add('icon');
description.classList.add('description');
title.classList.add('title');
meta.classList.add('meta');
for (var i in config.dataset) {
result.dataset[i] = config.dataset[i];
}
if (config.title) {
title.setAttribute('dir', 'auto');
title.textContent = config.title;
} else {
title.setAttribute('dir', 'ltr');
title.textContent = config.url;
}
if (config.meta) {
meta.textContent = config.meta;
// Expose meta infrormation as a helpful description for each result.
if (config.description) {
meta.id = this.name + '-description-' + index;
meta.setAttribute('aria-label', config.description);
result.setAttribute('aria-describedby', meta.id);
}
}
icon.setAttribute('role', 'presentation');
if (config.icon) {
icon.src = window.URL.createObjectURL(config.icon);
} else {
iconWrapper.classList.add('empty');
}
result.setAttribute('role', 'link');
// Either use an explicit label or, if not present, title.
result.setAttribute('aria-label', config.label || config.title);
description.appendChild(title);
description.appendChild(meta);
iconWrapper.appendChild(icon);
result.appendChild(iconWrapper);
result.appendChild(description);
frag.appendChild(result);
if (!config.icon) {
this.updateIcon(config, iconWrapper);
}
}, this);
return frag;
},
render: function(results) {
var dom = this.buildResultsDom(results);
this.container.appendChild(dom);
if (this.header) {
results.length ? this.header.classList.remove('hidden') :
this.header.classList.add('hidden');
}
}
};

View file

@ -0,0 +1,140 @@
(function() {
'use strict';
/* globals Promise, Provider, Search, MetricsHelper */
/* globals LazyLoader */
/* globals SearchProvider */
var suggestionsWrapper = document.getElementById('suggestions-wrapper');
var suggestionsProvider = document.getElementById('suggestions-provider');
var suggestionsSelect = document.getElementById('suggestions-select');
function providerUpdated() {
navigator.mozL10n.setAttributes(suggestionsProvider, 'search-header', {
provider: SearchProvider('title').toUpperCase()
});
var providers = SearchProvider.providers();
var selectFragment = document.createDocumentFragment();
var optionNode = document.createElement('option');
Object.keys(providers).forEach(provider => {
var option = optionNode.cloneNode();
option.value = provider;
option.text = providers[provider].title;
if (provider === SearchProvider.selected()) {
option.selected = true;
}
selectFragment.appendChild(option);
});
suggestionsSelect.innerHTML = '';
suggestionsSelect.appendChild(selectFragment);
}
function encodeTerms(str, search) {
return str.replace('{searchTerms}', encodeURIComponent(search));
}
function Suggestions() {}
Suggestions.prototype = {
__proto__: Provider.prototype,
name: 'Suggestions',
currentSearch: null,
init: function() {
this.metrics = new MetricsHelper();
this.metrics.init();
Provider.prototype.init.apply(this, arguments);
suggestionsSelect.addEventListener('change', function(e) {
SearchProvider.setProvider(e.target.value);
});
SearchProvider.ready().then(() => {
providerUpdated();
SearchProvider.providerUpdated(() => {
providerUpdated();
this.redoSearch();
});
});
},
redoSearch: function() {
if (!this.currentSearch) {
return;
}
this.search(this.currentSearch).then(results => {
this.render(results);
});
},
click: function(e) {
this.metrics.report('websearch', SearchProvider('title'));
var suggestion = e.target.dataset.suggestion;
var url = encodeTerms(SearchProvider('searchUrl'), suggestion);
Search.navigate(url);
},
search: function(input, preventRemote) {
this.currentSearch = preventRemote ? null : input;
this.render([input]);
if (!navigator.onLine || preventRemote) {
return Promise.resolve([input]);
}
suggestionsWrapper.dataset.loading = true;
return SearchProvider.ready().then(() => {
return new Promise((resolve, reject) => {
var url = encodeTerms(SearchProvider('suggestUrl'), input);
LazyLoader.getJSON(url, true).then(result => {
var results = result[1];
// We add an item to search the entered term as well
results.unshift(input);
resolve(results);
}).catch(function() {
suggestionsWrapper.dataset.loading = false;
reject();
});
});
});
},
render: function(items) {
var ul = document.createElement('ul');
ul.setAttribute('role', 'listbox');
items.forEach(function each(item) {
var li = document.createElement('li');
li.dataset.suggestion = li.textContent = item;
li.setAttribute('role', 'option');
ul.appendChild(li);
});
suggestionsWrapper.dataset.loading = false;
this.container.innerHTML = '';
if (ul.childNodes.length) {
this.container.appendChild(ul);
}
},
clear: function() {
Provider.prototype.clear.apply(this, arguments);
this.currentSearch = null;
}
};
Search.provider(new Suggestions());
}());

398
gaia/apps/search/js/search.js Executable file
View file

@ -0,0 +1,398 @@
(function() {
'use strict';
/* global asyncStorage */
/* global Contextmenu */
/* global Search */
/* global SearchDedupe */
/* global SettingsListener */
/* global UrlHelper */
/* global SearchProvider */
/* global MetricsHelper */
/* global MozActivity */
// timeout before notifying providers
var SEARCH_DELAY = 500;
var MAX_GRID_SIZE = 4;
window.Search = {
_port: null,
providers: {},
gridCount: 0,
searchResults: document.getElementById('search-results'),
offlineMessage: document.getElementById('offline-message'),
suggestionsWrapper: document.getElementById('suggestions-wrapper'),
grid: document.getElementById('icons'),
gridWrapper: document.getElementById('icons-wrapper'),
suggestionsEnabled: false,
/**
* Used to display a notice on how to configure the search provider
* on first use
*/
suggestionNotice: document.getElementById('suggestions-notice-wrapper'),
get settingsLink() {
return document.getElementById('settings-link');
},
toShowNotice: null,
NOTICE_KEY: 'notice-shown',
init: function() {
this.dedupe = new SearchDedupe();
this.metrics = new MetricsHelper();
this.metrics.init();
// Initialize the parent port connection
var self = this;
navigator.mozApps.getSelf().onsuccess = function() {
var app = this.result;
app.connect('search-results').then(function onConnAccepted(ports) {
ports.forEach(function(port) {
self._port = port;
});
setConnectionHandler();
}, function onConnectionRejected(reason) {
console.log('Error connecting: ' + reason + '\n');
});
};
function setConnectionHandler() {
navigator.mozSetMessageHandler('connection',
function(connectionRequest) {
var keyword = connectionRequest.keyword;
var port = connectionRequest.port;
if (keyword === 'search') {
port.onmessage = self.dispatchMessage.bind(self);
port.start();
}
});
initializeProviders();
}
function initializeProviders() {
for (var i in self.providers) {
self.providers[i].init(self);
}
}
var enabledKey = 'search.suggestions.enabled';
SettingsListener.observe(enabledKey, true, function(enabled) {
this.suggestionsEnabled = enabled;
}.bind(this));
this.initNotice();
this.initConnectivityCheck();
this.contextmenu = new Contextmenu();
window.addEventListener('resize', this.resize);
window.addEventListener('scroll', this.onScroll);
navigator.mozSetMessageHandler('activity',
this.handleActivityEvents.bind(this));
},
resize: function() {
if (this.grid && this.grid.render) {
this.grid.render({
rerender: true,
skipDivider: true
});
}
},
// Typically an input keeps focus when the user scrolls, here we
// want to grab focus and manually dismiss the keyboard.
onScroll: function() {
window.focus();
},
/**
* Adds a search provider
*/
provider: function(provider) {
if (!(provider.name in this.providers)) {
this.providers[provider.name] = provider;
}
},
/**
* Dispatches messages to handlers in the Search class
*/
dispatchMessage: function(msg) {
if (typeof this[msg.data.action] === 'function') {
this[msg.data.action](msg);
}
},
/**
* Called when the user changes the search query
*/
change: function(msg) {
clearTimeout(this.changeTimeout);
this.showSearchResults();
var input = msg.data.input;
var providers = this.providers;
this.changeTimeout = setTimeout(() => {
this.clear();
this.dedupe.reset();
Object.keys(providers).forEach((providerKey) => {
var provider = providers[providerKey];
var preventRemoteFetch =
UrlHelper.isURL(input) ||
msg.data.isPrivateBrowser ||
!this.suggestionsEnabled;
if (provider.remote && preventRemoteFetch) {
return;
}
if (provider.name === 'Suggestions') {
var toShow = input.length > 2 &&
this.toShowNotice &&
this.suggestionsEnabled &&
this.suggestionNotice.hidden &&
navigator.onLine;
if (toShow) {
this.suggestionNotice.hidden = false;
}
}
provider.search(input, preventRemoteFetch).then((results) => {
this.collect(provider, results);
}, error => { if (error) console.warn('Search provider ' + provider.name + ': ' + error); });
});
}, SEARCH_DELAY);
},
/**
* Show a notice to the user informaing them of how to configure
* search providers, should only be shown once.
*/
initNotice: function() {
var confirm = document.getElementById('suggestions-notice-confirm');
confirm.addEventListener('click', this.discardNotice.bind(this, true));
var settingsLink = this.settingsLink;
if (settingsLink) {
settingsLink
.addEventListener('click', this.openSettings.bind(this));
}
asyncStorage.getItem(this.NOTICE_KEY, function(value) {
if (this.toShowNotice === null) {
this.toShowNotice = !value;
}
}.bind(this));
},
openSettings: function() {
this.discardNotice();
/* jshint nonew: false */
new MozActivity({
name: 'configure',
data: {
target: 'device',
section: 'search'
}
});
},
discardNotice: function(focus) {
this.suggestionNotice.hidden = true;
this.toShowNotice = false;
asyncStorage.setItem(this.NOTICE_KEY, true);
if (focus) {
this._port.postMessage({'action': 'focus'});
}
},
/**
* Expands the search experience when the user taps on a suggestion
* or submits a query.
*/
expandSearch: function(query) {
this.clear();
this.providers.WebResults.fullscreen(query);
},
/**
* Collects results from a search provider.
* If the provider de-duplicates results, filter through them
* and dedupe them. Otherwise, render.
* @param {Array} results The results of the provider search.
*/
collect: function(provider, results) {
if (provider.dedupes) {
results = this.dedupe.reduce(results, provider.dedupeStrategy);
}
if (provider.isGridProvider &&
(results.length + this.gridCount) > MAX_GRID_SIZE) {
var spaces = MAX_GRID_SIZE - this.gridCount;
if (spaces < 1) {
this.abort();
return;
}
results.splice(spaces, (results.length - spaces));
}
if (provider.isGridProvider) {
this.gridCount += results.length;
}
this.gridWrapper.classList.toggle('hidden', !this.gridCount);
provider.render(results);
},
/**
* Called when the user trigger a search activity
*/
handleActivityEvents: function(activity) {
var activityName = activity.source.name;
if (activityName === 'search') {
this.submit({data: {
input: activity.source.data.keyword
}});
}
},
/**
* Called when the user submits the search form
*/
submit: function(msg) {
this.discardNotice();
var input = msg.data.input;
// Not a valid URL, could be a search term
if (UrlHelper.isNotURL(input)) {
this.metrics.report('websearch', SearchProvider('title'));
var url = SearchProvider('searchUrl')
.replace('{searchTerms}', encodeURIComponent(input));
this.navigate(url);
return;
}
var hasScheme = UrlHelper.hasScheme(input);
// No scheme, prepend basic protocol and return
if (!hasScheme) {
input = 'http://' + input;
}
this.navigate(input);
},
/**
* Clear results from each provider.
*/
clear: function(msg) {
this.abort();
for (var i in this.providers) {
this.providers[i].clear();
}
this.gridCount = 0;
this.suggestionNotice.hidden = true;
},
showSearchResults: function() {
if (this.searchResults) {
this.searchResults.classList.remove('hidden');
}
},
/**
* Aborts all in-progress provider requests.
*/
abort: function() {
clearTimeout(this.changeTimeout);
for (var i in this.providers) {
this.providers[i].abort();
}
},
/**
* Messages the parent container to close
*/
close: function() {
this.abort();
this._port.postMessage({'action': 'hide'});
},
/**
* Opens a browser to a URL
* @param {String} url The url to navigate to
*/
navigate: function(url) {
new MozActivity({name:'view',data:{type:'url',url:url}});
},
/**
* Sends a message to the system app to update the input value
*/
setInput: function(input) {
this._port.postMessage({
'action': 'input',
'input': input
});
this.expandSearch(input);
},
initConnectivityCheck: function() {
var self = this;
function onConnectivityChange() {
if (navigator.onLine) {
self.searchResults.classList.remove('offline');
} else {
self.searchResults.classList.add('offline');
}
}
this.offlineMessage.addEventListener(
'click', function() {
var activity = new window.MozActivity({
name: 'configure',
data: {
target: 'device',
section: 'root',
filterBy: 'connectivity'
}
});
activity.onsuccess = function() {
/*
XXX: Since this activity inmediately returns
success, we cannot go back to the search bar.
Keeping a reference of the activity once this
is fixed.
*/
};
}
);
window.addEventListener('offline', onConnectivityChange);
window.addEventListener('online', onConnectivityChange);
onConnectivityChange();
}
};
window.addEventListener('load', Search.init.bind(Search));
})();