зеркало из https://github.com/mozilla/gecko-dev.git
Merge mozilla-central to mozilla-inbound. a=merge CLOSED TREE
--HG-- rename : browser/app/LaunchUnelevated.cpp => browser/app/winlauncher/LaunchUnelevated.cpp rename : browser/app/LaunchUnelevated.h => browser/app/winlauncher/LaunchUnelevated.h rename : browser/app/LauncherProcessWin.cpp => browser/app/winlauncher/LauncherProcessWin.cpp rename : browser/app/LauncherProcessWin.h => browser/app/winlauncher/LauncherProcessWin.h rename : browser/app/ProcThreadAttributes.h => browser/app/winlauncher/ProcThreadAttributes.h
This commit is contained in:
Коммит
83c5f0bd74
|
@ -89,10 +89,8 @@ browser/extensions/pdfjs/content/web**
|
|||
# generated or library files in pocket
|
||||
browser/extensions/pocket/content/panels/js/tmpl.js
|
||||
browser/extensions/pocket/content/panels/js/vendor/**
|
||||
# generated or library files in activity-stream
|
||||
browser/extensions/activity-stream/data/content/activity-stream.bundle.js
|
||||
browser/extensions/activity-stream/test/**
|
||||
browser/extensions/activity-stream/vendor/**
|
||||
# Activity Stream has incompatible eslintrc. `npm run lint` from its directory
|
||||
browser/extensions/activity-stream/**
|
||||
# The only file in browser/locales/ is pre-processed.
|
||||
browser/locales/**
|
||||
# imported from chromium
|
||||
|
|
1
.flake8
1
.flake8
|
@ -5,6 +5,7 @@ max-line-length = 99
|
|||
exclude =
|
||||
browser/extensions/mortar/ppapi/,
|
||||
build/pymake/,
|
||||
node_modules,
|
||||
security/nss/,
|
||||
testing/mochitest/pywebsocket,
|
||||
tools/lint/test/files,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<blocklist lastupdate="1526381862851" xmlns="http://www.mozilla.org/2006/addons-blocklist">
|
||||
<blocklist lastupdate="1527680138898" xmlns="http://www.mozilla.org/2006/addons-blocklist">
|
||||
<emItems>
|
||||
<emItem blockID="i334" id="{0F827075-B026-42F3-885D-98981EE7B1AE}">
|
||||
<prefs/>
|
||||
|
|
|
@ -1601,6 +1601,12 @@ var BookmarkingUI = {
|
|||
let isBookmarked = this._itemGuids.size > 0;
|
||||
if (!isBookmarked) {
|
||||
BrowserUtils.setToolbarButtonHeightProperty(this.star);
|
||||
// there are no other animations on this element, so we can simply
|
||||
// listen for animationend with the "once" option to clean up
|
||||
let animatableBox = document.getElementById("star-button-animatable-box");
|
||||
animatableBox.addEventListener("animationend", event => {
|
||||
this.star.removeAttribute("animate");
|
||||
}, { once: true });
|
||||
this.star.setAttribute("animate", "true");
|
||||
}
|
||||
PlacesCommandHook.bookmarkPage(gBrowser.selectedBrowser, true);
|
||||
|
|
|
@ -36,10 +36,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-label[multiselected] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-label-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
@ -3624,13 +3624,17 @@ window._gBrowser = {
|
|||
return SessionStore.duplicateTab(window, aTab, 0, aRestoreTabImmediately);
|
||||
},
|
||||
|
||||
addToMultiSelectedTabs(aTab) {
|
||||
addToMultiSelectedTabs(aTab, skipPositionalAttributes) {
|
||||
if (aTab.multiselected) {
|
||||
return;
|
||||
}
|
||||
|
||||
aTab.setAttribute("multiselected", "true");
|
||||
this._multiSelectedTabsSet.add(aTab);
|
||||
|
||||
if (!skipPositionalAttributes) {
|
||||
this.tabContainer._setPositionalAttributes();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -3652,8 +3656,9 @@ window._gBrowser = {
|
|||
[indexOfTab1, indexOfTab2] : [indexOfTab2, indexOfTab1];
|
||||
|
||||
for (let i = lowerIndex; i <= higherIndex; i++) {
|
||||
this.addToMultiSelectedTabs(tabs[i]);
|
||||
this.addToMultiSelectedTabs(tabs[i], true);
|
||||
}
|
||||
this.tabContainer._setPositionalAttributes();
|
||||
},
|
||||
|
||||
removeFromMultiSelectedTabs(aTab) {
|
||||
|
@ -3661,10 +3666,11 @@ window._gBrowser = {
|
|||
return;
|
||||
}
|
||||
aTab.removeAttribute("multiselected");
|
||||
this.tabContainer._setPositionalAttributes();
|
||||
this._multiSelectedTabsSet.delete(aTab);
|
||||
},
|
||||
|
||||
clearMultiSelectedTabs() {
|
||||
clearMultiSelectedTabs(updatePositionalAttributes) {
|
||||
const selectedTabs = ChromeUtils.nondeterministicGetWeakSetKeys(this._multiSelectedTabsSet);
|
||||
for (let tab of selectedTabs) {
|
||||
if (tab.isConnected && tab.multiselected) {
|
||||
|
@ -3672,6 +3678,9 @@ window._gBrowser = {
|
|||
}
|
||||
}
|
||||
this._multiSelectedTabsSet = new WeakSet();
|
||||
if (updatePositionalAttributes) {
|
||||
this.tabContainer._setPositionalAttributes();
|
||||
}
|
||||
},
|
||||
|
||||
get multiSelectedTabsCount() {
|
||||
|
|
|
@ -297,6 +297,17 @@
|
|||
if (hoveredTab) {
|
||||
hoveredTab._mouseenter();
|
||||
}
|
||||
|
||||
// Update before-multiselected attributes.
|
||||
// gBrowser may not be initialized yet, so avoid using it
|
||||
for (let i = 0; i < visibleTabs.length - 1; i++) {
|
||||
let tab = visibleTabs[i];
|
||||
let nextTab = visibleTabs[i + 1];
|
||||
tab.removeAttribute("before-multiselected");
|
||||
if (nextTab.multiselected) {
|
||||
tab.setAttribute("before-multiselected", "true");
|
||||
}
|
||||
}
|
||||
]]></body>
|
||||
</method>
|
||||
|
||||
|
@ -1545,7 +1556,7 @@
|
|||
<xul:stack class="tab-stack" flex="1">
|
||||
<xul:vbox xbl:inherits="selected=visuallyselected,fadein"
|
||||
class="tab-background">
|
||||
<xul:hbox xbl:inherits="selected=visuallyselected"
|
||||
<xul:hbox xbl:inherits="selected=visuallyselected,multiselected,before-multiselected"
|
||||
class="tab-line"/>
|
||||
<xul:spacer flex="1"/>
|
||||
<xul:hbox class="tab-bottom-line"/>
|
||||
|
@ -1582,7 +1593,7 @@
|
|||
onunderflow="this.removeAttribute('textoverflow');"
|
||||
flex="1">
|
||||
<xul:label class="tab-text tab-label"
|
||||
xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention,multiselected"
|
||||
xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention"
|
||||
role="presentation"/>
|
||||
</xul:hbox>
|
||||
<xul:image xbl:inherits="soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked"
|
||||
|
@ -1668,6 +1679,11 @@
|
|||
return this.getAttribute("multiselected") == "true";
|
||||
</getter>
|
||||
</property>
|
||||
<property name="beforeMultiselected" readonly="true">
|
||||
<getter>
|
||||
return this.getAttribute("before-multiselected") == "true";
|
||||
</getter>
|
||||
</property>
|
||||
<!--
|
||||
Describes how the tab ended up in this mute state. May be any of:
|
||||
|
||||
|
@ -2004,7 +2020,12 @@
|
|||
if (gBrowser.multiSelectedTabsCount > 0 && !overCloseButton) {
|
||||
// Tabs were previously multi-selected and user clicks on a tab
|
||||
// without holding Ctrl/Cmd Key
|
||||
gBrowser.clearMultiSelectedTabs();
|
||||
|
||||
// Force positional attributes to update when the
|
||||
// target (of the click) is the "active" tab.
|
||||
let updatePositionalAttr = gBrowser.selectedTab == this;
|
||||
|
||||
gBrowser.clearMultiSelectedTabs(updatePositionalAttr);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -45,3 +45,4 @@ skip-if = (debug && os == 'mac') || (debug && os == 'linux' && bits == 64) #Bug
|
|||
[browser_multiselect_tabs_using_Ctrl.js]
|
||||
[browser_multiselect_tabs_using_Shift.js]
|
||||
[browser_multiselect_tabs_close.js]
|
||||
[browser_multiselect_tabs_positional_attrs.js]
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
const PREF_MULTISELECT_TABS = "browser.tabs.multiselect";
|
||||
const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
|
||||
|
||||
add_task(async function setPref() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
[PREF_MULTISELECT_TABS, true],
|
||||
[PREF_WARN_ON_CLOSE, false]
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function checkBeforeMultiselectedAttributes() {
|
||||
let tab1 = await addTab();
|
||||
let tab2 = await addTab();
|
||||
let tab3 = await addTab();
|
||||
|
||||
let visibleTabs = gBrowser._visibleTabs;
|
||||
|
||||
await triggerClickOn(tab3, { ctrlKey: true });
|
||||
|
||||
is(visibleTabs.indexOf(tab1), 1, "The index of Tab1 is one");
|
||||
is(visibleTabs.indexOf(tab2), 2, "The index of Tab2 is two");
|
||||
is(visibleTabs.indexOf(tab3), 3, "The index of Tab3 is three");
|
||||
|
||||
ok(!tab1.multiselected, "Tab1 is not multi-selected");
|
||||
ok(!tab2.multiselected, "Tab2 is not multi-selected");
|
||||
ok(tab3.multiselected, "Tab3 is multi-selected");
|
||||
|
||||
ok(!tab1.beforeMultiselected, "Tab1 is not before-multiselected");
|
||||
ok(tab2.beforeMultiselected, "Tab2 is before-multiselected");
|
||||
|
||||
info("Close Tab2");
|
||||
let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
|
||||
BrowserTestUtils.removeTab(tab2);
|
||||
await tab2Closing;
|
||||
|
||||
// Cache invalidated, so we need to update the collection
|
||||
visibleTabs = gBrowser._visibleTabs;
|
||||
|
||||
is(visibleTabs.indexOf(tab1), 1, "The index of Tab1 is one");
|
||||
is(visibleTabs.indexOf(tab3), 2, "The index of Tab3 is two");
|
||||
ok(tab1.beforeMultiselected, "Tab1 is before-multiselected");
|
||||
|
||||
// Checking if positional attributes are updated when "active" tab is clicked.
|
||||
info("Click on the active tab to clear multiselect");
|
||||
await triggerClickOn(gBrowser.selectedTab, {});
|
||||
|
||||
is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
|
||||
ok(!tab1.beforeMultiselected, "Tab1 is not before-multiselected anymore");
|
||||
|
||||
BrowserTestUtils.removeTab(tab1);
|
||||
BrowserTestUtils.removeTab(tab3);
|
||||
});
|
|
@ -2,8 +2,8 @@ const BASE_ORIGIN = "http://example.com";
|
|||
const URI = BASE_ORIGIN +
|
||||
"/browser/browser/components/contextualidentity/test/browser/empty_file.html";
|
||||
|
||||
// opens `uri' in a new tab with the provided userContextId and focuses it.
|
||||
// returns the newly opened tab
|
||||
// Opens `uri' in a new tab with the provided userContextId and focuses it.
|
||||
// Returns the newly opened tab and browser.
|
||||
async function openTabInUserContext(uri, userContextId) {
|
||||
// open the tab in the correct userContextId
|
||||
let tab = BrowserTestUtils.addTab(gBrowser, uri, {userContextId});
|
||||
|
@ -17,16 +17,55 @@ async function openTabInUserContext(uri, userContextId) {
|
|||
return {tab, browser};
|
||||
}
|
||||
|
||||
add_task(async function setup() {
|
||||
// make sure userContext is enabled.
|
||||
await SpecialPowers.pushPrefEnv({"set": [
|
||||
["privacy.userContext.enabled", true]
|
||||
]});
|
||||
});
|
||||
// Opens `uri' in a new <iframe mozbrowser> with the provided userContextId.
|
||||
// Returns the newly opened browser.
|
||||
async function addBrowserFrameInUserContext(uri, userContextId) {
|
||||
// Create a browser frame with the user context and uri
|
||||
const browser = document.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
|
||||
browser.setAttribute("remote", "true");
|
||||
browser.setAttribute("usercontextid", userContextId);
|
||||
browser.setAttribute("mozbrowser", "true");
|
||||
// `noisolation = true` means `OA.mInIsolatedMozBrowser = false` which matches
|
||||
// the default for a XUL browser. It is indepedent from user contexts.
|
||||
browser.setAttribute("noisolation", "true");
|
||||
browser.setAttribute("src", uri);
|
||||
gBrowser.tabpanels.appendChild(browser);
|
||||
|
||||
add_task(async function test() {
|
||||
let receiver = await openTabInUserContext(URI, 2);
|
||||
// Create a XUL browser-like API expected by test helpers
|
||||
Object.defineProperty(browser, "messageManager", {
|
||||
get() {
|
||||
return browser.frameLoader.messageManager;
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
await browserFrameLoaded(browser);
|
||||
|
||||
return { browser };
|
||||
}
|
||||
|
||||
function browserFrameLoaded(browser) {
|
||||
const mm = browser.messageManager;
|
||||
return new Promise(resolve => {
|
||||
const eventName = "browser-test-utils:loadEvent";
|
||||
mm.addMessageListener(eventName, function onLoad(msg) {
|
||||
if (msg.target != browser) {
|
||||
return;
|
||||
}
|
||||
mm.removeMessageListener(eventName, onLoad);
|
||||
resolve(msg.data.internalURL);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeBrowserFrame({ browser }) {
|
||||
browser.remove();
|
||||
// Clean up Browser API parent-side data
|
||||
delete window._browserElementParents;
|
||||
}
|
||||
|
||||
async function runTestForReceiver(receiver) {
|
||||
let channelName = "contextualidentity-broadcastchannel";
|
||||
|
||||
// reflect the received message on title
|
||||
|
@ -72,5 +111,28 @@ add_task(async function test() {
|
|||
|
||||
gBrowser.removeTab(sender1.tab);
|
||||
gBrowser.removeTab(sender2.tab);
|
||||
}
|
||||
|
||||
add_task(async function setup() {
|
||||
// make sure userContext is enabled.
|
||||
await SpecialPowers.pushPrefEnv({"set": [
|
||||
["privacy.userContext.enabled", true]
|
||||
]});
|
||||
});
|
||||
|
||||
add_task(async function test() {
|
||||
info("Checking broadcast channel with browser tab receiver");
|
||||
let receiver = await openTabInUserContext(URI, 2);
|
||||
await runTestForReceiver(receiver);
|
||||
gBrowser.removeTab(receiver.tab);
|
||||
});
|
||||
|
||||
add_task(async function test() {
|
||||
info("Checking broadcast channel with <iframe mozbrowser> receiver");
|
||||
await SpecialPowers.pushPrefEnv({"set": [
|
||||
["dom.mozBrowserFramesEnabled", true]
|
||||
]});
|
||||
let receiver = await addBrowserFrameInUserContext(URI, 2);
|
||||
await runTestForReceiver(receiver);
|
||||
removeBrowserFrame(receiver);
|
||||
});
|
||||
|
|
|
@ -424,23 +424,11 @@ class GPOPoliciesProvider {
|
|||
this._policies = null;
|
||||
|
||||
let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(Ci.nsIWindowsRegKey);
|
||||
|
||||
// Machine policies override user policies, so we read
|
||||
// user policies first and then replace them if necessary.
|
||||
wrk.open(wrk.ROOT_KEY_CURRENT_USER,
|
||||
"SOFTWARE\\Policies",
|
||||
wrk.ACCESS_READ);
|
||||
if (wrk.hasChild("Mozilla\\Firefox")) {
|
||||
this._readData(wrk);
|
||||
}
|
||||
wrk.close();
|
||||
|
||||
wrk.open(wrk.ROOT_KEY_LOCAL_MACHINE,
|
||||
"SOFTWARE\\Policies",
|
||||
wrk.ACCESS_READ);
|
||||
if (wrk.hasChild("Mozilla\\Firefox")) {
|
||||
this._readData(wrk);
|
||||
}
|
||||
wrk.close();
|
||||
this._readData(wrk, wrk.ROOT_KEY_CURRENT_USER);
|
||||
this._readData(wrk, wrk.ROOT_KEY_LOCAL_MACHINE);
|
||||
}
|
||||
|
||||
get hasPolicies() {
|
||||
|
@ -455,8 +443,13 @@ class GPOPoliciesProvider {
|
|||
return this._failed;
|
||||
}
|
||||
|
||||
_readData(wrk) {
|
||||
this._policies = WindowsGPOParser.readPolicies(wrk, this._policies);
|
||||
_readData(wrk, root) {
|
||||
wrk.open(root, "SOFTWARE\\Policies", wrk.ACCESS_READ);
|
||||
if (wrk.hasChild("Mozilla\\Firefox")) {
|
||||
let isMachineRoot = (root == wrk.ROOT_KEY_LOCAL_MACHINE);
|
||||
this._policies = WindowsGPOParser.readPolicies(wrk, this._policies, isMachineRoot);
|
||||
}
|
||||
wrk.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -672,7 +672,7 @@ var Policies = {
|
|||
});
|
||||
}
|
||||
if (param.Default) {
|
||||
runOnce("setDefaultSearchEngine", () => {
|
||||
runOncePerModification("setDefaultSearchEngine", param.Default, () => {
|
||||
let defaultEngine;
|
||||
try {
|
||||
defaultEngine = Services.search.getEngineByName(param.Default);
|
||||
|
|
|
@ -19,16 +19,20 @@ XPCOMUtils.defineLazyGetter(this, "log", () => {
|
|||
});
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetters(this, {
|
||||
schema: "resource:///modules/policies/schema.jsm",
|
||||
});
|
||||
|
||||
var EXPORTED_SYMBOLS = ["WindowsGPOParser"];
|
||||
|
||||
var WindowsGPOParser = {
|
||||
readPolicies(wrk, policies) {
|
||||
readPolicies(wrk, policies, isMachineRoot) {
|
||||
let childWrk = wrk.openChild("Mozilla\\Firefox", wrk.ACCESS_READ);
|
||||
if (!policies) {
|
||||
policies = {};
|
||||
}
|
||||
try {
|
||||
policies = registryToObject(childWrk, policies);
|
||||
policies = registryToObject(childWrk, policies, isMachineRoot);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
} finally {
|
||||
|
@ -37,13 +41,14 @@ var WindowsGPOParser = {
|
|||
// Need an extra check here so we don't
|
||||
// JSON.stringify if we aren't in debug mode
|
||||
if (log._maxLogLevel == "debug") {
|
||||
log.debug("root = " + isMachineRoot ? "HKEY_LOCAL_MACHINE" : "HKEY_CURRENT_USER");
|
||||
log.debug(JSON.stringify(policies, null, 2));
|
||||
}
|
||||
return policies;
|
||||
}
|
||||
};
|
||||
|
||||
function registryToObject(wrk, policies) {
|
||||
function registryToObject(wrk, policies, isMachineRoot) {
|
||||
if (!policies) {
|
||||
policies = {};
|
||||
}
|
||||
|
@ -60,6 +65,14 @@ function registryToObject(wrk, policies) {
|
|||
for (let i = 0; i < wrk.valueCount; i++) {
|
||||
let name = wrk.getValueName(i);
|
||||
let value = readRegistryValue(wrk, name);
|
||||
|
||||
if (!isMachineRoot &&
|
||||
schema.properties[name] &&
|
||||
schema.properties[name].machine_only) {
|
||||
log.error(`Policy ${name} is only allowed under the HKEY_LOCAL_MACHINE root`);
|
||||
continue;
|
||||
}
|
||||
|
||||
policies[name] = value;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,23 +24,42 @@ async function hideBookmarksPanel() {
|
|||
await hiddenPromise;
|
||||
}
|
||||
|
||||
async function openPopupAndSelectFolder(guid) {
|
||||
async function openPopupAndSelectFolder(guid, newBookmark = false) {
|
||||
await clickBookmarkStar();
|
||||
|
||||
let notificationPromise;
|
||||
if (!newBookmark) {
|
||||
notificationPromise = PlacesTestUtils.waitForNotification("onItemMoved",
|
||||
(id, oldParentId, oldIndex, newParentId, newIndex, type,
|
||||
itemGuid, oldParentGuid, newParentGuid) => guid == newParentGuid);
|
||||
}
|
||||
|
||||
// Expand the folder tree.
|
||||
document.getElementById("editBMPanel_foldersExpander").click();
|
||||
document.getElementById("editBMPanel_folderTree").selectItems([guid]);
|
||||
|
||||
await hideBookmarksPanel();
|
||||
// Ensure the meta data has had chance to be written to disk.
|
||||
await PlacesTestUtils.promiseAsyncUpdates();
|
||||
if (!newBookmark) {
|
||||
await notificationPromise;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRecentFolders(expectedGuids, msg) {
|
||||
// Give the metadata chance to be written to the database before we attempt
|
||||
// to open the dialog again.
|
||||
let diskGuids = [];
|
||||
await TestUtils.waitForCondition(async () => {
|
||||
diskGuids = await PlacesUtils.metadata.get(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, []);
|
||||
return diskGuids.length == expectedGuids.length;
|
||||
}, `Should have written data to disk for: ${msg}`);
|
||||
|
||||
Assert.deepEqual(diskGuids, expectedGuids, `Should match the disk GUIDS for ${msg}`);
|
||||
|
||||
await clickBookmarkStar();
|
||||
|
||||
let actualGuids = [];
|
||||
function getGuids() {
|
||||
actualGuids = [];
|
||||
const folderMenuPopup = document.getElementById("editBMPanel_folderMenuList").children[0];
|
||||
|
||||
let separatorFound = false;
|
||||
|
@ -54,10 +73,12 @@ async function assertRecentFolders(expectedGuids, msg) {
|
|||
}
|
||||
}
|
||||
|
||||
// The dialog fills in the folder list asnychronously, so we might need to wait
|
||||
// for that to complete.
|
||||
await TestUtils.waitForCondition(() => {
|
||||
getGuids();
|
||||
return actualGuids.length == expectedGuids.length;
|
||||
}, msg);
|
||||
}, `Should have opened dialog with expected recent folders for: ${msg}`);
|
||||
|
||||
Assert.deepEqual(actualGuids, expectedGuids, msg);
|
||||
|
||||
|
@ -115,7 +136,7 @@ add_task(async function setup() {
|
|||
add_task(async function test_remember_last_folder() {
|
||||
await assertRecentFolders([], "Should have no recent folders to start with.");
|
||||
|
||||
await openPopupAndSelectFolder(folders[0].guid);
|
||||
await openPopupAndSelectFolder(folders[0].guid, true);
|
||||
|
||||
await assertRecentFolders([folders[0].guid], "Should have one folder in the list.");
|
||||
});
|
||||
|
|
|
@ -734,10 +734,10 @@ var MessageQueue = {
|
|||
_timeoutDisabled: false,
|
||||
|
||||
/**
|
||||
* The idle callback ID referencing an active idle callback. When no idle
|
||||
* callback is pending, this is null.
|
||||
* */
|
||||
_idleCallbackID: null,
|
||||
* True if there is already a send pending idle dispatch, set to prevent
|
||||
* scheduling more than one. If false there may or may not be one scheduled.
|
||||
*/
|
||||
_idleScheduled: false,
|
||||
|
||||
/**
|
||||
* True if batched messages are not being fired on a timer. This should only
|
||||
|
@ -782,10 +782,7 @@ var MessageQueue = {
|
|||
* Cleanup pending idle callback and timer.
|
||||
*/
|
||||
cleanupTimers() {
|
||||
if (this._idleCallbackID) {
|
||||
content.cancelIdleCallback(this._idleCallbackID);
|
||||
this._idleCallbackID = null;
|
||||
}
|
||||
this._idleScheduled = false;
|
||||
if (this._timeout) {
|
||||
clearTimeout(this._timeout);
|
||||
this._timeout = null;
|
||||
|
@ -837,7 +834,7 @@ var MessageQueue = {
|
|||
* given, this function is going to schedule the first request.
|
||||
*
|
||||
* @param deadline (object)
|
||||
* An IdleDeadline object passed by requestIdleCallback().
|
||||
* An IdleDeadline object passed by idleDispatch().
|
||||
*/
|
||||
sendWhenIdle(deadline) {
|
||||
if (!content) {
|
||||
|
@ -850,13 +847,13 @@ var MessageQueue = {
|
|||
MessageQueue.send();
|
||||
return;
|
||||
}
|
||||
} else if (MessageQueue._idleCallbackID) {
|
||||
} else if (MessageQueue._idleScheduled) {
|
||||
// Bail out if there's a pending run.
|
||||
return;
|
||||
}
|
||||
MessageQueue._idleCallbackID =
|
||||
content.requestIdleCallback(MessageQueue.sendWhenIdle, {timeout: MessageQueue._timeoutWaitIdlePeriodMs});
|
||||
},
|
||||
ChromeUtils.idleDispatch(MessageQueue.sendWhenIdle, {timeout: MessageQueue._timeoutWaitIdlePeriodMs});
|
||||
MessageQueue._idleScheduled = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends queued data to the chrome process.
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
activity-streams-env/
|
||||
dist/
|
||||
firefox/
|
||||
logs/
|
||||
stats.json
|
||||
prerendered/
|
||||
vendor/
|
||||
data/
|
||||
bin/prerender.js
|
||||
bin/prerender.js.map
|
|
@ -0,0 +1,231 @@
|
|||
module.exports = {
|
||||
// When adding items to this file please check for effects on sub-directories.
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"plugins": [
|
||||
"import", // require("eslint-plugin-import")
|
||||
"json", // require("eslint-plugin-json")
|
||||
"promise", // require("eslint-plugin-promise")
|
||||
"react" // require("eslint-plugin-react")
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:mozilla/recommended" // require("eslint-plugin-mozilla")
|
||||
],
|
||||
"overrides": [{
|
||||
// Use a configuration that's more appropriate for JSMs
|
||||
"files": "**/*.jsm",
|
||||
"parserOptions": {
|
||||
"sourceType": "script"
|
||||
},
|
||||
"env": {
|
||||
"node": false
|
||||
},
|
||||
"rules": {
|
||||
"no-implicit-globals": 0
|
||||
}
|
||||
}],
|
||||
"rules": {
|
||||
"promise/catch-or-return": 2,
|
||||
"promise/param-names": 2,
|
||||
|
||||
"react/jsx-boolean-value": [2, "always"],
|
||||
"react/jsx-closing-bracket-location": [2, "after-props"],
|
||||
"react/jsx-curly-spacing": [2, "never"],
|
||||
"react/jsx-equals-spacing": [2, "never"],
|
||||
"react/jsx-key": 2,
|
||||
"react/jsx-no-bind": 2,
|
||||
"react/jsx-no-comment-textnodes": 2,
|
||||
"react/jsx-no-duplicate-props": 2,
|
||||
"react/jsx-no-target-blank": 2,
|
||||
"react/jsx-no-undef": 2,
|
||||
"react/jsx-pascal-case": 2,
|
||||
"react/jsx-space-before-closing": [2, "always"],
|
||||
"react/jsx-uses-react": 2,
|
||||
"react/jsx-uses-vars": 2,
|
||||
"react/jsx-wrap-multilines": 2,
|
||||
"react/no-access-state-in-setstate": 2,
|
||||
"react/no-danger": 2,
|
||||
"react/no-deprecated": 2,
|
||||
"react/no-did-mount-set-state": 2,
|
||||
"react/no-did-update-set-state": 2,
|
||||
"react/no-direct-mutation-state": 2,
|
||||
"react/no-is-mounted": 2,
|
||||
"react/no-unknown-property": 2,
|
||||
"react/require-render-return": 2,
|
||||
"react/self-closing-comp": 2,
|
||||
|
||||
"accessor-pairs": [2, {"setWithoutGet": true, "getWithoutSet": false}],
|
||||
"array-bracket-newline": [2, "consistent"],
|
||||
"array-bracket-spacing": [2, "never"],
|
||||
"array-callback-return": 2,
|
||||
"array-element-newline": 0,
|
||||
"arrow-body-style": [2, "as-needed"],
|
||||
"arrow-parens": [2, "as-needed"],
|
||||
"block-scoped-var": 2,
|
||||
"callback-return": 0,
|
||||
"camelcase": 0,
|
||||
"capitalized-comments": 0,
|
||||
"class-methods-use-this": 0,
|
||||
"comma-dangle": [2, "never"],
|
||||
"consistent-this": [2, "use-bind"],
|
||||
"curly": [2, "all"],
|
||||
"default-case": 0,
|
||||
"dot-location": [2, "property"],
|
||||
"eqeqeq": 2,
|
||||
"for-direction": 2,
|
||||
"func-name-matching": 2,
|
||||
"func-names": 0,
|
||||
"func-style": 0,
|
||||
"function-paren-newline": 0,
|
||||
"getter-return": 2,
|
||||
"global-require": 0,
|
||||
"guard-for-in": 2,
|
||||
"handle-callback-err": 2,
|
||||
"id-blacklist": 0,
|
||||
"id-length": 0,
|
||||
"id-match": 0,
|
||||
"implicit-arrow-linebreak": 0,
|
||||
// XXX Switch back to indent once mozilla-central has decided what it is using.
|
||||
"indent": 0,
|
||||
"indent-legacy": ["error", 2, {"SwitchCase": 1}],
|
||||
"init-declarations": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
"line-comment-position": 0,
|
||||
"lines-around-comment": ["error", {
|
||||
"allowClassStart": true,
|
||||
"allowObjectStart": true,
|
||||
"beforeBlockComment": true
|
||||
}],
|
||||
"lines-between-class-members": 2,
|
||||
"max-depth": [2, 4],
|
||||
"max-len": 0,
|
||||
"max-lines": 0,
|
||||
"max-nested-callbacks": [2, 4],
|
||||
"max-params": [2, 6],
|
||||
"max-statements": [2, 50],
|
||||
"max-statements-per-line": [2, {"max": 2}],
|
||||
"multiline-comment-style": 0,
|
||||
"multiline-ternary": 0,
|
||||
"new-cap": [2, {"newIsCap": true, "capIsNew": false}],
|
||||
"new-parens": 2,
|
||||
"newline-after-var": 0,
|
||||
"newline-before-return": 0,
|
||||
"newline-per-chained-call": [2, {"ignoreChainWithDepth": 3}],
|
||||
"no-alert": 2,
|
||||
"no-await-in-loop": 0,
|
||||
"no-bitwise": 0,
|
||||
"no-buffer-constructor": 2,
|
||||
"no-catch-shadow": 2,
|
||||
"no-confusing-arrow": [2, {"allowParens": true}],
|
||||
"no-console": 1,
|
||||
"no-continue": 0,
|
||||
"no-div-regex": 2,
|
||||
"no-duplicate-imports": 2,
|
||||
"no-empty-function": 0,
|
||||
"no-eq-null": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-label": 2,
|
||||
"no-extra-parens": 0,
|
||||
"no-floating-decimal": 2,
|
||||
"no-implicit-coercion": [2, {"allow": ["!!"]}],
|
||||
"no-implicit-globals": 2,
|
||||
"no-inline-comments": 0,
|
||||
"no-invalid-this": 0,
|
||||
"no-label-var": 2,
|
||||
"no-loop-func": 2,
|
||||
"no-magic-numbers": 0,
|
||||
"no-mixed-operators": [2, {"allowSamePrecedence": true, "groups": [["&", "|", "^", "~", "<<", ">>", ">>>"], ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["&&", "||"], ["in", "instanceof"]]}],
|
||||
"no-mixed-requires": 2,
|
||||
"no-multi-assign": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-multiple-empty-lines": [2, {"max": 1, "maxBOF": 0, "maxEOF": 0}],
|
||||
"no-negated-condition": 0,
|
||||
"no-negated-in-lhs": 2,
|
||||
"no-new": 2,
|
||||
"no-new-func": 2,
|
||||
"no-new-require": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-param-reassign": 2,
|
||||
"no-path-concat": 2,
|
||||
"no-plusplus": 0,
|
||||
"no-process-env": 0,
|
||||
"no-process-exit": 2,
|
||||
"no-proto": 2,
|
||||
"no-prototype-builtins": 2,
|
||||
"no-restricted-globals": 0,
|
||||
"no-restricted-imports": 0,
|
||||
"no-restricted-modules": 0,
|
||||
"no-restricted-properties": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"no-return-assign": [2, "except-parens"],
|
||||
"no-script-url": 2,
|
||||
"no-sequences": 2,
|
||||
"no-shadow": 2,
|
||||
"no-spaced-func": 2,
|
||||
"no-sync": 0,
|
||||
"no-template-curly-in-string": 2,
|
||||
"no-ternary": 0,
|
||||
"no-throw-literal": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-undefined": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"no-unmodified-loop-condition": 2,
|
||||
"no-unused-expressions": 2,
|
||||
"no-use-before-define": 2,
|
||||
"no-useless-computed-key": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-useless-rename": 2,
|
||||
"no-var": 2,
|
||||
"no-void": 2,
|
||||
"no-warning-comments": 0, // TODO: Change to `1`?
|
||||
"nonblock-statement-body-position": 2,
|
||||
"object-curly-newline": [2, {"multiline": true}],
|
||||
"object-curly-spacing": [2, "never"],
|
||||
"object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}],
|
||||
"one-var": [2, "never"],
|
||||
"one-var-declaration-per-line": [2, "initializations"],
|
||||
"operator-assignment": [2, "always"],
|
||||
"operator-linebreak": [2, "after"],
|
||||
"padded-blocks": [2, "never"],
|
||||
"padding-line-between-statements": 0,
|
||||
"prefer-arrow-callback": ["error", {"allowNamedFunctions": true}],
|
||||
"prefer-const": 0, // TODO: Change to `1`?
|
||||
"prefer-destructuring": [2, {"AssignmentExpression": {"array": true}, "VariableDeclarator": {"array": true, "object": true}}],
|
||||
"prefer-numeric-literals": 2,
|
||||
"prefer-promise-reject-errors": 2,
|
||||
"prefer-reflect": 0,
|
||||
"prefer-rest-params": 2,
|
||||
"prefer-spread": 2,
|
||||
"prefer-template": 2,
|
||||
"quote-props": [2, "consistent"],
|
||||
"radix": [2, "always"],
|
||||
"require-await": 2,
|
||||
"require-jsdoc": 0,
|
||||
"semi-spacing": [2, {"before": false, "after": true}],
|
||||
"semi-style": 2,
|
||||
"sort-imports": [2, {"ignoreCase": true}],
|
||||
"sort-keys": 0,
|
||||
"sort-vars": 2,
|
||||
"space-in-parens": [2, "never"],
|
||||
"strict": 0,
|
||||
"switch-colon-spacing": 2,
|
||||
"symbol-description": 2,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
"template-tag-spacing": 2,
|
||||
"unicode-bom": [2, "never"],
|
||||
"valid-jsdoc": [0, {"requireReturn": false, "requireParamDescription": false, "requireReturnDescription": false}],
|
||||
"vars-on-top": 2,
|
||||
"wrap-iife": [2, "inside"],
|
||||
"wrap-regex": 0,
|
||||
"yield-star-spacing": [2, "after"],
|
||||
"yoda": [2, "never"]
|
||||
}
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
npm-debug.log
|
||||
.DS_Store
|
||||
*.sw[po]
|
||||
*.xpi
|
||||
*.pyc
|
||||
*.update.rdf
|
||||
.gitignore
|
||||
|
||||
/.git/
|
||||
/bin/prerender.js
|
||||
/bin/prerender.js.map
|
||||
/data/locales.json
|
||||
/dist/
|
||||
/logs/
|
||||
/node_modules/
|
||||
|
||||
# also ignores ping centre tests
|
||||
ping-centre/
|
|
@ -0,0 +1 @@
|
|||
7.*
|
|
@ -0,0 +1,25 @@
|
|||
options:
|
||||
merge-default-rules: true
|
||||
|
||||
files:
|
||||
include: 'content-src/**/*.scss'
|
||||
|
||||
rules:
|
||||
class-name-format: [{convention: ["hyphenatedlowercase", "camelcase"]}]
|
||||
extends-before-declarations: 2
|
||||
extends-before-mixins: 2
|
||||
force-element-nesting: 0
|
||||
force-pseudo-nesting: 0
|
||||
hex-notation: [2, {style: uppercase}]
|
||||
indentation: [2, {size: 2}]
|
||||
leading-zero: [2, {include: true}]
|
||||
mixins-before-declarations: [2, {exclude: [breakpoint, mq]}]
|
||||
nesting-depth: [2, {max-depth: 4}]
|
||||
no-debug: 1
|
||||
no-duplicate-properties: 2
|
||||
no-misspelled-properties: [2, {extra-properties: [-moz-context-properties]}]
|
||||
no-url-domains: 0
|
||||
no-vendor-prefixes: 0
|
||||
no-warn: 1
|
||||
placeholder-in-extend: 2
|
||||
property-sort-order: 0
|
|
@ -0,0 +1,33 @@
|
|||
language: node_js
|
||||
|
||||
node_js:
|
||||
# when changing this, be sure to edit .nvrmc and package.json too
|
||||
- 7
|
||||
|
||||
python:
|
||||
- "2.7"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
before_install:
|
||||
# see https://docs.travis-ci.com/user/gui-and-headless-browsers/#Using-xvfb-to-Run-Tests-That-Require-a-GUI
|
||||
- "export DISPLAY=:99.0"
|
||||
- "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16 -extension RANDR"
|
||||
- export PATH="$PATH:$HOME/.rvm/bin"
|
||||
- sleep 3
|
||||
|
||||
install:
|
||||
- npm config set spin false
|
||||
- npm install
|
||||
|
||||
before_script:
|
||||
- bash bin/download-firefox-travis.sh release-linux64-add-on-devel
|
||||
- export FIREFOX_BIN=./firefox/firefox
|
||||
|
||||
script:
|
||||
- npm test
|
||||
|
||||
notifications:
|
||||
email: false
|
|
@ -0,0 +1,2 @@
|
|||
# flod as main contact for string changes
|
||||
locales/en-US/strings.properties @flodolo
|
|
@ -0,0 +1,374 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
|
|
@ -7,3 +7,16 @@ The files in this directory, including vendor dependencies, are imported from th
|
|||
system-addon directory in https://github.com/mozilla/activity-stream.
|
||||
|
||||
Read [docs/v2-system-addon](https://github.com/mozilla/activity-stream/tree/master/docs/v2-system-addon/1.GETTING_STARTED.md) for more detail.
|
||||
|
||||
## Where should I file bugs?
|
||||
|
||||
We regularly check the ActivityStream:NewTab component on Bugzilla.
|
||||
|
||||
## For Developers
|
||||
|
||||
If you are interested in contributing, take a look at [this guide](contributing.md) on where to find us and how to contribute,
|
||||
and [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) for getting your development environment set up.
|
||||
|
||||
## For Localizers
|
||||
|
||||
Activity Stream localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/activity-stream-new-tab/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language, or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance.
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash -x
|
||||
|
||||
# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/download-firefox-artifact
|
||||
#
|
||||
# This looks for a mozilla-central artifact build as a sibling of the
|
||||
# activity-stream tree. If it's not there, it creates it. If it is there, it
|
||||
# updates it.
|
||||
|
||||
# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
|
||||
# friends will be executed) isn't set in the environment, just use the repo
|
||||
# we're running from.
|
||||
if [ -z ${AS_GIT_BIN_REPO+x} ]; then
|
||||
ROOT=`dirname $0`
|
||||
AS_GIT_BIN_REPO="../../../../activity-stream"
|
||||
else
|
||||
ROOT=${AS_GIT_BIN_REPO}/bin
|
||||
fi
|
||||
|
||||
# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
|
||||
# (i.e. whether this script has been called from test-merges.js)
|
||||
if [ -z ${AS_PINE_TEST_DIR+x} ]; then
|
||||
FIREFOX_PATH="$ROOT/../../mozilla-central"
|
||||
else
|
||||
FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
|
||||
fi
|
||||
|
||||
# check that mercurial is installed
|
||||
if [ -z "`command -v hg`" ]; then
|
||||
echo >&2 "mercurial is required for mochitests, use 'brew install mercurial' on MacOS";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
if [ -d "$FIREFOX_PATH" ]; then
|
||||
# convert path to absolute path
|
||||
FIREFOX_PATH=$(cd "$FIREFOX_PATH"; pwd)
|
||||
|
||||
# If we already have Firefox locally, just update it
|
||||
cd "$FIREFOX_PATH";
|
||||
|
||||
if [ -n "`hg status`" ]; then
|
||||
read -p "There are local changes to Firefox which will be overwritten. Are you sure? [Y/n] " -r
|
||||
if [[ $REPLY == "n" ]]; then
|
||||
exit 0;
|
||||
fi
|
||||
|
||||
hg revert -a
|
||||
fi
|
||||
|
||||
hg pull
|
||||
hg update -C
|
||||
else
|
||||
echo "Downloading Firefox source code, requires about 10-30min depending on connection"
|
||||
hg clone https://hg.mozilla.org/mozilla-central/ "$FIREFOX_PATH"
|
||||
# if somebody cancels (ctrl-c) out of the long download don't continue
|
||||
exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
cd "$FIREFOX_PATH"
|
||||
|
||||
# Make an artifact build so it builds much faster
|
||||
echo "
|
||||
ac_add_options --enable-artifact-builds
|
||||
mk_add_options AUTOCLOBBER=1
|
||||
mk_add_options MOZ_OBJDIR=./objdir-frontend
|
||||
" > .mozconfig
|
||||
fi
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
# Copied and slightly modified from https://github.com/lidel/ipfs-firefox-addon/commit/d656832eec807ebae59543982dde96932ce5bb7c
|
||||
# Licensed under Creative Commons - CC0 1.0 Universal - https://github.com/lidel/ipfs-firefox-addon/blob/master/LICENSE
|
||||
BUILD_TYPE=${1:-$FIREFOX_RELEASE}
|
||||
echo "Looking up latest URL for $BUILD_TYPE"
|
||||
BUILD_ROOT="/pub/firefox/tinderbox-builds/mozilla-${BUILD_TYPE}/"
|
||||
ROOT="https://archive.mozilla.org"
|
||||
LATEST=$(curl -s "$ROOT$BUILD_ROOT" | grep $BUILD_TYPE | grep -Po '<a href=".+">\K[[:digit:]]+' | sort -n | tail -1)
|
||||
echo "Latest build located at $ROOT$BUILD_ROOT$LATEST"
|
||||
FILE=$(curl -s "$ROOT$BUILD_ROOT$LATEST/" | grep '.tar.' | grep -Po '<a href="\K[^"]*')
|
||||
echo "URL: $ROOT$FILE"
|
||||
wget -O "firefox-${BUILD_TYPE}.tar.bz2" "$ROOT$FILE" && tar xf "firefox-${BUILD_TYPE}.tar.bz2"
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash -x -e
|
||||
#
|
||||
# -e means "exit on error", so that we don't have to constantly
|
||||
# check exit codes
|
||||
#
|
||||
# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/prepare-mochitests-dev
|
||||
#
|
||||
# This sets up a mozilla-central build for local mochitest development with an
|
||||
# exported activity-stream tree and test directory.
|
||||
|
||||
# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
|
||||
# friends will be executed) isn't set in the environment, just use the repo
|
||||
# we're running from.
|
||||
if [ -z ${AS_GIT_BIN_REPO+x} ]; then
|
||||
ROOT=`dirname $0`
|
||||
AS_GIT_BIN_REPO="../activity-stream" # as seen from mozilla-central
|
||||
else
|
||||
ROOT=${AS_GIT_BIN_REPO}/bin
|
||||
fi
|
||||
|
||||
# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
|
||||
# (i.e. whether this script has been called from test-merges.js)
|
||||
if [ -z ${AS_PINE_TEST_DIR+x} ]; then
|
||||
FIREFOX_PATH="$ROOT/../../mozilla-central"
|
||||
else
|
||||
FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
|
||||
fi
|
||||
|
||||
MC_MODULE_PATH="$FIREFOX_PATH/browser/extensions/activity-stream"
|
||||
|
||||
# By default, just use mozilla-central + the export. If ENABLE_MC_AS is set to
|
||||
# 1, patch on top of mozilla-central + the export to turn on the AS pref and
|
||||
# turn on the tests. Once AS is on by default in mozilla-central, stuff
|
||||
# related to ENABLE_MC_AS can go away entirely.
|
||||
ENABLE_MC_AS=${ENABLE_MC_AS-0}
|
||||
|
||||
# This will either download or update the local Firefox repo
|
||||
"$ROOT/download-firefox-artifact"
|
||||
|
||||
# blow away any old bits in order to workaround bug 1335976 for users
|
||||
# who are using the default objdir-frontend
|
||||
rm -f ${FIREFOX_PATH}/objdir-frontend/dist/bin/browser/features/@activity-streams/*
|
||||
|
||||
# Clean, package, and copy the activity stream files.
|
||||
npm run buildmc
|
||||
|
||||
# Patch mozilla-central (on top of the export) so that AS is preffed on, and
|
||||
# the mochitests are turned on.
|
||||
shopt -s nullglob # don't explode if there are no patches right now
|
||||
if [ $ENABLE_MC_AS ]; then
|
||||
PATCHES=$AS_GIT_BIN_REPO/mozilla-central-patches/*.diff
|
||||
for p in $PATCHES
|
||||
do
|
||||
patch --directory="$FIREFOX_PATH" -p1 --force --no-backup-if-mismatch \
|
||||
--input=$p
|
||||
done
|
||||
fi
|
||||
shopt -u nullglob
|
||||
|
||||
# Be sure that we've built, and that the test glop in the objdir has been
|
||||
# created.
|
||||
#
|
||||
cd "$FIREFOX_PATH"
|
||||
./mach build
|
||||
exit $?
|
|
@ -0,0 +1,21 @@
|
|||
#! /usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const MIN_FIREFOX_VERSION = "55.0a1";
|
||||
|
||||
/* globals cd, mv, sed */
|
||||
require("shelljs/global");
|
||||
|
||||
cd(process.argv[2]);
|
||||
|
||||
// Convert install.rdf.in to install.rdf without substitutions
|
||||
mv("install.rdf.in", "install.rdf");
|
||||
sed("-i", /^#filter substitution/, "", "install.rdf");
|
||||
sed("-i", /(<em:minVersion>).+(<\/em:minVersion>)/, `$1${MIN_FIREFOX_VERSION}$2`, "install.rdf");
|
||||
sed("-i", /(<em:maxVersion>).+(<\/em:maxVersion>)/, "$1*$2", "install.rdf");
|
||||
|
||||
// Convert jar.mn to chrome.manifest with just manifest
|
||||
mv("jar.mn", "chrome.manifest");
|
||||
sed("-i", /^[^%].*$/, "", "chrome.manifest");
|
||||
sed("-i", /^% (content.*) %(.*)$/, "$1 $2", "chrome.manifest");
|
||||
sed("-i", /^% (resource.*) %.*$/, "$1 .", "chrome.manifest");
|
|
@ -0,0 +1,352 @@
|
|||
/* eslint-disable no-console */
|
||||
const fs = require("fs");
|
||||
const {mkdir} = require("shelljs");
|
||||
const path = require("path");
|
||||
|
||||
// Note: this file is generated by webpack from content-src/activity-stream-prerender.jsx
|
||||
const {prerender} = require("./prerender");
|
||||
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const DEFAULT_OPTIONS = {
|
||||
addonPath: "..",
|
||||
baseUrl: "resource://activity-stream/"
|
||||
};
|
||||
|
||||
// This locales list is to find any similar locales that we can reuse strings
|
||||
// instead of falling back to the default, e.g., use bn-BD strings for bn-IN.
|
||||
// https://hg.mozilla.org/mozilla-central/file/tip/browser/locales/l10n.toml
|
||||
const CENTRAL_LOCALES = [
|
||||
"ach",
|
||||
"af",
|
||||
"an",
|
||||
"ar",
|
||||
"as",
|
||||
"ast",
|
||||
"az",
|
||||
"be",
|
||||
"bg",
|
||||
"bn-BD",
|
||||
"bn-IN",
|
||||
"br",
|
||||
"bs",
|
||||
"ca",
|
||||
"cak",
|
||||
"crh",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"dsb",
|
||||
"el",
|
||||
"en-CA",
|
||||
"en-GB",
|
||||
"en-ZA",
|
||||
"eo",
|
||||
"es-AR",
|
||||
"es-CL",
|
||||
"es-ES",
|
||||
"es-MX",
|
||||
"et",
|
||||
"eu",
|
||||
"fa",
|
||||
"ff",
|
||||
"fi",
|
||||
"fr",
|
||||
"fy-NL",
|
||||
"ga-IE",
|
||||
"gd",
|
||||
"gl",
|
||||
"gn",
|
||||
"gu-IN",
|
||||
"he",
|
||||
"hi-IN",
|
||||
"hr",
|
||||
"hsb",
|
||||
"hu",
|
||||
"hy-AM",
|
||||
"ia",
|
||||
"id",
|
||||
"is",
|
||||
"it",
|
||||
"ja",
|
||||
"ja-JP-mac",
|
||||
"ka",
|
||||
"kab",
|
||||
"kk",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"lij",
|
||||
"lo",
|
||||
"lt",
|
||||
"ltg",
|
||||
"lv",
|
||||
"mai",
|
||||
"mk",
|
||||
"ml",
|
||||
"mr",
|
||||
"ms",
|
||||
"my",
|
||||
"nb-NO",
|
||||
"ne-NP",
|
||||
"nl",
|
||||
"nn-NO",
|
||||
"oc",
|
||||
"or",
|
||||
"pa-IN",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"rm",
|
||||
"ro",
|
||||
"ru",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"son",
|
||||
"sq",
|
||||
"sr",
|
||||
"sv-SE",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tl",
|
||||
"tr",
|
||||
"uk",
|
||||
"ur",
|
||||
"uz",
|
||||
"vi",
|
||||
"wo",
|
||||
"xh",
|
||||
"zh-CN",
|
||||
"zh-TW"
|
||||
];
|
||||
|
||||
// Locales that should be displayed RTL
|
||||
const RTL_LIST = ["ar", "he", "fa", "ur"];
|
||||
|
||||
/**
|
||||
* Get the language part of the locale.
|
||||
*/
|
||||
function getLanguage(locale) {
|
||||
return locale.split("-")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best strings for a single provided locale using similar locales and
|
||||
* DEFAULT_LOCALE as fallbacks.
|
||||
*/
|
||||
function getStrings(locale, allStrings) {
|
||||
const availableLocales = Object.keys(allStrings);
|
||||
|
||||
const language = getLanguage(locale);
|
||||
const similarLocales = availableLocales.filter(other =>
|
||||
other !== locale && getLanguage(other) === language);
|
||||
|
||||
// Rank locales from least desired to most desired
|
||||
const localeFallbacks = [DEFAULT_LOCALE, ...similarLocales, locale];
|
||||
|
||||
// Get strings from each locale replacing with those from more desired ones
|
||||
return Object.assign({}, ...localeFallbacks.map(l => allStrings[l]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text direction of the locale.
|
||||
*/
|
||||
function getTextDirection(locale) {
|
||||
return RTL_LIST.includes(locale.split("-")[0]) ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* templateHTML - Generates HTML for activity stream, given some options and
|
||||
* prerendered HTML if necessary.
|
||||
*
|
||||
* @param {obj} options
|
||||
* {str} options.locale The locale to render in lang="" attribute
|
||||
* {str} options.direction The language direction to render in dir="" attribute
|
||||
* {str} options.baseUrl The base URL for all local assets
|
||||
* {bool} options.debug Should we use dev versions of JS libraries?
|
||||
* @param {str} html The prerendered HTML created with React.renderToString (optional)
|
||||
* @return {str} An HTML document as a string
|
||||
*/
|
||||
function templateHTML(options, html) {
|
||||
const isPrerendered = !!html;
|
||||
const debugString = options.debug ? "-dev" : "";
|
||||
const scripts = [
|
||||
"chrome://browser/content/contentSearchUI.js",
|
||||
`${options.baseUrl}vendor/react${debugString}.js`,
|
||||
`${options.baseUrl}vendor/react-dom${debugString}.js`,
|
||||
`${options.baseUrl}vendor/prop-types.js`,
|
||||
`${options.baseUrl}vendor/react-intl.js`,
|
||||
`${options.baseUrl}vendor/redux.js`,
|
||||
`${options.baseUrl}vendor/react-redux.js`,
|
||||
`${options.baseUrl}prerendered/${options.locale}/activity-stream-strings.js`,
|
||||
`${options.baseUrl}data/content/activity-stream.bundle.js`
|
||||
];
|
||||
if (isPrerendered) {
|
||||
scripts.unshift(`${options.baseUrl}prerendered/static/activity-stream-initial-state.js`);
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html lang="${options.locale}" dir="${options.direction}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy-Report-Only" content="script-src 'unsafe-inline'; img-src http: https: data: blob:; style-src 'unsafe-inline'; child-src 'none'; object-src 'none'; report-uri https://tiles.services.mozilla.com/v4/links/activity-stream/csp">
|
||||
<title>${options.strings.newtab_page_title}</title>
|
||||
<link rel="icon" type="image/png" href="chrome://branding/content/icon32.png"/>
|
||||
<link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
|
||||
<link rel="stylesheet" href="${options.baseUrl}css/activity-stream.css" />
|
||||
</head>
|
||||
<body class="activity-stream">
|
||||
<div id="root">${isPrerendered ? html : ""}</div>
|
||||
<div id="snippets-container">
|
||||
<div id="snippets"></div>
|
||||
</div>
|
||||
<script>
|
||||
// Don't directly load the following scripts as part of html to let the page
|
||||
// finish loading to render the content sooner.
|
||||
for (const src of ${JSON.stringify(scripts, null, 2)}) {
|
||||
// These dynamically inserted scripts by default are async, but we need them
|
||||
// to load in the desired order (i.e., bundle last).
|
||||
const script = document.body.appendChild(document.createElement("script"));
|
||||
script.async = false;
|
||||
script.src = src;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* templateJs - Generates a js file that passes the initial state of the prerendered
|
||||
* DOM to the React version. This is necessary to ensure the checksum matches when
|
||||
* React mounts so that it can attach to the prerendered elements instead of blowing
|
||||
* them away.
|
||||
*
|
||||
* Note that this may no longer be necessary in React 16 and we should review whether
|
||||
* it is still necessary.
|
||||
*
|
||||
* @param {string} name The name of the global to expose
|
||||
* @param {string} desc Extra description to include in a js comment
|
||||
* @param {obj} state The data to expose as a window global
|
||||
* @return {str} The js file as a string
|
||||
*/
|
||||
function templateJs(name, desc, state) {
|
||||
return `// Note - this is a generated ${desc} file.
|
||||
window.${name} = ${JSON.stringify(state, null, 2)};
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* writeFiles - Writes to the desired files the result of a template given
|
||||
* various prerendered data and options.
|
||||
*
|
||||
* @param {string} name Something to identify in the console
|
||||
* @param {string} destPath Path to write the files to
|
||||
* @param {Map} filesMap Mapping of a string file name to templater
|
||||
* @param {Object} prerenderData Contains the html and state
|
||||
* @param {Object} options Various options for the templater
|
||||
*/
|
||||
function writeFiles(name, destPath, filesMap, {html, state}, options) {
|
||||
for (const [file, templater] of filesMap) {
|
||||
fs.writeFileSync(path.join(destPath, file), templater({html, options, state}));
|
||||
}
|
||||
console.log("\x1b[32m", `✓ ${name}`, "\x1b[0m");
|
||||
}
|
||||
|
||||
const STATIC_FILES = new Map([
|
||||
["activity-stream-debug.html", ({options}) => templateHTML(options)],
|
||||
["activity-stream-initial-state.js", ({state}) => templateJs("gActivityStreamPrerenderedState", "static", state)],
|
||||
["activity-stream-prerendered-debug.html", ({html, options}) => templateHTML(options, html)]
|
||||
]);
|
||||
|
||||
const LOCALIZED_FILES = new Map([
|
||||
["activity-stream-prerendered.html", ({html, options}) => templateHTML(options, html)],
|
||||
["activity-stream-strings.js", ({options: {locale, strings}}) => templateJs("gActivityStreamStrings", locale, strings)],
|
||||
["activity-stream.html", ({options}) => templateHTML(options)]
|
||||
]);
|
||||
|
||||
/**
|
||||
* main - Parses command line arguments, generates html and js with templates,
|
||||
* and writes files to their specified locations.
|
||||
*/
|
||||
function main() { // eslint-disable-line max-statements
|
||||
// This code parses command line arguments passed to this script.
|
||||
// Note: process.argv.slice(2) is necessary because the first two items in
|
||||
// process.argv are paths
|
||||
const args = require("minimist")(process.argv.slice(2), {
|
||||
alias: {
|
||||
addonPath: "a",
|
||||
baseUrl: "b"
|
||||
}
|
||||
});
|
||||
|
||||
const baseOptions = Object.assign({debug: false}, DEFAULT_OPTIONS, args || {});
|
||||
const addonPath = path.resolve(__dirname, baseOptions.addonPath);
|
||||
const allStrings = require(`${baseOptions.addonPath}/data/locales.json`);
|
||||
const extraLocales = Object.keys(allStrings).filter(locale =>
|
||||
locale !== DEFAULT_LOCALE && !CENTRAL_LOCALES.includes(locale));
|
||||
|
||||
const prerenderedPath = path.join(addonPath, "prerendered");
|
||||
console.log(`Writing prerendered files to individual directories under ${prerenderedPath}:`);
|
||||
|
||||
// Save default locale's strings to compare against other locales' strings
|
||||
let defaultStrings;
|
||||
let langStrings;
|
||||
const isSubset = (strings, existing) => existing &&
|
||||
Object.keys(strings).every(key => strings[key] === existing[key]);
|
||||
|
||||
// Process the default locale first then all the ones from mozilla-central
|
||||
const localizedLocales = [];
|
||||
const skippedLocales = [];
|
||||
for (const locale of [DEFAULT_LOCALE, ...CENTRAL_LOCALES, ...extraLocales]) {
|
||||
// Skip the locale if it would have resulted in duplicate packaged files
|
||||
const strings = getStrings(locale, allStrings);
|
||||
if (isSubset(strings, defaultStrings) || isSubset(strings, langStrings)) {
|
||||
skippedLocales.push(locale);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prerenderData = prerender(locale, strings);
|
||||
const options = Object.assign({}, baseOptions, {
|
||||
direction: getTextDirection(locale),
|
||||
locale,
|
||||
strings
|
||||
});
|
||||
|
||||
// Put locale-specific files in their own directory
|
||||
const localePath = path.join(prerenderedPath, "locales", locale);
|
||||
mkdir("-p", localePath);
|
||||
writeFiles(locale, localePath, LOCALIZED_FILES, prerenderData, options);
|
||||
|
||||
// Only write static files once for the default locale
|
||||
if (locale === DEFAULT_LOCALE) {
|
||||
const staticPath = path.join(prerenderedPath, "static");
|
||||
mkdir("-p", staticPath);
|
||||
writeFiles(`${locale} (static)`, staticPath, STATIC_FILES, prerenderData,
|
||||
Object.assign({}, options, {debug: true}));
|
||||
|
||||
// Save the default strings to compare against other locales' strings
|
||||
defaultStrings = strings;
|
||||
}
|
||||
|
||||
// Save the language's strings to maybe reuse for the next similar locales
|
||||
if (getLanguage(locale) === locale) {
|
||||
langStrings = strings;
|
||||
}
|
||||
|
||||
localizedLocales.push(locale);
|
||||
}
|
||||
|
||||
if (skippedLocales.length) {
|
||||
console.log("\x1b[33m", `Skipped the following locales because they use the same strings as ${DEFAULT_LOCALE} or its language locale: ${skippedLocales.join(", ")}`, "\x1b[0m");
|
||||
}
|
||||
if (extraLocales.length) {
|
||||
console.log("\x1b[31m", `✗ These locales were not in CENTRAL_LOCALES, but probably should be: ${extraLocales.join(", ")}`, "\x1b[0m");
|
||||
}
|
||||
|
||||
// Provide some help to copy/paste locales if tests are failing
|
||||
console.log(`\nIf aboutNewTabService tests are failing for unexpected locales, make sure its list is updated:\nconst ACTIVITY_STREAM_LOCALES = "${localizedLocales.join(" ")}".split(" ");`);
|
||||
}
|
||||
|
||||
main();
|
|
@ -0,0 +1,75 @@
|
|||
#! /usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable no-console */
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
/* globals cd, ls, mkdir, rm, ShellString */
|
||||
require("shelljs/global");
|
||||
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const L10N_CENTRAL = "https://hg.mozilla.org/l10n-central";
|
||||
const PROPERTIES_PATH = "raw-file/default/browser/chrome/browser/activity-stream/newtab.properties";
|
||||
const STRINGS_FILE = "strings.properties";
|
||||
|
||||
// Get all the locales in l10n-central
|
||||
async function getLocales() {
|
||||
console.log(`Getting locales from ${L10N_CENTRAL}`);
|
||||
|
||||
// Add all non-test sub repository locales
|
||||
const locales = [];
|
||||
const subrepos = await (await fetch(`${L10N_CENTRAL}?style=json`)).json();
|
||||
subrepos.entries.forEach(({name}) => {
|
||||
if (name !== "x-testing") {
|
||||
locales.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Got ${locales.length} locales: ${locales}`);
|
||||
return locales;
|
||||
}
|
||||
|
||||
// Save the properties file to the locale's directory
|
||||
async function saveProperties(locale) {
|
||||
// Only save a file if the repository has the file
|
||||
const url = `${L10N_CENTRAL}/${locale}/${PROPERTIES_PATH}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
// Indicate that this locale didn't save
|
||||
return locale;
|
||||
}
|
||||
|
||||
// Save the file to the right place
|
||||
const text = await response.text();
|
||||
mkdir(locale);
|
||||
cd(locale);
|
||||
ShellString(text).to(STRINGS_FILE);
|
||||
cd("..");
|
||||
|
||||
// Indicate that we were successful in saving
|
||||
return "";
|
||||
}
|
||||
|
||||
// Replace and update each locale's strings
|
||||
async function updateLocales() {
|
||||
console.log("Switching to and deleting existing l10n tree under: locales");
|
||||
|
||||
cd("locales");
|
||||
ls().forEach(dir => {
|
||||
// Keep the default/source locale as it might have newer strings
|
||||
if (dir !== DEFAULT_LOCALE) {
|
||||
rm("-r", dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Save the properties file for each locale in parallel
|
||||
const locales = await getLocales();
|
||||
const missing = (await Promise.all(locales.map(saveProperties))).filter(v => v);
|
||||
console.log(`Skipped ${missing.length} locales without strings: ${missing.sort()}`);
|
||||
|
||||
console.log(`
|
||||
Please check the diffs, add/remove files, and then commit the result. Suggested commit message:
|
||||
chore(l10n): Update from l10n-central ${new Date()}`);
|
||||
}
|
||||
|
||||
updateLocales().catch(console.error);
|
|
@ -0,0 +1,344 @@
|
|||
#! /usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable no-console, mozilla/no-task */
|
||||
/* this is a node script; primary interaction is via console */
|
||||
|
||||
const Task = require("co-task");
|
||||
const process = require("process");
|
||||
const path = require("path");
|
||||
const GitHubApi = require("@octokit/rest");
|
||||
const shelljs = require("shelljs");
|
||||
const child_process = require("child_process");
|
||||
const github = new GitHubApi();
|
||||
|
||||
// some of our API requests need to be authenticated
|
||||
let token = process.env.AS_PINE_TOKEN;
|
||||
github.authenticate({type: "token", token});
|
||||
|
||||
// note that this token MUST have the public_repo scope set in the github API
|
||||
|
||||
const AS_REPO_OWNER = process.env.AS_REPO_OWNER || "mozilla";
|
||||
const AS_REPO_NAME = process.env.AS_REPO_NAME || "activity-stream";
|
||||
const AS_REPO = `${AS_REPO_OWNER}/${AS_REPO_NAME}`;
|
||||
const OLDEST_PR_DATE = "2017-03-17";
|
||||
const HG = "hg"; // mercurial
|
||||
const HG_BRANCH_NAME = "pine";
|
||||
const ALREADY_PUSHED_LABEL = "pushed-to-pine";
|
||||
const TREEHERDER_PREFIX = "https://treeherder.mozilla.org/#/jobs?repo=pine&revision=";
|
||||
|
||||
// Path to the working directory where the export/commit operations will be
|
||||
// done. Highly advisted to be used only for this testing purpose so you don't
|
||||
// accidently clobber real work.
|
||||
//
|
||||
// There will be two child directories:
|
||||
//
|
||||
// activity-stream - the github repo to be exported from. MUST
|
||||
//
|
||||
// * be cloned by hand before running this script
|
||||
// * be 'npm install'ed
|
||||
// * have the ${ALREADY_PUSHED_LABEL} label created by hand
|
||||
// * have the user who has issued AS_PINE_TOKEN as a collaborator for the repo
|
||||
// in order to be able to change labels.
|
||||
//
|
||||
// mozilla-central - the hg repo for firefox. Will be created if it doesn't
|
||||
// already exist.
|
||||
const {AS_PINE_TEST_DIR} = process.env;
|
||||
|
||||
const TESTING_LOCAL_MC = path.join(AS_PINE_TEST_DIR, "mozilla-central");
|
||||
|
||||
const SimpleGit = require("simple-git");
|
||||
const TESTING_LOCAL_GIT = path.join(AS_PINE_TEST_DIR, AS_REPO_NAME);
|
||||
const git = new SimpleGit(TESTING_LOCAL_GIT);
|
||||
|
||||
// Mostly useful to specify during development of the test automation so that
|
||||
// prepare-mochitests-dev and friends from the development repo get used
|
||||
// instead of from the testing repo, which won't have had any changes checked in
|
||||
// just yet.
|
||||
const AS_GIT_BIN_REPO = process.env.AS_GIT_BIN_REPO || TESTING_LOCAL_GIT;
|
||||
|
||||
const PREPARE_MOCHITESTS_DEV =
|
||||
path.join(AS_GIT_BIN_REPO, "bin", "prepare-mochitests-dev");
|
||||
|
||||
/**
|
||||
* Find all PRs merged since ${OLDEST_PR_DATE} that don't have
|
||||
* ${ALREADY_PUSHED_LABEL}
|
||||
*
|
||||
* @return {Promise} Promise that resolves with the search results or rejects
|
||||
*/
|
||||
function findNewlyMergedPRs() {
|
||||
const searchTerms = [
|
||||
// closed PRs in our repo
|
||||
`repo:${AS_REPO}`, "type:pr", "state:closed", "is:merged",
|
||||
|
||||
// don't try and mochitest old closed stuff, we don't want to kick off a
|
||||
// zillion test jobs
|
||||
`merged:>=${OLDEST_PR_DATE}`,
|
||||
|
||||
// only look at merges to master
|
||||
"base:master",
|
||||
|
||||
// if it's already been pushed to pine, don't do it again
|
||||
`-label:${ALREADY_PUSHED_LABEL}`
|
||||
];
|
||||
|
||||
console.log(`Searching ${AS_REPO} for newly merged PRs`);
|
||||
return github.search.issues({q: searchTerms.join("+")});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the commitId when the given PR was merged. This is the one
|
||||
* we will want to export and test.
|
||||
*
|
||||
* @param {String} prNumber The number of the PR to export.
|
||||
* @return {String} The commitId associated with the merge of this PR.
|
||||
*/
|
||||
function getPRMergeCommitId(prNumber) {
|
||||
return github.issues.getEvents({
|
||||
owner: AS_REPO_OWNER,
|
||||
repo: AS_REPO_NAME,
|
||||
issue_number: prNumber
|
||||
}).then(({data}) => {
|
||||
if (data.incomplete_results) {
|
||||
// XXX should handle this case theoretically, but since we'll be running
|
||||
// regularly from cron, it seems unlikely that we'll even hit 30 new
|
||||
// merges (default GitHub page size) in a single run.
|
||||
throw new Error("data.incomplete_results is true, aborting");
|
||||
}
|
||||
|
||||
let mergeEvents = data.filter(item => item.event === "merged");
|
||||
if (mergeEvents.length > 1) {
|
||||
throw new Error("more than one merge event, aborting");
|
||||
} else if (!mergeEvents.length) {
|
||||
throw new Error(`Github returned no merge events for PR ${prNumber}, aborting. Workaround: mark this PR as pushed-to-pine, so it gets skipped`);
|
||||
}
|
||||
let [mergeEvent] = mergeEvents;
|
||||
|
||||
if (!mergeEvent.commit_id) {
|
||||
throw new Error("merge event has no commit id attached, aborted");
|
||||
}
|
||||
|
||||
return mergeEvent.commit_id;
|
||||
}).catch(err => { throw err; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks out the given commit into ${TESTING_LOCAL_GIT}
|
||||
*
|
||||
* @param {String} commitId
|
||||
* @return {Promise<String[]|?>} Resolves with commit [id, message] on checkout, or
|
||||
* rejects with error
|
||||
*/
|
||||
function checkoutGitCommit(commitId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Fetching changes from github remote ${AS_REPO}...`);
|
||||
// fetch any changes from the remote
|
||||
git.fetch({}, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log(`Starting github checkout of ${commitId}...`);
|
||||
git.checkout(commitId, (err2, data2) => {
|
||||
if (err2) {
|
||||
reject(err2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass along the original commit message
|
||||
git.show(["-s", "--format=%B"], (err3, data3) => {
|
||||
if (err3) {
|
||||
reject(err3);
|
||||
return;
|
||||
}
|
||||
resolve([commitId, data3.trim()]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function exportToLocalMC(commitId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("Preparing mochitest dev environment...");
|
||||
// Weirdly, /bin/yes causes npm-run-all bundle-static to explode, so we
|
||||
// use echo.
|
||||
shelljs.exec(`
|
||||
echo yes | \
|
||||
env AS_GIT_BIN_REPO=${AS_GIT_BIN_REPO} SYMLINK_TESTS=false \
|
||||
ENABLE_MC_AS=1 ${PREPARE_MOCHITESTS_DEV}`,
|
||||
{async: true, cwd: TESTING_LOCAL_GIT, silent: false}, (code, stdout, stderr) => {
|
||||
if (code) {
|
||||
reject(new Error(`${PREPARE_MOCHITESTS_DEV} failed, exit code: ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(commitId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function commitToHg([commitId, commitMsg]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// we use child_process.execFile here because shelljs.exec goes through
|
||||
// the shell, which means that if the original commit message has shell
|
||||
// quote characters, things can go haywire in weird ways.
|
||||
console.log(`Committing exported ${commitId} to ${AS_REPO_NAME}...`);
|
||||
child_process.execFile(HG,
|
||||
[
|
||||
"commit",
|
||||
"--addremove",
|
||||
"-m",
|
||||
`${commitMsg}\n\nExport of ${commitId} from ${AS_REPO_OWNER}/${AS_REPO_NAME}`,
|
||||
"."
|
||||
],
|
||||
{cwd: TESTING_LOCAL_MC, env: process.env, timeout: 5 * 60 * 1000},
|
||||
(code, stdout, stderr) => {
|
||||
if (code) {
|
||||
reject(new Error(`${HG} commit failed, output: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(code);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [pushToHgProjectBranch description]
|
||||
*
|
||||
* @return {Promise<String|Number>} resolves with the text written to XXXstdout, or
|
||||
* rejects with the exit code from ${HG}.
|
||||
*/
|
||||
function pushToHgProjectBranch() {
|
||||
return new Promise((resolve, reject) => {
|
||||
shelljs.exec(`${HG} push -f ${HG_BRANCH_NAME}`, {async: true, cwd: TESTING_LOCAL_MC},
|
||||
(code, stdout, stderr) => {
|
||||
if (code) {
|
||||
reject(new Error(`${HG} failed, exit code: ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the last linked revision from the push output
|
||||
const [rev] = stdout.split(/(?:\/rev\/|changeset=)/).slice(-1)[0].split("\n");
|
||||
resolve(`[Treeherder: ${rev}](${TREEHERDER_PREFIX}${rev})`);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove last commit from the repo so the next artifact build will work right
|
||||
*/
|
||||
function stripTipFromHg() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("Stripping tip commit from mozilla-central so the next artifact build will work ...");
|
||||
shelljs.exec(`${HG} strip --force --rev -1`,
|
||||
{async: true, cwd: TESTING_LOCAL_MC},
|
||||
(code, stdout, stderr) => {
|
||||
if (code) {
|
||||
reject(new Error(`${HG} strip failed, output: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(code);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function annotateGithubPR(prNumber, annotation) {
|
||||
console.log(`Annotating ${prNumber} with ${annotation}...`);
|
||||
|
||||
// We use createComment from issues instead of pullRequests because we're
|
||||
// not commenting on a particular commit
|
||||
return github.issues.createComment({
|
||||
owner: AS_REPO_OWNER,
|
||||
repo: AS_REPO_NAME,
|
||||
number: prNumber,
|
||||
body: annotation
|
||||
}).catch(reason => console.log(reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels a given github PR ${ALREADY_PUSHED_LABEL}.
|
||||
*/
|
||||
function labelGithubPR(prNumber) {
|
||||
console.log(`Labeling PR ${prNumber} with ${ALREADY_PUSHED_LABEL}...`);
|
||||
|
||||
return github.issues.addLabels({
|
||||
owner: AS_REPO_OWNER,
|
||||
repo: AS_REPO_NAME,
|
||||
number: prNumber,
|
||||
labels: [ALREADY_PUSHED_LABEL]
|
||||
}).catch(reason => console.log(reason));
|
||||
}
|
||||
|
||||
function pushPR(pr) {
|
||||
return getPRMergeCommitId(pr.number)
|
||||
|
||||
// get the merged commit to test
|
||||
.then(checkoutGitCommit)
|
||||
|
||||
// use prepare-mochitest-dev to export
|
||||
.then(exportToLocalMC)
|
||||
|
||||
// commit latest export to hg
|
||||
.then(commitToHg)
|
||||
|
||||
// hg push
|
||||
.then(() => pushToHgProjectBranch().catch(() => {
|
||||
stripTipFromHg();
|
||||
throw new Error("pushToHgProjectBranch failed; tip stripped from hg");
|
||||
}))
|
||||
|
||||
// annotate PR with URL to watch
|
||||
.then(annotation => annotateGithubPR(pr.number, annotation))
|
||||
|
||||
// make sure next artifact build doesn't explode
|
||||
.then(() => stripTipFromHg())
|
||||
|
||||
// label with ${ALREADY_PUSHED_LABEL}
|
||||
.then(() => labelGithubPR(pr.number))
|
||||
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
findNewlyMergedPRs().then(({data}) => {
|
||||
if (data.incomplete_results) {
|
||||
throw new Error("data.incomplete_results is true, aborting");
|
||||
}
|
||||
|
||||
if (data.items.length === 0) {
|
||||
console.log("No newly merged PRs to test");
|
||||
return;
|
||||
}
|
||||
|
||||
function* executePush() {
|
||||
for (let pr of data.items) {
|
||||
yield pushPR(pr);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the execution of the export and pushing tests since each
|
||||
// depend on exclusive access the state of the git and hg repos used to
|
||||
// stage the tests.
|
||||
Task.spawn(executePush).then(() => {
|
||||
console.log("Processed all new merges.");
|
||||
}).catch(reason => {
|
||||
console.log("Something went wrong processing the merges:", reason);
|
||||
process.exitCode = -1;
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error(reason);
|
||||
process.exitCode = -1;
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
|
@ -0,0 +1,62 @@
|
|||
#! /usr/bin/env node
|
||||
/* globals cd, sed */
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Generate update install.rdf.in in the given directory with a version string
|
||||
* composed of YYYY.MM.DD.${minuteOfDay}-${github_commit_hash}.
|
||||
*
|
||||
* @note The github hash is taken from the github repo in the current directory
|
||||
* the script is run in.
|
||||
*
|
||||
* @note The minute of the day was chosen so that the version number is
|
||||
* (more-or-less) consistently increasing (modulo clock-skew and builds that
|
||||
* happen within a minute of each other), and although it's UTC, it won't likely
|
||||
* be confused with something in a readers own time zone.
|
||||
*
|
||||
* @example generated version string: 2017.08.28.1217-ebda466c
|
||||
*/
|
||||
const process = require("process");
|
||||
require("shelljs/global");
|
||||
const simpleGit = require("simple-git")(process.cwd());
|
||||
|
||||
const time = new Date();
|
||||
const minuteOfDay = time.getUTCHours() * 60 + time.getUTCMinutes();
|
||||
|
||||
/**
|
||||
* Return the given string padded with 0s out to the given width.
|
||||
*
|
||||
* XXX we should ditch this function in favor of using padStart once
|
||||
* we start requiring Node 8.
|
||||
*
|
||||
* @param {any} s - the string to pad, will be coerced to String first
|
||||
* @param {Number} width - what's the desired width?
|
||||
*/
|
||||
function zeroPadStart(s, width) {
|
||||
let padded = String(s);
|
||||
while (padded.length < width) {
|
||||
padded = `0${padded}`;
|
||||
}
|
||||
|
||||
return padded;
|
||||
}
|
||||
|
||||
// git rev-parse --short HEAD
|
||||
simpleGit.revparse(["--short", "HEAD"], (err, gitHash) => {
|
||||
if (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`SimpleGit.revparse failed: ${err}`);
|
||||
throw new Error(`SimpleGit.revparse failed: ${err}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-template
|
||||
let versionString = String(time.getUTCFullYear()) +
|
||||
"." + zeroPadStart(time.getUTCMonth() + 1, 2) +
|
||||
"." + zeroPadStart(time.getUTCDate(), 2) +
|
||||
"." + zeroPadStart(minuteOfDay, 4) +
|
||||
"-" + gitHash.trim();
|
||||
|
||||
cd(process.argv[2]);
|
||||
sed("-i", /(<em:version>).+(<\/em:version>)$/, `$1${versionString}$2`,
|
||||
"install.rdf.in");
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
rules: {
|
||||
"import/no-commonjs": 2
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import {INITIAL_STATE, reducers} from "common/Reducers.jsm";
|
||||
import {actionTypes as at} from "common/Actions.jsm";
|
||||
import {Base} from "content-src/components/Base/Base";
|
||||
import {initStore} from "content-src/lib/init-store";
|
||||
import {PrerenderData} from "common/PrerenderData.jsm";
|
||||
import {Provider} from "react-redux";
|
||||
import React from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
|
||||
/**
|
||||
* prerenderStore - Generate a store with the initial state required for a prerendered page
|
||||
*
|
||||
* @return {obj} A store
|
||||
*/
|
||||
export function prerenderStore() {
|
||||
const store = initStore(reducers, INITIAL_STATE);
|
||||
store.dispatch({type: at.PREFS_INITIAL_VALUES, data: PrerenderData.initialPrefs});
|
||||
PrerenderData.initialSections.forEach(data => store.dispatch({type: at.SECTION_REGISTER, data}));
|
||||
return store;
|
||||
}
|
||||
|
||||
export function prerender(locale, strings,
|
||||
renderToString = ReactDOMServer.renderToString) {
|
||||
const store = prerenderStore();
|
||||
|
||||
const html = renderToString(
|
||||
<Provider store={store}>
|
||||
<Base
|
||||
isPrerendered={true}
|
||||
locale={locale}
|
||||
strings={strings} />
|
||||
</Provider>);
|
||||
|
||||
// If this happens, it means pre-rendering is effectively disabled, so we
|
||||
// need to sound the alarms:
|
||||
if (!html || !html.length) {
|
||||
throw new Error("no HTML returned");
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
state: store.getState(),
|
||||
store
|
||||
};
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {addSnippetsSubscriber} from "content-src/lib/snippets";
|
||||
import {Base} from "content-src/components/Base/Base";
|
||||
import {DetectUserSessionStart} from "content-src/lib/detect-user-session-start";
|
||||
import {initStore} from "content-src/lib/init-store";
|
||||
import {Provider} from "react-redux";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import {reducers} from "common/Reducers.jsm";
|
||||
|
||||
const store = initStore(reducers, global.gActivityStreamPrerenderedState);
|
||||
|
||||
new DetectUserSessionStart(store).sendEventOrAddListener();
|
||||
|
||||
// If we are starting in a prerendered state, we must wait until the first render
|
||||
// to request state rehydration (see Base.jsx). If we are NOT in a prerendered state,
|
||||
// we can request it immedately.
|
||||
if (!global.gActivityStreamPrerenderedState) {
|
||||
store.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
|
||||
}
|
||||
|
||||
ReactDOM.hydrate(<Provider store={store}>
|
||||
<Base
|
||||
isFirstrun={global.document.location.href === "about:welcome"}
|
||||
isPrerendered={!!global.gActivityStreamPrerenderedState}
|
||||
locale={global.document.documentElement.lang}
|
||||
strings={global.gActivityStreamStrings} />
|
||||
</Provider>, document.getElementById("root"));
|
||||
|
||||
addSnippetsSubscriber(store);
|
|
@ -0,0 +1,188 @@
|
|||
import {actionCreators as ac, ASRouterActions as ra} from "common/Actions.jsm";
|
||||
import {OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME} from "content-src/lib/init-store";
|
||||
import {ImpressionsWrapper} from "./components/ImpressionsWrapper/ImpressionsWrapper";
|
||||
import {OnboardingMessage} from "./templates/OnboardingMessage/OnboardingMessage";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import {SimpleSnippet} from "./templates/SimpleSnippet/SimpleSnippet";
|
||||
|
||||
const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child";
|
||||
const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent";
|
||||
|
||||
export const ASRouterUtils = {
|
||||
addListener(listener) {
|
||||
global.addMessageListener(INCOMING_MESSAGE_NAME, listener);
|
||||
},
|
||||
removeListener(listener) {
|
||||
global.removeMessageListener(INCOMING_MESSAGE_NAME, listener);
|
||||
},
|
||||
sendMessage(action) {
|
||||
global.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
|
||||
},
|
||||
blockById(id) {
|
||||
ASRouterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id}});
|
||||
},
|
||||
blockBundle(bundle) {
|
||||
ASRouterUtils.sendMessage({type: "BLOCK_BUNDLE", data: {bundle}});
|
||||
},
|
||||
executeAction({button_action, button_action_params}) {
|
||||
if (button_action in ra) {
|
||||
ASRouterUtils.sendMessage({type: button_action, data: {button_action_params}});
|
||||
}
|
||||
},
|
||||
unblockById(id) {
|
||||
ASRouterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
|
||||
},
|
||||
unblockBundle(bundle) {
|
||||
ASRouterUtils.sendMessage({type: "UNBLOCK_BUNDLE", data: {bundle}});
|
||||
},
|
||||
getNextMessage() {
|
||||
ASRouterUtils.sendMessage({type: "GET_NEXT_MESSAGE"});
|
||||
},
|
||||
overrideMessage(id) {
|
||||
ASRouterUtils.sendMessage({type: "OVERRIDE_MESSAGE", data: {id}});
|
||||
},
|
||||
sendTelemetry(ping) {
|
||||
const payload = ac.ASRouterUserEvent(ping);
|
||||
global.sendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
|
||||
function shouldSendImpressionOnUpdate(nextProps, prevProps) {
|
||||
return (nextProps.message.id && (!prevProps.message || prevProps.message.id !== nextProps.message.id));
|
||||
}
|
||||
|
||||
export class ASRouterUISurface extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onMessageFromParent = this.onMessageFromParent.bind(this);
|
||||
this.sendImpression = this.sendImpression.bind(this);
|
||||
this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
|
||||
this.state = {message: {}, bundle: {}};
|
||||
}
|
||||
|
||||
sendUserActionTelemetry(extraProps = {}) {
|
||||
const {message, bundle} = this.state;
|
||||
if (!message && !extraProps.message_id) {
|
||||
throw new Error(`You must provide a message_id for bundled messages`);
|
||||
}
|
||||
const eventType = `${message.provider || bundle.provider}_user_event`;
|
||||
ASRouterUtils.sendTelemetry({
|
||||
message_id: message.id || extraProps.message_id,
|
||||
source: extraProps.id,
|
||||
action: eventType,
|
||||
...extraProps
|
||||
});
|
||||
}
|
||||
|
||||
sendImpression(extraProps) {
|
||||
this.sendUserActionTelemetry({event: "IMPRESSION", ...extraProps});
|
||||
}
|
||||
|
||||
onBlockById(id) {
|
||||
return () => ASRouterUtils.blockById(id);
|
||||
}
|
||||
|
||||
clearBundle(bundle) {
|
||||
return () => ASRouterUtils.blockBundle(bundle);
|
||||
}
|
||||
|
||||
onMessageFromParent({data: action}) {
|
||||
switch (action.type) {
|
||||
case "SET_MESSAGE":
|
||||
this.setState({message: action.data});
|
||||
break;
|
||||
case "SET_BUNDLED_MESSAGES":
|
||||
this.setState({bundle: action.data});
|
||||
break;
|
||||
case "CLEAR_MESSAGE":
|
||||
if (action.data.id === this.state.message.id) {
|
||||
this.setState({message: {}});
|
||||
}
|
||||
break;
|
||||
case "CLEAR_BUNDLE":
|
||||
if (this.state.bundle.bundle) {
|
||||
this.setState({bundle: {}});
|
||||
}
|
||||
break;
|
||||
case "CLEAR_ALL":
|
||||
this.setState({message: {}, bundle: {}});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
ASRouterUtils.addListener(this.onMessageFromParent);
|
||||
ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST"});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
ASRouterUtils.removeListener(this.onMessageFromParent);
|
||||
}
|
||||
|
||||
renderSnippets() {
|
||||
return (
|
||||
<ImpressionsWrapper
|
||||
id="NEWTAB_FOOTER_BAR"
|
||||
message={this.state.message}
|
||||
sendImpression={this.sendImpression}
|
||||
shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
|
||||
// This helps with testing
|
||||
document={this.props.document}>
|
||||
<SimpleSnippet
|
||||
{...this.state.message}
|
||||
UISurface="NEWTAB_FOOTER_BAR"
|
||||
getNextMessage={ASRouterUtils.getNextMessage}
|
||||
onBlock={this.onBlockById(this.state.message.id)}
|
||||
sendUserActionTelemetry={this.sendUserActionTelemetry} />
|
||||
</ImpressionsWrapper>);
|
||||
}
|
||||
|
||||
renderOnboarding() {
|
||||
return (
|
||||
<OnboardingMessage
|
||||
{...this.state.bundle}
|
||||
UISurface="NEWTAB_OVERLAY"
|
||||
onAction={ASRouterUtils.executeAction}
|
||||
onDoneButton={this.clearBundle(this.state.bundle.bundle)}
|
||||
getNextMessage={ASRouterUtils.getNextMessage}
|
||||
sendUserActionTelemetry={this.sendUserActionTelemetry} />);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {message, bundle} = this.state;
|
||||
if (!message.id && !bundle.template) { return null; }
|
||||
if (bundle.template === "onboarding") { return this.renderOnboarding(); }
|
||||
return this.renderSnippets();
|
||||
}
|
||||
}
|
||||
|
||||
ASRouterUISurface.defaultProps = {document: global.document};
|
||||
|
||||
export class ASRouterContent {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.containerElement = null;
|
||||
}
|
||||
|
||||
_mount() {
|
||||
this.containerElement = global.document.getElementById("snippets-container");
|
||||
ReactDOM.render(<ASRouterUISurface />, this.containerElement);
|
||||
}
|
||||
|
||||
_unmount() {
|
||||
ReactDOM.unmountComponentAtNode(this.containerElement);
|
||||
}
|
||||
|
||||
init() {
|
||||
this._mount();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
uninit() {
|
||||
if (this.initialized) {
|
||||
this._unmount();
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import React from "react";
|
||||
import {safeURI} from "../../template-utils";
|
||||
|
||||
const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
|
||||
|
||||
export const Button = props => {
|
||||
const style = {};
|
||||
|
||||
// Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
|
||||
for (const tag of ALLOWED_STYLE_TAGS) {
|
||||
if (typeof props[tag] !== "undefined") {
|
||||
style[tag] = props[tag];
|
||||
}
|
||||
}
|
||||
// remove border if bg is set to something custom
|
||||
if (style.backgroundColor) {
|
||||
style.border = "0";
|
||||
}
|
||||
|
||||
return (<a href={safeURI(props.url)}
|
||||
onClick={props.onClick}
|
||||
className={props.className || "ASRouterButton"}
|
||||
style={style}>
|
||||
{props.children}
|
||||
</a>);
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
.ASRouterButton {
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--newtab-border-secondary-color);
|
||||
background-color: var(--newtab-button-secondary-color);
|
||||
font-family: inherit;
|
||||
padding: 8px 15px;
|
||||
margin-inline-start: 12px;
|
||||
color: inherit;
|
||||
.tall & {
|
||||
margin-inline-start: 20px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import React from "react";
|
||||
|
||||
export const VISIBLE = "visible";
|
||||
export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||||
|
||||
/**
|
||||
* Component wrapper used to send telemetry pings on every impression.
|
||||
*/
|
||||
export class ImpressionsWrapper extends React.PureComponent {
|
||||
// This sends an event when a user sees a set of new content. If content
|
||||
// changes while the page is hidden (i.e. preloaded or on a hidden tab),
|
||||
// only send the event if the page becomes visible again.
|
||||
sendImpressionOrAddListener() {
|
||||
if (this.props.document.visibilityState === VISIBLE) {
|
||||
this.props.sendImpression({id: this.props.id});
|
||||
} else {
|
||||
// We should only ever send the latest impression stats ping, so remove any
|
||||
// older listeners.
|
||||
if (this._onVisibilityChange) {
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
|
||||
// When the page becomes visible, send the impression stats ping if the section isn't collapsed.
|
||||
this._onVisibilityChange = () => {
|
||||
if (this.props.document.visibilityState === VISIBLE) {
|
||||
this.props.sendImpression({id: this.props.id});
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
};
|
||||
this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._onVisibilityChange) {
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.sendOnMount) {
|
||||
this.sendImpressionOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
|
||||
this.sendImpressionOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ImpressionsWrapper.defaultProps = {
|
||||
document: global.document,
|
||||
sendOnMount: true
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
|
||||
export class ModalOverlay extends React.PureComponent {
|
||||
componentWillMount() {
|
||||
this.setState({active: true});
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.classList.remove("modal-open");
|
||||
this.setState({active: false});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {active} = this.state;
|
||||
const {title, button_label} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className={`modalOverlayOuter ${active ? "active" : ""}`} />
|
||||
<div className={`modalOverlayInner ${active ? "active" : ""}`}>
|
||||
<h2> {title} </h2>
|
||||
{this.props.children}
|
||||
<div className="footer">
|
||||
<button onClick={this.props.onDoneButton} className="button primary modalButton"> {button_label} </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
.activity-stream {
|
||||
&.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.modalOverlayOuter {
|
||||
background: $white;
|
||||
opacity: 0.93;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
display: none;
|
||||
z-index: 100000;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.modalOverlayInner {
|
||||
width: 960px;
|
||||
height: 510px;
|
||||
position: fixed;
|
||||
top: calc(50% - 255px); // halfway down minus half the height of the modal
|
||||
left: calc(50% - 480px); // halfway across minus half the width of the modal
|
||||
background: $white;
|
||||
box-shadow: 0 1px 15px 0 $black-30;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
z-index: 100001;
|
||||
|
||||
|
||||
// modal takes over entire screen
|
||||
@media(max-width: 960px) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
// if modal is short enough, add a vertical scroll bar
|
||||
@media(max-width: 850px) and (max-height: 730px) {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: $grey-60;
|
||||
text-align: center;
|
||||
font-weight: 200;
|
||||
margin-top: 30px;
|
||||
font-size: 28px;
|
||||
line-height: 37px;
|
||||
letter-spacing: -0.13px;
|
||||
|
||||
@media(max-width: 960px) {
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
@media(max-width: 850px) {
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid $grey-30;
|
||||
height: 70px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
background-color: $white;
|
||||
|
||||
// if modal is short enough, footer becomes sticky
|
||||
@media(max-width: 850px) and (max-height: 730px) {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.modalButton {
|
||||
margin-top: 20px;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
padding: 4px 0 6px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import React from "react";
|
||||
|
||||
export class SnippetBase extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onBlockClicked = this.onBlockClicked.bind(this);
|
||||
}
|
||||
|
||||
onBlockClicked() {
|
||||
this.props.sendUserActionTelemetry({event: "BLOCK", id: this.props.UISurface});
|
||||
this.props.onBlock();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
const containerClassName = `SnippetBaseContainer${props.className ? ` ${props.className}` : ""}`;
|
||||
|
||||
return (<div className={containerClassName}>
|
||||
<div className="innerWrapper">
|
||||
{props.children}
|
||||
</div>
|
||||
<button className="blockButton" onClick={this.onBlockClicked} />
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
.SnippetBaseContainer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--newtab-snippets-background-color);
|
||||
color: var(--newtab-text-primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
border-top: 1px solid var(--newtab-snippets-hairline-color);
|
||||
box-shadow: $shadow-secondary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.innerWrapper {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px $section-horizontal-padding;
|
||||
|
||||
// This is to account for the block button on smaller screens
|
||||
padding-inline-end: 36px;
|
||||
@media (min-width: $break-point-large) {
|
||||
padding-inline-end: $section-horizontal-padding;
|
||||
}
|
||||
|
||||
max-width: $wrapper-max-width-large;
|
||||
@media (min-width: $break-point-widest) {
|
||||
max-width: $wrapper-max-width-widest;
|
||||
}
|
||||
}
|
||||
|
||||
.blockButton {
|
||||
display: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
offset-inline-end: 12px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-image: url('resource://activity-stream/data/content/assets/glyph-dismiss-16.svg');
|
||||
-moz-context-properties: fill;
|
||||
fill: var(--newtab-icon-primary-color);
|
||||
opacity: 0.5;
|
||||
margin-top: -8px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
@media (min-width: 766px) {
|
||||
offset-inline-end: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .blockButton {
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
## Activity Stream Router message format
|
||||
|
||||
Field name | Type | Required | Description | Example / Note
|
||||
--- | --- | --- | --- | ---
|
||||
`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
|
||||
`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
|
||||
`publish_start` | `date` | No | When to start showing the message | `1524474850876`
|
||||
`publish_end` | `date` | No | When to stop showing the message | `1524474850876`
|
||||
`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
|
||||
`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
|
||||
`targeting` | `string` `JEXL` | Yes | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [some examples](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#examples)
|
||||
|
||||
### Message example
|
||||
```javascript
|
||||
{
|
||||
id: "ONBOARDING_1",
|
||||
template: "simple_snippet",
|
||||
content: {
|
||||
title: "Find it faster",
|
||||
body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML subset
|
||||
The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
|
||||
|
||||
Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
|
||||
```
|
||||
{
|
||||
"id": "7899",
|
||||
"content": {
|
||||
"text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
|
||||
"links": {
|
||||
"cta": {
|
||||
"url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If a tag that is not on the allowed is used, the text content will be extracted and displayed.
|
||||
|
||||
Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"title": "ProviderResponse",
|
||||
"description": "A response object for remote providers of AS Router",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"description": "An array of router messages",
|
||||
"items": {
|
||||
"title": "RouterMessage",
|
||||
"description": "A definition of an individual message",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "A unique identifier for the message that should not conflict with any other previous message"
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "An id matching an existing Activity Stream Router template",
|
||||
"enum": ["simple_snippet"]
|
||||
},
|
||||
"content": {
|
||||
"type": "object",
|
||||
"description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
|
||||
},
|
||||
"targeting": {
|
||||
"type": "string",
|
||||
"description": "a JEXL expression representing targeting information"
|
||||
}
|
||||
},
|
||||
"required": ["id", "template", "content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["messages"]
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
export function safeURI(url) {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
const {protocol} = new URL(url);
|
||||
const isAllowed = [
|
||||
"http:",
|
||||
"https:",
|
||||
"data:",
|
||||
"resource:",
|
||||
"chrome:"
|
||||
].includes(protocol);
|
||||
if (!isAllowed) {
|
||||
console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
|
||||
}
|
||||
return isAllowed ? url : "";
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import {ModalOverlay} from "../../components/ModalOverlay/ModalOverlay";
|
||||
import React from "react";
|
||||
|
||||
class OnboardingCard extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const {props} = this;
|
||||
props.sendUserActionTelemetry({event: "CLICK_BUTTON", message_id: props.id, id: props.UISurface});
|
||||
props.onAction(props.content);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {content} = this.props;
|
||||
return (
|
||||
<div className="onboardingMessage">
|
||||
<div className={`onboardingMessageImage ${content.icon}`} />
|
||||
<div className="onboardingContent">
|
||||
<span>
|
||||
<h3> {content.title} </h3>
|
||||
<p> {content.text} </p>
|
||||
</span>
|
||||
<span>
|
||||
<button className="button onboardingButton" onClick={this.onClick}> {content.button_label} </button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class OnboardingMessage extends React.PureComponent {
|
||||
render() {
|
||||
const {props} = this;
|
||||
return (
|
||||
<ModalOverlay {...props} button_label={"Start Browsing"} title={"Welcome to Firefox"}>
|
||||
<div className="onboardingMessageContainer">
|
||||
{props.bundle.map(message => (
|
||||
<OnboardingCard key={message.id}
|
||||
sendUserActionTelemetry={props.sendUserActionTelemetry}
|
||||
onAction={props.onAction}
|
||||
UISurface={props.UISurface}
|
||||
{...message} />
|
||||
))}
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
.onboardingMessageContainer {
|
||||
display: grid;
|
||||
grid-column-gap: 21px;
|
||||
grid-template-columns: auto auto auto;
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
|
||||
// at 850px, the cards go from vertical layout to horizontal layout
|
||||
@media(max-width: 850px) {
|
||||
grid-template-columns: none;
|
||||
grid-template-rows: auto auto auto;
|
||||
padding-left: 110px;
|
||||
padding-right: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
.onboardingMessage {
|
||||
height: 340px;
|
||||
text-align: center;
|
||||
padding: 13px;
|
||||
font-weight: 200;
|
||||
|
||||
// at 850px, img floats left, content floats right next to it
|
||||
@media(max-width: 850px) {
|
||||
height: 170px;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #D8D8D8;
|
||||
display: flex;
|
||||
margin-bottom: 11px;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.onboardingContent {
|
||||
padding-left: 10px;
|
||||
height: 100%;
|
||||
|
||||
> span > h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
> span > p {
|
||||
margin-top: 0;
|
||||
line-height: 22px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboardingMessageImage {
|
||||
height: 100px;
|
||||
width: 120px;
|
||||
background-size: 120px;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
|
||||
@media(max-width: 850px) {
|
||||
height: 75px;
|
||||
min-width: 80px;
|
||||
background-size: 80px;
|
||||
}
|
||||
|
||||
&.addons {
|
||||
background-image: url("resource://activity-stream/data/content/assets/illustration-addons@2x.png");
|
||||
}
|
||||
|
||||
&.privatebrowsing {
|
||||
background-image: url("resource://activity-stream/data/content/assets/illustration-privatebrowsing@2x.png");
|
||||
}
|
||||
|
||||
&.screenshots {
|
||||
background-image: url("resource://activity-stream/data/content/assets/illustration-screenshots@2x.png");
|
||||
}
|
||||
|
||||
&.gift {
|
||||
background-image: url("resource://activity-stream/data/content/assets/illustration-gift@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
.onboardingContent {
|
||||
height: 175px;
|
||||
|
||||
> span > h3 {
|
||||
color: $grey-90;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
> span > p {
|
||||
color: $grey-60;
|
||||
margin-top: 0;
|
||||
height: 130px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.onboardingButton {
|
||||
background-color: $grey-90-10;
|
||||
border: none;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
margin-bottom: 23px;
|
||||
padding: 4px 0 6px 0;
|
||||
font-size: 15px;
|
||||
|
||||
// at 850px, the button shimmies down and to the right
|
||||
@media(max-width: 850px) {
|
||||
float: right;
|
||||
margin-top: -60px;
|
||||
margin-right: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 220px;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
background-color: #D8D8D8;
|
||||
margin-top: 40px;
|
||||
margin-left: 215px;
|
||||
|
||||
// at 850px, the line goes from vertical to horizontal
|
||||
@media(max-width: 850px) {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import {Button} from "../../components/Button/Button";
|
||||
import React from "react";
|
||||
import {safeURI} from "../../template-utils";
|
||||
import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
|
||||
|
||||
const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
|
||||
|
||||
export class SimpleSnippet extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onButtonClick = this.onButtonClick.bind(this);
|
||||
}
|
||||
|
||||
onButtonClick() {
|
||||
this.props.sendUserActionTelemetry({event: "CLICK_BUTTON", id: this.props.UISurface});
|
||||
}
|
||||
|
||||
renderTitle() {
|
||||
const {title} = this.props.content;
|
||||
return title ? <h3 className="title">{title}</h3> : null;
|
||||
}
|
||||
|
||||
renderTitleIcon() {
|
||||
const titleIcon = safeURI(this.props.content.title_icon);
|
||||
return titleIcon ? <span className="titleIcon" style={{backgroundImage: `url("${titleIcon}")`}} /> : null;
|
||||
}
|
||||
|
||||
renderButton(className) {
|
||||
const {props} = this;
|
||||
return (<Button
|
||||
className={className}
|
||||
onClick={this.onButtonClick}
|
||||
url={props.content.button_url}
|
||||
color={props.content.button_color}
|
||||
backgroundColor={props.content.button_background_color}>
|
||||
{props.content.button_label}
|
||||
</Button>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const hasLink = props.content.button_url && props.content.button_type === "anchor";
|
||||
const hasButton = props.content.button_url && !props.content.button_type;
|
||||
const className = `SimpleSnippet${props.content.tall ? " tall" : ""}`;
|
||||
return (<SnippetBase {...props} className={className}>
|
||||
<img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon" />
|
||||
<div>
|
||||
{this.renderTitleIcon()} {this.renderTitle()} <p className="body">{props.content.text}</p> {hasLink ? this.renderButton("ASRouterAnchor") : null}
|
||||
</div>
|
||||
{hasButton ? <div>{this.renderButton()}</div> : null}
|
||||
</SnippetBase>);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"title": "SimpleSnippet",
|
||||
"description": "A simple template with an icon, text, and optional button.",
|
||||
"version": "0.2.0",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Snippet title displayed before snippet text"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Main body text of snippet"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Snippet icon. 64x64px. SVG or PNG preferred."
|
||||
},
|
||||
"title_icon": {
|
||||
"type": "string",
|
||||
"description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
|
||||
},
|
||||
"button_url": {
|
||||
"type": "string",
|
||||
"description": "A url, button_label links to this"
|
||||
},
|
||||
"button_label": {
|
||||
"type": "string",
|
||||
"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."
|
||||
},
|
||||
"button_color": {
|
||||
"type": "string",
|
||||
"description": "The text color of the button. Valid CSS color."
|
||||
},
|
||||
"button_background_color": {
|
||||
"type": "string",
|
||||
"description": "The background color of the button. Valid CSS color."
|
||||
},
|
||||
"button_type": {
|
||||
"type": "string",
|
||||
"enum": ["anchor", "button"],
|
||||
"description": "(**temporary**, until we get html support in text field Bug 1457233) Style for button, either a regular button or a text link."
|
||||
},
|
||||
"tall": {
|
||||
"type": "boolean",
|
||||
"description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false."
|
||||
},
|
||||
"links": {
|
||||
"additionalProperties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The url where the link points to."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["text"],
|
||||
"dependencies": {
|
||||
"button_url": ["button_label"],
|
||||
"button_label": ["button_url"],
|
||||
"button_type": ["button_url"],
|
||||
"button_color": ["button_url"],
|
||||
"button_background_color": ["button_url"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
.SimpleSnippet {
|
||||
&.tall {
|
||||
padding: 27px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.titleIcon {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 14px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-top: 2px;
|
||||
margin-inline-end: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
margin-inline-end: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&.tall .icon {
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
|
||||
.ASRouterAnchor {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import {ASRouterUtils} from "../../asrouter/asrouter-content";
|
||||
import React from "react";
|
||||
|
||||
export class ASRouterAdmin extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.findOtherBundledMessagesOfSameTemplate = this.findOtherBundledMessagesOfSameTemplate.bind(this);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
onMessage({data: action}) {
|
||||
if (action.type === "ADMIN_SET_STATE") {
|
||||
this.setState(action.data);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
ASRouterUtils.sendMessage({type: "ADMIN_CONNECT_STATE"});
|
||||
ASRouterUtils.addListener(this.onMessage);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
ASRouterUtils.removeListener(this.onMessage);
|
||||
}
|
||||
|
||||
findOtherBundledMessagesOfSameTemplate(template) {
|
||||
return this.state.messages.filter(msg => msg.template === template && msg.bundled);
|
||||
}
|
||||
|
||||
handleBlock(msg) {
|
||||
if (msg.bundled) {
|
||||
// If we are blocking a message that belongs to a bundle, block all other messages that are bundled of that same template
|
||||
let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
|
||||
return () => ASRouterUtils.blockBundle(bundle);
|
||||
}
|
||||
return () => ASRouterUtils.blockById(msg.id);
|
||||
}
|
||||
|
||||
handleUnblock(msg) {
|
||||
if (msg.bundled) {
|
||||
// If we are unblocking a message that belongs to a bundle, unblock all other messages that are bundled of that same template
|
||||
let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
|
||||
return () => ASRouterUtils.unblockBundle(bundle);
|
||||
}
|
||||
return () => ASRouterUtils.unblockById(msg.id);
|
||||
}
|
||||
|
||||
handleOverride(id) {
|
||||
return () => ASRouterUtils.overrideMessage(id);
|
||||
}
|
||||
|
||||
renderMessageItem(msg) {
|
||||
const isCurrent = msg.id === this.state.lastMessageId;
|
||||
const isBlocked = this.state.blockList.includes(msg.id);
|
||||
|
||||
let itemClassName = "message-item";
|
||||
if (isCurrent) { itemClassName += " current"; }
|
||||
if (isBlocked) { itemClassName += " blocked"; }
|
||||
|
||||
return (<tr className={itemClassName} key={msg.id}>
|
||||
<td className="message-id"><span>{msg.id}</span></td>
|
||||
<td>
|
||||
<button className={`button ${(isBlocked ? "" : " primary")}`} onClick={isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)}>{isBlocked ? "Unblock" : "Block"}</button>
|
||||
{isBlocked ? null : <button className="button" onClick={this.handleOverride(msg.id)}>Show</button>}
|
||||
</td>
|
||||
<td className="message-summary">
|
||||
<pre>{JSON.stringify(msg, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>);
|
||||
}
|
||||
|
||||
renderMessages() {
|
||||
if (!this.state.messages) {
|
||||
return null;
|
||||
}
|
||||
return (<table><tbody>
|
||||
{this.state.messages.map(msg => this.renderMessageItem(msg))}
|
||||
</tbody></table>);
|
||||
}
|
||||
|
||||
renderProviders() {
|
||||
return (<table><tbody>
|
||||
{this.state.providers.map((provider, i) => (<tr className="message-item" key={i}>
|
||||
<td>{provider.id}</td>
|
||||
<td>{provider.type === "remote" ? <a target="_blank" href={provider.url}>{provider.url}</a> : "(local)"}</td>
|
||||
</tr>))}
|
||||
</tbody></table>);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div className="asrouter-admin outer-wrapper">
|
||||
<h1>AS Router Admin</h1>
|
||||
<button className="button primary" onClick={ASRouterUtils.getNextMessage}>Refresh Current Message</button>
|
||||
<h2>Message Providers</h2>
|
||||
{this.state.providers ? this.renderProviders() : null}
|
||||
<h2>Messages</h2>
|
||||
{this.renderMessages()}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
|
||||
.asrouter-admin {
|
||||
$border-color: var(--newtab-border-secondary-color);
|
||||
$monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
|
||||
max-width: 996px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
// Reset .outer-wrapper styles
|
||||
display: inherit;
|
||||
padding: 0 0 92px;
|
||||
|
||||
h1 {
|
||||
font-weight: 200;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
&:first-child td {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid $border-color;
|
||||
padding: 8px;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.current {
|
||||
.message-id span {
|
||||
background: $yellow-50;
|
||||
padding: 2px 5px;
|
||||
|
||||
.dark-theme & {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.blocked {
|
||||
.message-id,
|
||||
.message-summary {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.message-id {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-family: $monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--newtab-textbox-background-color);
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
max-width: 750px;
|
||||
overflow: auto;
|
||||
font-family: $monospace;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {addLocaleData, injectIntl, IntlProvider} from "react-intl";
|
||||
import {ASRouterAdmin} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
|
||||
import {ConfirmDialog} from "content-src/components/ConfirmDialog/ConfirmDialog";
|
||||
import {connect} from "react-redux";
|
||||
import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
|
||||
import {ManualMigration} from "content-src/components/ManualMigration/ManualMigration";
|
||||
import {PrerenderData} from "common/PrerenderData.jsm";
|
||||
import React from "react";
|
||||
import {Search} from "content-src/components/Search/Search";
|
||||
import {Sections} from "content-src/components/Sections/Sections";
|
||||
import {StartupOverlay} from "content-src/components/StartupOverlay/StartupOverlay";
|
||||
|
||||
const PrefsButton = injectIntl(props => (
|
||||
<div className="prefs-button">
|
||||
<button className="icon icon-settings" onClick={props.onClick} title={props.intl.formatMessage({id: "settings_pane_button_label"})} />
|
||||
</div>
|
||||
));
|
||||
|
||||
// Add the locale data for pluralization and relative-time formatting for now,
|
||||
// this just uses english locale data. We can make this more sophisticated if
|
||||
// more features are needed.
|
||||
function addLocaleDataForReactIntl(locale) {
|
||||
addLocaleData([{locale, parentLocale: "en"}]);
|
||||
}
|
||||
|
||||
export class _Base extends React.PureComponent {
|
||||
componentWillMount() {
|
||||
const {App, locale, Theme} = this.props;
|
||||
if (Theme.className) {
|
||||
this.updateTheme(Theme);
|
||||
}
|
||||
this.sendNewTabRehydrated(App);
|
||||
addLocaleDataForReactIntl(locale);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Request state AFTER the first render to ensure we don't cause the
|
||||
// prerendered DOM to be unmounted. Otherwise, NEW_TAB_STATE_REQUEST is
|
||||
// dispatched right after the store is ready.
|
||||
if (this.props.isPrerendered) {
|
||||
this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
|
||||
this.props.dispatch(ac.AlsoToMain({type: at.PAGE_PRERENDERED}));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.updateTheme({className: ""});
|
||||
}
|
||||
|
||||
componentWillUpdate({App, Theme}) {
|
||||
this.updateTheme(Theme);
|
||||
this.sendNewTabRehydrated(App);
|
||||
}
|
||||
|
||||
updateTheme(Theme) {
|
||||
const bodyClassName = [
|
||||
"activity-stream",
|
||||
Theme.className,
|
||||
this.props.isFirstrun ? "welcome" : ""
|
||||
].filter(v => v).join(" ");
|
||||
global.document.body.className = bodyClassName;
|
||||
}
|
||||
|
||||
// The NEW_TAB_REHYDRATED event is used to inform feeds that their
|
||||
// data has been consumed e.g. for counting the number of tabs that
|
||||
// have rendered that data.
|
||||
sendNewTabRehydrated(App) {
|
||||
if (App && App.initialized && !this.renderNotified) {
|
||||
this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_REHYDRATED, data: {}}));
|
||||
this.renderNotified = true;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {App, locale, strings} = props;
|
||||
const {initialized} = App;
|
||||
|
||||
if (props.Prefs.values.asrouterExperimentEnabled && window.location.hash === "#asrouter") {
|
||||
return (<ASRouterAdmin />);
|
||||
}
|
||||
|
||||
if (!props.isPrerendered && !initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<IntlProvider locale={locale} messages={strings}>
|
||||
<ErrorBoundary className="base-content-fallback">
|
||||
<BaseContent {...this.props} />
|
||||
</ErrorBoundary>
|
||||
</IntlProvider>);
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseContent extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.openPreferences = this.openPreferences.bind(this);
|
||||
}
|
||||
|
||||
openPreferences() {
|
||||
this.props.dispatch(ac.OnlyToMain({type: at.SETTINGS_OPEN}));
|
||||
this.props.dispatch(ac.UserEvent({event: "OPEN_NEWTAB_PREFS"}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {App} = props;
|
||||
const {initialized} = App;
|
||||
const prefs = props.Prefs.values;
|
||||
|
||||
const shouldBeFixedToTop = PrerenderData.arePrefsValid(name => prefs[name]);
|
||||
|
||||
const outerClassName = [
|
||||
"outer-wrapper",
|
||||
shouldBeFixedToTop && "fixed-to-top"
|
||||
].filter(v => v).join(" ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={outerClassName}>
|
||||
<main>
|
||||
{prefs.showSearch &&
|
||||
<div className="non-collapsible-section">
|
||||
<ErrorBoundary>
|
||||
<Search />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
}
|
||||
<div className={`body-wrapper${(initialized ? " on" : "")}`}>
|
||||
{!prefs.migrationExpired &&
|
||||
<div className="non-collapsible-section">
|
||||
<ManualMigration />
|
||||
</div>
|
||||
}
|
||||
<Sections />
|
||||
<PrefsButton onClick={this.openPreferences} />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</main>
|
||||
</div>
|
||||
{this.props.isFirstrun && <StartupOverlay />}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Theme: state.Theme}))(_Base);
|
|
@ -0,0 +1,96 @@
|
|||
.outer-wrapper {
|
||||
color: var(--newtab-text-primary-color);
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-height: 100vh;
|
||||
padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter;
|
||||
|
||||
&.fixed-to-top {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--newtab-link-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
margin: auto;
|
||||
// Offset the snippets container so things at the bottom of the page are still
|
||||
// visible when snippets / onboarding are visible. Adjust for other spacing.
|
||||
padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
|
||||
width: $wrapper-default-width;
|
||||
|
||||
@media (min-width: $break-point-small) {
|
||||
width: $wrapper-max-width-small;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-medium) {
|
||||
width: $wrapper-max-width-medium;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-large) {
|
||||
width: $wrapper-max-width-large;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-widest) {
|
||||
width: $wrapper-max-width-widest;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: $section-spacing;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-fallback {
|
||||
// Make the error message be centered against the viewport
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.body-wrapper {
|
||||
// Hide certain elements so the page structure is fixed, e.g., placeholders,
|
||||
// while avoiding flashes of changing content, e.g., icons and text
|
||||
$selectors-to-hide: '
|
||||
.section-title,
|
||||
.sections-list .section:last-of-type,
|
||||
.topic
|
||||
';
|
||||
|
||||
#{$selectors-to-hide} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.on {
|
||||
#{$selectors-to-hide} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.non-collapsible-section {
|
||||
padding: 0 $section-horizontal-padding;
|
||||
}
|
||||
|
||||
.prefs-button {
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
fill: var(--newtab-icon-primary-color);
|
||||
offset-inline-end: 15px;
|
||||
padding: 15px;
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
z-index: 12001;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--newtab-element-hover-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--newtab-element-active-color);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,312 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {cardContextTypes} from "./types";
|
||||
import {connect} from "react-redux";
|
||||
import {FormattedMessage} from "react-intl";
|
||||
import {GetPlatformString} from "content-src/lib/link-menu-options";
|
||||
import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
|
||||
import React from "react";
|
||||
|
||||
// Keep track of pending image loads to only request once
|
||||
const gImageLoading = new Map();
|
||||
|
||||
/**
|
||||
* Card component.
|
||||
* Cards are found within a Section component and contain information about a link such
|
||||
* as preview image, page title, page description, and some context about if the page
|
||||
* was visited, bookmarked, trending etc...
|
||||
* Each Section can make an unordered list of Cards which will create one instane of
|
||||
* this class. Each card will then get a context menu which reflects the actions that
|
||||
* can be done on this Card.
|
||||
*/
|
||||
export class _Card extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activeCard: null,
|
||||
imageLoaded: false,
|
||||
showContextMenu: false,
|
||||
cardImage: null
|
||||
};
|
||||
this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
|
||||
this.onMenuUpdate = this.onMenuUpdate.bind(this);
|
||||
this.onLinkClick = this.onLinkClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to conditionally load an image and update state when it loads.
|
||||
*/
|
||||
async maybeLoadImage() {
|
||||
// No need to load if it's already loaded or no image
|
||||
const {cardImage} = this.state;
|
||||
if (!cardImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = cardImage.url;
|
||||
if (!this.state.imageLoaded) {
|
||||
// Initialize a promise to share a load across multiple card updates
|
||||
if (!gImageLoading.has(imageUrl)) {
|
||||
const loaderPromise = new Promise((resolve, reject) => {
|
||||
const loader = new Image();
|
||||
loader.addEventListener("load", resolve);
|
||||
loader.addEventListener("error", reject);
|
||||
loader.src = imageUrl;
|
||||
});
|
||||
|
||||
// Save and remove the promise only while it's pending
|
||||
gImageLoading.set(imageUrl, loaderPromise);
|
||||
loaderPromise.catch(ex => ex).then(() => gImageLoading.delete(imageUrl)).catch();
|
||||
}
|
||||
|
||||
// Wait for the image whether just started loading or reused promise
|
||||
await gImageLoading.get(imageUrl);
|
||||
|
||||
// Only update state if we're still waiting to load the original image
|
||||
if (_Card.isImageInState(this.state, this.props.link.image) && !this.state.imageLoaded) {
|
||||
this.setState({imageLoaded: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if `.image` property on link object is a local image with blob data.
|
||||
* This function only works for props since state has `.url` and not `.data`.
|
||||
*
|
||||
* @param {obj|string} image
|
||||
* @returns {bool} true if image is a local image object, otherwise false
|
||||
* (otherwise, image will be a URL as a string)
|
||||
*/
|
||||
static isLocalImageObject(image) {
|
||||
return image && image.data && image.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to obtain the next state based on nextProps and prevState.
|
||||
*
|
||||
* NOTE: Rename this method to getDerivedStateFromProps when we update React
|
||||
* to >= 16.3. We will need to update tests as well. We cannot rename this
|
||||
* method to getDerivedStateFromProps now because there is a mismatch in
|
||||
* the React version that we are using for both testing and production.
|
||||
* (i.e. react-test-render => "16.3.2", react => "16.2.0").
|
||||
*
|
||||
* See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
|
||||
*/
|
||||
static getNextStateFromProps(nextProps, prevState) {
|
||||
const {image} = nextProps.link;
|
||||
const imageInState = _Card.isImageInState(prevState, image);
|
||||
let nextState = null;
|
||||
|
||||
// Image is updating.
|
||||
if (!imageInState && nextProps.link) {
|
||||
nextState = {imageLoaded: false};
|
||||
}
|
||||
|
||||
if (imageInState) {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
nextState = nextState || {};
|
||||
|
||||
// Since image was updated, attempt to revoke old image blob URL, if it exists.
|
||||
_Card.maybeRevokeImageBlob(prevState);
|
||||
|
||||
if (!image) {
|
||||
nextState.cardImage = null;
|
||||
} else if (_Card.isLocalImageObject(image)) {
|
||||
nextState.cardImage = {url: global.URL.createObjectURL(image.data), path: image.path};
|
||||
} else {
|
||||
nextState.cardImage = {url: image};
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to conditionally revoke the previous card image if it is a blob.
|
||||
*/
|
||||
static maybeRevokeImageBlob(prevState) {
|
||||
if (prevState.cardImage && prevState.cardImage.path) {
|
||||
global.URL.revokeObjectURL(prevState.cardImage.url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if an image is already in state.
|
||||
*/
|
||||
static isImageInState(state, image) {
|
||||
const {cardImage} = state;
|
||||
|
||||
// Both image and cardImage are present.
|
||||
if (image && cardImage) {
|
||||
return _Card.isLocalImageObject(image) ?
|
||||
cardImage.path === image.path :
|
||||
cardImage.url === image;
|
||||
}
|
||||
|
||||
// This will only handle the remaining three possible outcomes.
|
||||
// (i.e. everything except when both image and cardImage are present)
|
||||
return !image && !cardImage;
|
||||
}
|
||||
|
||||
onMenuButtonClick(event) {
|
||||
event.preventDefault();
|
||||
this.setState({
|
||||
activeCard: this.props.index,
|
||||
showContextMenu: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report to telemetry additional information about the item.
|
||||
*/
|
||||
_getTelemetryInfo() {
|
||||
// Filter out "history" type for being the default
|
||||
if (this.props.link.type !== "history") {
|
||||
return {value: {card_type: this.props.link.type}};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
onLinkClick(event) {
|
||||
event.preventDefault();
|
||||
if (this.props.link.type === "download") {
|
||||
this.props.dispatch(ac.OnlyToMain({
|
||||
type: at.SHOW_DOWNLOAD_FILE,
|
||||
data: this.props.link
|
||||
}));
|
||||
} else {
|
||||
const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
|
||||
this.props.dispatch(ac.OnlyToMain({
|
||||
type: at.OPEN_LINK,
|
||||
data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}})
|
||||
}));
|
||||
}
|
||||
if (this.props.isWebExtension) {
|
||||
this.props.dispatch(ac.WebExtEvent(at.WEBEXT_CLICK, {
|
||||
source: this.props.eventSource,
|
||||
url: this.props.link.url,
|
||||
action_position: this.props.index
|
||||
}));
|
||||
} else {
|
||||
this.props.dispatch(ac.UserEvent(Object.assign({
|
||||
event: "CLICK",
|
||||
source: this.props.eventSource,
|
||||
action_position: this.props.index
|
||||
}, this._getTelemetryInfo())));
|
||||
|
||||
if (this.props.shouldSendImpressionStats) {
|
||||
this.props.dispatch(ac.ImpressionStats({
|
||||
source: this.props.eventSource,
|
||||
click: 0,
|
||||
tiles: [{id: this.props.link.guid, pos: this.props.index}]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMenuUpdate(showContextMenu) {
|
||||
this.setState({showContextMenu});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.maybeLoadImage();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.maybeLoadImage();
|
||||
}
|
||||
|
||||
// NOTE: Remove this function when we update React to >= 16.3 since React will
|
||||
// call getDerivedStateFromProps automatically. We will also need to
|
||||
// rename getNextStateFromProps to getDerivedStateFromProps.
|
||||
componentWillMount() {
|
||||
const nextState = _Card.getNextStateFromProps(this.props, this.state);
|
||||
if (nextState) {
|
||||
this.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Remove this function when we update React to >= 16.3 since React will
|
||||
// call getDerivedStateFromProps automatically. We will also need to
|
||||
// rename getNextStateFromProps to getDerivedStateFromProps.
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const nextState = _Card.getNextStateFromProps(nextProps, this.state);
|
||||
if (nextState) {
|
||||
this.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
_Card.maybeRevokeImageBlob(this.state);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {index, className, link, dispatch, contextMenuOptions, eventSource, shouldSendImpressionStats} = this.props;
|
||||
const {props} = this;
|
||||
const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
|
||||
// Display "now" as "trending" until we have new strings #3402
|
||||
const {icon, intlID} = cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
|
||||
const hasImage = this.state.cardImage || link.hasImage;
|
||||
const imageStyle = {backgroundImage: this.state.cardImage ? `url(${this.state.cardImage.url})` : "none"};
|
||||
const outerClassName = [
|
||||
"card-outer",
|
||||
className,
|
||||
isContextMenuOpen && "active",
|
||||
props.placeholder && "placeholder"
|
||||
].filter(v => v).join(" ");
|
||||
|
||||
return (<li className={outerClassName}>
|
||||
<a href={link.type === "pocket" ? link.open_url : link.url} onClick={!props.placeholder ? this.onLinkClick : undefined}>
|
||||
<div className="card">
|
||||
<div className="card-preview-image-outer">
|
||||
{hasImage &&
|
||||
<div className={`card-preview-image${this.state.imageLoaded ? " loaded" : ""}`} style={imageStyle} />
|
||||
}
|
||||
</div>
|
||||
<div className="card-details">
|
||||
{link.type === "download" && <div className="card-host-name alternate"><FormattedMessage id={GetPlatformString(this.props.platform)} /></div>}
|
||||
{link.hostname &&
|
||||
<div className="card-host-name">
|
||||
{link.hostname.slice(0, 100)}{link.type === "download" && ` \u2014 ${link.description}`}
|
||||
</div>
|
||||
}
|
||||
<div className={[
|
||||
"card-text",
|
||||
icon ? "" : "no-context",
|
||||
link.description ? "" : "no-description",
|
||||
link.hostname ? "" : "no-host-name"
|
||||
].join(" ")}>
|
||||
<h4 className="card-title" dir="auto">{link.title}</h4>
|
||||
<p className="card-description" dir="auto">{link.description}</p>
|
||||
</div>
|
||||
<div className="card-context">
|
||||
{icon && !link.context && <span className={`card-context-icon icon icon-${icon}`} />}
|
||||
{link.icon && link.context && <span className="card-context-icon icon" style={{backgroundImage: `url('${link.icon}')`}} />}
|
||||
{intlID && !link.context && <div className="card-context-label"><FormattedMessage id={intlID} defaultMessage="Visited" /></div>}
|
||||
{link.context && <div className="card-context-label">{link.context}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{!props.placeholder && <button className="context-menu-button icon"
|
||||
onClick={this.onMenuButtonClick}>
|
||||
<span className="sr-only">{`Open context menu for ${link.title}`}</span>
|
||||
</button>}
|
||||
{isContextMenuOpen &&
|
||||
<LinkMenu
|
||||
dispatch={dispatch}
|
||||
index={index}
|
||||
source={eventSource}
|
||||
onUpdate={this.onMenuUpdate}
|
||||
options={link.contextMenuOptions || contextMenuOptions}
|
||||
site={link}
|
||||
siteInfo={this._getTelemetryInfo()}
|
||||
shouldSendImpressionStats={shouldSendImpressionStats} />
|
||||
}
|
||||
</li>);
|
||||
}
|
||||
}
|
||||
_Card.defaultProps = {link: {}};
|
||||
export const Card = connect(state => ({platform: state.Prefs.values.platform}))(_Card);
|
||||
export const PlaceholderCard = props => <Card placeholder={true} className={props.className} />;
|
|
@ -0,0 +1,315 @@
|
|||
.card-outer {
|
||||
@include context-menu-button;
|
||||
background: var(--newtab-card-background-color);
|
||||
border-radius: $border-radius;
|
||||
display: inline-block;
|
||||
height: $card-height;
|
||||
margin-inline-end: $base-gutter;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.placeholder {
|
||||
background: transparent;
|
||||
|
||||
.card {
|
||||
box-shadow: inset $inner-box-shadow;
|
||||
}
|
||||
|
||||
.card-preview-image-outer,
|
||||
.card-context {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: $border-radius;
|
||||
box-shadow: var(--newtab-card-shadow);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> a {
|
||||
color: inherit;
|
||||
display: block;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
&:-moz-any(.active, :focus) {
|
||||
.card {
|
||||
@include fade-in-card;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--newtab-link-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:-moz-any(:hover, :focus, .active):not(.placeholder) {
|
||||
@include fade-in-card;
|
||||
@include context-menu-button-hover;
|
||||
outline: none;
|
||||
|
||||
.card-title {
|
||||
color: var(--newtab-link-primary-color);
|
||||
}
|
||||
|
||||
.alternate ~ .card-host-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-host-name.alternate {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-preview-image-outer {
|
||||
background-color: $grey-30;
|
||||
border-radius: $border-radius $border-radius 0 0;
|
||||
height: $card-preview-image-height;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
border-bottom: 1px solid var(--newtab-card-hairline-color);
|
||||
bottom: 0;
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-preview-image {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 1s $photon-easing;
|
||||
width: 100%;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-details {
|
||||
padding: 15px 16px 12px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
max-height: 4 * $card-text-line-height + $card-title-margin;
|
||||
overflow: hidden;
|
||||
|
||||
&.no-host-name,
|
||||
&.no-context {
|
||||
max-height: 5 * $card-text-line-height + $card-title-margin;
|
||||
}
|
||||
|
||||
&.no-host-name.no-context {
|
||||
max-height: 6 * $card-text-line-height + $card-title-margin;
|
||||
}
|
||||
|
||||
&:not(.no-description) .card-title {
|
||||
max-height: 3 * $card-text-line-height;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.card-host-name {
|
||||
color: var(--newtab-text-secondary-color);
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4px;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-host-name.alternate { display: none; }
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: $card-text-line-height;
|
||||
margin: 0 0 $card-title-margin;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
line-height: $card-text-line-height;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.card-context {
|
||||
bottom: 0;
|
||||
color: var(--newtab-text-secondary-color);
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
offset-inline-start: 0;
|
||||
padding: 9px 16px 9px 14px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.card-context-icon {
|
||||
fill: var(--newtab-text-secondary-color);
|
||||
height: 22px;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
.card-context-label {
|
||||
flex-grow: 1;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-cards {
|
||||
.card-outer {
|
||||
// Wide layout styles
|
||||
@media (min-width: $break-point-widest) {
|
||||
$line-height: 23px;
|
||||
height: $card-height-large;
|
||||
|
||||
.card-preview-image-outer {
|
||||
height: $card-preview-image-height-large;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
padding: 13px 16px 12px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
max-height: 6 * $line-height + $card-title-margin;
|
||||
}
|
||||
|
||||
.card-host-name {
|
||||
font-size: 12px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
line-height: $line-height;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-text:not(.no-description) {
|
||||
.card-title {
|
||||
max-height: 3 * $line-height;
|
||||
}
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 15px;
|
||||
line-height: $line-height;
|
||||
}
|
||||
|
||||
.card-context {
|
||||
bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-cards {
|
||||
$card-detail-vertical-spacing: 12px;
|
||||
$card-title-font-size: 12px;
|
||||
|
||||
.card-outer {
|
||||
height: $card-height-compact;
|
||||
|
||||
.card-preview-image-outer {
|
||||
height: $card-preview-image-height-compact;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
padding: $card-detail-vertical-spacing 16px;
|
||||
}
|
||||
|
||||
.card-host-name {
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
.card-title,
|
||||
&:not(.no-description) .card-title {
|
||||
font-size: $card-title-font-size;
|
||||
line-height: $card-title-font-size + 1;
|
||||
max-height: $card-title-font-size + 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.card-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-context {
|
||||
$icon-size: 16px;
|
||||
$container-size: 32px;
|
||||
background-color: var(--newtab-card-background-color);
|
||||
border-radius: $container-size / 2;
|
||||
clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing));
|
||||
height: $container-size;
|
||||
width: $container-size;
|
||||
padding: ($container-size - $icon-size) / 2;
|
||||
top: $card-preview-image-height-compact - $icon-size;
|
||||
offset-inline-end: 12px;
|
||||
offset-inline-start: auto;
|
||||
|
||||
&::after {
|
||||
border: 1px solid var(--newtab-card-hairline-color);
|
||||
border-bottom: 0;
|
||||
border-radius: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0;
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: ($container-size + 2) / 2;
|
||||
width: $container-size + 2;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.card-context-icon {
|
||||
margin-inline-end: 0;
|
||||
height: $icon-size;
|
||||
width: $icon-size;
|
||||
|
||||
&.icon-bookmark-added {
|
||||
fill: $bookmark-icon-fill;
|
||||
}
|
||||
|
||||
&.icon-download {
|
||||
fill: $download-icon-fill;
|
||||
}
|
||||
|
||||
&.icon-history-item {
|
||||
fill: $history-icon-fill;
|
||||
}
|
||||
|
||||
&.icon-pocket {
|
||||
fill: $pocket-icon-fill;
|
||||
}
|
||||
}
|
||||
|
||||
.card-context-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media not all and (min-width: $break-point-widest) {
|
||||
.hide-for-narrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
export const cardContextTypes = {
|
||||
history: {
|
||||
intlID: "type_label_visited",
|
||||
icon: "history-item"
|
||||
},
|
||||
bookmark: {
|
||||
intlID: "type_label_bookmarked",
|
||||
icon: "bookmark-added"
|
||||
},
|
||||
trending: {
|
||||
intlID: "type_label_recommended",
|
||||
icon: "trending"
|
||||
},
|
||||
now: {
|
||||
intlID: "type_label_now",
|
||||
icon: "now"
|
||||
},
|
||||
pocket: {
|
||||
intlID: "type_label_pocket",
|
||||
icon: "pocket"
|
||||
},
|
||||
download: {
|
||||
intlID: "type_label_downloaded",
|
||||
icon: "download"
|
||||
}
|
||||
};
|
|
@ -0,0 +1,218 @@
|
|||
import {FormattedMessage, injectIntl} from "react-intl";
|
||||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
|
||||
import React from "react";
|
||||
import {SectionMenu} from "content-src/components/SectionMenu/SectionMenu";
|
||||
import {SectionMenuOptions} from "content-src/lib/section-menu-options";
|
||||
|
||||
const VISIBLE = "visible";
|
||||
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||||
|
||||
function getFormattedMessage(message) {
|
||||
return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
|
||||
}
|
||||
|
||||
export class Disclaimer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onAcknowledge = this.onAcknowledge.bind(this);
|
||||
}
|
||||
|
||||
onAcknowledge() {
|
||||
this.props.dispatch(ac.SetPref(this.props.disclaimerPref, false));
|
||||
this.props.dispatch(ac.UserEvent({event: "DISCLAIMER_ACKED", source: this.props.eventSource}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {disclaimer} = this.props;
|
||||
return (
|
||||
<div className="section-disclaimer">
|
||||
<div className="section-disclaimer-text">
|
||||
{getFormattedMessage(disclaimer.text)}
|
||||
{disclaimer.link &&
|
||||
<a href={disclaimer.link.href} target="_blank" rel="noopener noreferrer">
|
||||
{getFormattedMessage(disclaimer.link.title || disclaimer.link)}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button onClick={this.onAcknowledge}>
|
||||
{getFormattedMessage(disclaimer.button)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DisclaimerIntl = injectIntl(Disclaimer);
|
||||
|
||||
export class _CollapsibleSection extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onBodyMount = this.onBodyMount.bind(this);
|
||||
this.onHeaderClick = this.onHeaderClick.bind(this);
|
||||
this.onTransitionEnd = this.onTransitionEnd.bind(this);
|
||||
this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this);
|
||||
this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
|
||||
this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
|
||||
this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
|
||||
this.onMenuUpdate = this.onMenuUpdate.bind(this);
|
||||
this.state = {enableAnimation: true, isAnimating: false, menuButtonHover: false, showContextMenu: false};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps) {
|
||||
// Check if we're about to go from expanded to collapsed
|
||||
if (!this.props.collapsed && nextProps.collapsed) {
|
||||
// This next line forces a layout flush of the section body, which has a
|
||||
// max-height style set, so that the upcoming collapse animation can
|
||||
// animate from that height to the collapsed height. Without this, the
|
||||
// update is coalesced and there's no animation from no-max-height to 0.
|
||||
this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
|
||||
}
|
||||
|
||||
enableOrDisableAnimation() {
|
||||
// Only animate the collapse/expand for visible tabs.
|
||||
const visible = this.props.document.visibilityState === VISIBLE;
|
||||
if (this.state.enableAnimation !== visible) {
|
||||
this.setState({enableAnimation: visible});
|
||||
}
|
||||
}
|
||||
|
||||
onBodyMount(node) {
|
||||
this.sectionBody = node;
|
||||
}
|
||||
|
||||
onHeaderClick() {
|
||||
// If this.sectionBody is unset, it means that we're in some sort of error
|
||||
// state, probably displaying the error fallback, so we won't be able to
|
||||
// compute the height, and we don't want to persist the preference.
|
||||
// If props.collapsed is undefined handler shouldn't do anything.
|
||||
if (!this.sectionBody || this.props.collapsed === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current height of the body so max-height transitions can work
|
||||
this.setState({
|
||||
isAnimating: true,
|
||||
maxHeight: `${this.sectionBody.scrollHeight}px`
|
||||
});
|
||||
const {action, userEvent} = SectionMenuOptions.CheckCollapsed(this.props);
|
||||
this.props.dispatch(action);
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
event: userEvent,
|
||||
source: this.props.source
|
||||
}));
|
||||
}
|
||||
|
||||
onTransitionEnd(event) {
|
||||
// Only update the animating state for our own transition (not a child's)
|
||||
if (event.target === event.currentTarget) {
|
||||
this.setState({isAnimating: false});
|
||||
}
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
const {icon} = this.props;
|
||||
if (icon && icon.startsWith("moz-extension://")) {
|
||||
return <span className="icon icon-small-spacer" style={{backgroundImage: `url('${icon}')`}} />;
|
||||
}
|
||||
return <span className={`icon icon-small-spacer icon-${icon || "webextension"}`} />;
|
||||
}
|
||||
|
||||
onMenuButtonClick(event) {
|
||||
event.preventDefault();
|
||||
this.setState({showContextMenu: true});
|
||||
}
|
||||
|
||||
onMenuButtonMouseEnter() {
|
||||
this.setState({menuButtonHover: true});
|
||||
}
|
||||
|
||||
onMenuButtonMouseLeave() {
|
||||
this.setState({menuButtonHover: false});
|
||||
}
|
||||
|
||||
onMenuUpdate(showContextMenu) {
|
||||
this.setState({showContextMenu});
|
||||
}
|
||||
|
||||
render() {
|
||||
const isCollapsible = this.props.collapsed !== undefined;
|
||||
const {enableAnimation, isAnimating, maxHeight, menuButtonHover, showContextMenu} = this.state;
|
||||
const {id, eventSource, collapsed, disclaimer, title, extraMenuOptions, showPrefName, privacyNoticeURL, dispatch, isFirst, isLast, isWebExtension} = this.props;
|
||||
const disclaimerPref = `section.${id}.showDisclaimer`;
|
||||
const needsDisclaimer = disclaimer && this.props.Prefs.values[disclaimerPref];
|
||||
const active = menuButtonHover || showContextMenu;
|
||||
return (
|
||||
<section
|
||||
className={`collapsible-section ${this.props.className}${enableAnimation ? " animation-enabled" : ""}${collapsed ? " collapsed" : ""}${active ? " active" : ""}`}
|
||||
// Note: data-section-id is used for web extension api tests in mozilla central
|
||||
data-section-id={id}>
|
||||
<div className="section-top-bar">
|
||||
<h3 className="section-title">
|
||||
<span className="click-target" onClick={this.onHeaderClick}>
|
||||
{this.renderIcon()}
|
||||
{getFormattedMessage(title)}
|
||||
{isCollapsible && <span className={`collapsible-arrow icon ${collapsed ? "icon-arrowhead-forward-small" : "icon-arrowhead-down-small"}`} />}
|
||||
</span>
|
||||
</h3>
|
||||
<div>
|
||||
<button
|
||||
className="context-menu-button icon"
|
||||
onClick={this.onMenuButtonClick}
|
||||
onMouseEnter={this.onMenuButtonMouseEnter}
|
||||
onMouseLeave={this.onMenuButtonMouseLeave}>
|
||||
<span className="sr-only">
|
||||
<FormattedMessage id="section_context_menu_button_sr" />
|
||||
</span>
|
||||
</button>
|
||||
{showContextMenu &&
|
||||
<SectionMenu
|
||||
id={id}
|
||||
extraOptions={extraMenuOptions}
|
||||
eventSource={eventSource}
|
||||
showPrefName={showPrefName}
|
||||
privacyNoticeURL={privacyNoticeURL}
|
||||
collapsed={collapsed}
|
||||
onUpdate={this.onMenuUpdate}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
dispatch={dispatch}
|
||||
isWebExtension={isWebExtension} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBoundary className="section-body-fallback">
|
||||
<div
|
||||
className={`section-body${isAnimating ? " animating" : ""}`}
|
||||
onTransitionEnd={this.onTransitionEnd}
|
||||
ref={this.onBodyMount}
|
||||
style={isAnimating && !collapsed ? {maxHeight} : null}>
|
||||
{needsDisclaimer && <DisclaimerIntl disclaimerPref={disclaimerPref} disclaimer={disclaimer} eventSource={eventSource} dispatch={this.props.dispatch} />}
|
||||
{this.props.children}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_CollapsibleSection.defaultProps = {
|
||||
document: global.document || {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
visibilityState: "hidden"
|
||||
},
|
||||
Prefs: {values: {}}
|
||||
};
|
||||
|
||||
export const CollapsibleSection = injectIntl(_CollapsibleSection);
|
|
@ -0,0 +1,166 @@
|
|||
.collapsible-section {
|
||||
padding: $section-vertical-padding $section-horizontal-padding;
|
||||
transition-delay: 100ms;
|
||||
transition-duration: 100ms;
|
||||
transition-property: background-color;
|
||||
|
||||
.section-title {
|
||||
font-size: $section-title-font-size;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
span {
|
||||
color: var(--newtab-section-header-text-color);
|
||||
display: inline-block;
|
||||
fill: var(--newtab-section-header-text-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.click-target {
|
||||
cursor: pointer;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsible-arrow {
|
||||
margin-inline-start: 8px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-top-bar {
|
||||
height: 19px;
|
||||
margin-bottom: 13px;
|
||||
position: relative;
|
||||
|
||||
.context-menu-button {
|
||||
background: url('chrome://browser/skin/page-action.svg') no-repeat right center;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
fill: var(--newtab-section-header-text-color);
|
||||
height: 100%;
|
||||
offset-inline-end: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition-duration: 200ms;
|
||||
transition-property: opacity;
|
||||
width: $context-menu-button-size;
|
||||
|
||||
&:-moz-any(:active, :focus, :hover) {
|
||||
fill: $grey-90;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: $break-point-widest + $card-width * 1.5) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
.section-top-bar {
|
||||
.context-menu-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--newtab-element-hover-color);
|
||||
border-radius: 4px;
|
||||
|
||||
.section-top-bar {
|
||||
.context-menu-button {
|
||||
fill: var(--newtab-section-active-contextmenu-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-disclaimer {
|
||||
$max-button-width: 130px;
|
||||
$min-button-height: 26px;
|
||||
|
||||
color: var(--newtab-text-conditional-color);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
|
||||
.section-disclaimer-text {
|
||||
display: inline-block;
|
||||
min-height: $min-button-height;
|
||||
width: calc(100% - #{$max-button-width});
|
||||
|
||||
@media (max-width: $break-point-medium) {
|
||||
width: $card-width;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--newtab-link-primary-color);
|
||||
font-weight: bold;
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--newtab-button-secondary-color);
|
||||
border: 1px solid $grey-40;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 2px;
|
||||
max-width: $max-button-width;
|
||||
min-height: $min-button-height;
|
||||
offset-inline-end: 0;
|
||||
|
||||
&:hover:not(.dismiss) {
|
||||
box-shadow: $shadow-primary;
|
||||
transition: box-shadow 150ms;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-small) {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-body-fallback {
|
||||
height: $card-height;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
// This is so the top sites favicon and card dropshadows don't get clipped during animation:
|
||||
$horizontal-padding: 7px;
|
||||
margin: 0 (-$horizontal-padding);
|
||||
padding: 0 $horizontal-padding;
|
||||
|
||||
&.animating {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.animation-enabled {
|
||||
.section-title {
|
||||
.collapsible-arrow {
|
||||
transition: transform 0.5s $photon-easing;
|
||||
}
|
||||
}
|
||||
|
||||
.section-body {
|
||||
transition: max-height 0.5s $photon-easing;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.section-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {perfService as perfSvc} from "common/PerfService.jsm";
|
||||
import React from "react";
|
||||
|
||||
// Currently record only a fixed set of sections. This will prevent data
|
||||
// from custom sections from showing up or from topstories.
|
||||
const RECORDED_SECTIONS = ["highlights", "topsites"];
|
||||
|
||||
export class ComponentPerfTimer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Just for test dependency injection:
|
||||
this.perfSvc = this.props.perfSvc || perfSvc;
|
||||
|
||||
this._sendBadStateEvent = this._sendBadStateEvent.bind(this);
|
||||
this._sendPaintedEvent = this._sendPaintedEvent.bind(this);
|
||||
this._reportMissingData = false;
|
||||
this._timestampHandled = false;
|
||||
this._recordedFirstRender = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!RECORDED_SECTIONS.includes(this.props.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maybeSendPaintedEvent();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (!RECORDED_SECTIONS.includes(this.props.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maybeSendPaintedEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the given callback after the upcoming frame paints.
|
||||
*
|
||||
* @note Both setTimeout and requestAnimationFrame are throttled when the page
|
||||
* is hidden, so this callback may get called up to a second or so after the
|
||||
* requestAnimationFrame "paint" for hidden tabs.
|
||||
*
|
||||
* Newtabs hidden while loading will presumably be fairly rare (other than
|
||||
* preloaded tabs, which we will be filtering out on the server side), so such
|
||||
* cases should get lost in the noise.
|
||||
*
|
||||
* If we decide that it's important to find out when something that's hidden
|
||||
* has "painted", however, another option is to post a message to this window.
|
||||
* That should happen even faster than setTimeout, and, at least as of this
|
||||
* writing, it's not throttled in hidden windows in Firefox.
|
||||
*
|
||||
* @param {Function} callback
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
_afterFramePaint(callback) {
|
||||
requestAnimationFrame(() => setTimeout(callback, 0));
|
||||
}
|
||||
|
||||
_maybeSendBadStateEvent() {
|
||||
// Follow up bugs:
|
||||
// https://github.com/mozilla/activity-stream/issues/3691
|
||||
if (!this.props.initialized) {
|
||||
// Remember to report back when data is available.
|
||||
this._reportMissingData = true;
|
||||
} else if (this._reportMissingData) {
|
||||
this._reportMissingData = false;
|
||||
// Report how long it took for component to become initialized.
|
||||
this._sendBadStateEvent();
|
||||
}
|
||||
}
|
||||
|
||||
_maybeSendPaintedEvent() {
|
||||
// If we've already handled a timestamp, don't do it again.
|
||||
if (this._timestampHandled || !this.props.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// And if we haven't, we're doing so now, so remember that. Even if
|
||||
// something goes wrong in the callback, we can't try again, as we'd be
|
||||
// sending back the wrong data, and we have to do it here, so that other
|
||||
// calls to this method while waiting for the next frame won't also try to
|
||||
// handle it.
|
||||
this._timestampHandled = true;
|
||||
this._afterFramePaint(this._sendPaintedEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered by call to render. Only first call goes through due to
|
||||
* `_recordedFirstRender`.
|
||||
*/
|
||||
_ensureFirstRenderTsRecorded() {
|
||||
// Used as t0 for recording how long component took to initialize.
|
||||
if (!this._recordedFirstRender) {
|
||||
this._recordedFirstRender = true;
|
||||
// topsites_first_render_ts, highlights_first_render_ts.
|
||||
const key = `${this.props.id}_first_render_ts`;
|
||||
this.perfSvc.mark(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates `TELEMETRY_UNDESIRED_EVENT` with timestamp in ms
|
||||
* of how much longer the data took to be ready for display than it would
|
||||
* have been the ideal case.
|
||||
* https://github.com/mozilla/ping-centre/issues/98
|
||||
*/
|
||||
_sendBadStateEvent() {
|
||||
// highlights_data_ready_ts, topsites_data_ready_ts.
|
||||
const dataReadyKey = `${this.props.id}_data_ready_ts`;
|
||||
this.perfSvc.mark(dataReadyKey);
|
||||
|
||||
try {
|
||||
const firstRenderKey = `${this.props.id}_first_render_ts`;
|
||||
// value has to be Int32.
|
||||
const value = parseInt(this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) -
|
||||
this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), 10);
|
||||
this.props.dispatch(ac.OnlyToMain({
|
||||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
// highlights_data_late_by_ms, topsites_data_late_by_ms.
|
||||
data: {[`${this.props.id}_data_late_by_ms`]: value}
|
||||
}));
|
||||
} catch (ex) {
|
||||
// If this failed, it's likely because the `privacy.resistFingerprinting`
|
||||
// pref is true.
|
||||
}
|
||||
}
|
||||
|
||||
_sendPaintedEvent() {
|
||||
// Record first_painted event but only send if topsites.
|
||||
if (this.props.id !== "topsites") {
|
||||
return;
|
||||
}
|
||||
|
||||
// topsites_first_painted_ts.
|
||||
const key = `${this.props.id}_first_painted_ts`;
|
||||
this.perfSvc.mark(key);
|
||||
|
||||
try {
|
||||
const data = {};
|
||||
data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key);
|
||||
|
||||
this.props.dispatch(ac.OnlyToMain({
|
||||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data
|
||||
}));
|
||||
} catch (ex) {
|
||||
// If this failed, it's likely because the `privacy.resistFingerprinting`
|
||||
// pref is true. We should at least not blow up, and should continue
|
||||
// to set this._timestampHandled to avoid going through this again.
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (RECORDED_SECTIONS.includes(this.props.id)) {
|
||||
this._ensureFirstRenderTsRecorded();
|
||||
this._maybeSendBadStateEvent();
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import {actionCreators as ac, actionTypes} from "common/Actions.jsm";
|
||||
import {connect} from "react-redux";
|
||||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* ConfirmDialog component.
|
||||
* One primary action button, one cancel button.
|
||||
*
|
||||
* Content displayed is controlled by `data` prop the component receives.
|
||||
* Example:
|
||||
* data: {
|
||||
* // Any sort of data needed to be passed around by actions.
|
||||
* payload: site.url,
|
||||
* // Primary button AlsoToMain action.
|
||||
* action: "DELETE_HISTORY_URL",
|
||||
* // Primary button USerEvent action.
|
||||
* userEvent: "DELETE",
|
||||
* // Array of locale ids to display.
|
||||
* message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
|
||||
* // Text for primary button.
|
||||
* confirm_button_string_id: "menu_action_delete"
|
||||
* },
|
||||
*/
|
||||
export class _ConfirmDialog extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._handleCancelBtn = this._handleCancelBtn.bind(this);
|
||||
this._handleConfirmBtn = this._handleConfirmBtn.bind(this);
|
||||
}
|
||||
|
||||
_handleCancelBtn() {
|
||||
this.props.dispatch({type: actionTypes.DIALOG_CANCEL});
|
||||
this.props.dispatch(ac.UserEvent({event: actionTypes.DIALOG_CANCEL, source: this.props.data.eventSource}));
|
||||
}
|
||||
|
||||
_handleConfirmBtn() {
|
||||
this.props.data.onConfirm.forEach(this.props.dispatch);
|
||||
}
|
||||
|
||||
_renderModalMessage() {
|
||||
const message_body = this.props.data.body_string_id;
|
||||
|
||||
if (!message_body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<span>
|
||||
{message_body.map(msg => <p key={msg}><FormattedMessage id={msg} /></p>)}
|
||||
</span>);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<div className="confirmation-dialog">
|
||||
<div className="modal-overlay" onClick={this._handleCancelBtn} />
|
||||
<div className="modal">
|
||||
<section className="modal-message">
|
||||
{this.props.data.icon && <span className={`icon icon-spacer icon-${this.props.data.icon}`} />}
|
||||
{this._renderModalMessage()}
|
||||
</section>
|
||||
<section className="actions">
|
||||
<button onClick={this._handleCancelBtn}>
|
||||
<FormattedMessage id={this.props.data.cancel_button_string_id} />
|
||||
</button>
|
||||
<button className="done" onClick={this._handleConfirmBtn}>
|
||||
<FormattedMessage id={this.props.data.confirm_button_string_id} />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog);
|
|
@ -0,0 +1,67 @@
|
|||
.confirmation-dialog {
|
||||
.modal {
|
||||
box-shadow: 0 2px 2px 0 $black-10;
|
||||
left: 50%;
|
||||
margin-left: -200px;
|
||||
position: fixed;
|
||||
top: 20%;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 16px;
|
||||
|
||||
button {
|
||||
margin-inline-end: 16px;
|
||||
padding-inline-end: 18px;
|
||||
padding-inline-start: 18px;
|
||||
white-space: normal;
|
||||
width: 50%;
|
||||
|
||||
&.done {
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
background: var(--newtab-overlay-color);
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 11001;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--newtab-modal-color);
|
||||
border: $border-secondary;
|
||||
border-radius: 5px;
|
||||
font-size: 15px;
|
||||
z-index: 11002;
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import React from "react";
|
||||
|
||||
export class ContextMenu extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.hideContext = this.hideContext.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
hideContext() {
|
||||
this.props.onUpdate(false);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
global.addEventListener("click", this.hideContext);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
global.removeEventListener("click", this.hideContext);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
// Eat all clicks on the context menu so they don't bubble up to window.
|
||||
// This prevents the context menu from closing when clicking disabled items
|
||||
// or the separators.
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<span className="context-menu" onClick={this.onClick}>
|
||||
<ul role="menu" className="context-menu-list">
|
||||
{this.props.options.map((option, i) => (option.type === "separator" ?
|
||||
(<li key={i} className="separator" />) :
|
||||
(option.type !== "empty" && <ContextMenuItem key={i} option={option} hideContext={this.hideContext} />)
|
||||
))}
|
||||
</ul>
|
||||
</span>);
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextMenuItem extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.hideContext();
|
||||
this.props.option.onClick();
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
const {option} = this.props;
|
||||
switch (event.key) {
|
||||
case "Tab":
|
||||
// tab goes down in context menu, shift + tab goes up in context menu
|
||||
// if we're on the last item, one more tab will close the context menu
|
||||
// similarly, if we're on the first item, one more shift + tab will close it
|
||||
if ((event.shiftKey && option.first) || (!event.shiftKey && option.last)) {
|
||||
this.props.hideContext();
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
this.props.hideContext();
|
||||
option.onClick();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {option} = this.props;
|
||||
return (
|
||||
<li role="menuitem" className="context-menu-item">
|
||||
<a onClick={this.onClick} onKeyDown={this.onKeyDown} tabIndex="0" className={option.disabled ? "disabled" : ""}>
|
||||
{option.icon && <span className={`icon icon-spacer icon-${option.icon}`} />}
|
||||
{option.label}
|
||||
</a>
|
||||
</li>);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
.context-menu {
|
||||
background: var(--newtab-contextmenu-background-color);
|
||||
border-radius: $context-menu-border-radius;
|
||||
box-shadow: $context-menu-shadow;
|
||||
display: block;
|
||||
font-size: $context-menu-font-size;
|
||||
margin-inline-start: 5px;
|
||||
offset-inline-start: 100%;
|
||||
position: absolute;
|
||||
top: ($context-menu-button-size / 4);
|
||||
z-index: 10000;
|
||||
|
||||
> ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: $context-menu-outer-padding 0;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
&.separator {
|
||||
border-bottom: $border-secondary;
|
||||
margin: $context-menu-outer-padding 0;
|
||||
}
|
||||
|
||||
> a {
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
line-height: 16px;
|
||||
outline: none;
|
||||
padding: $context-menu-item-padding;
|
||||
white-space: nowrap;
|
||||
|
||||
&:-moz-any(:focus, :hover) {
|
||||
background: var(--newtab-element-hover-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--newtab-element-active-color);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
|
||||
export class ErrorBoundaryFallback extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.windowObj = this.props.windowObj || window;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we only get here if part of the page has crashed, do a
|
||||
* forced reload to give us the best chance at recovering.
|
||||
*/
|
||||
onClick() {
|
||||
this.windowObj.location.reload(true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const defaultClass = "as-error-fallback";
|
||||
let className;
|
||||
if ("className" in this.props) {
|
||||
className = `${this.props.className} ${defaultClass}`;
|
||||
} else {
|
||||
className = defaultClass;
|
||||
}
|
||||
|
||||
// href="#" to force normal link styling stuff (eg cursor on hover)
|
||||
return (
|
||||
<div className={className}>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
defaultMessage="Oops, something went wrong loading this content."
|
||||
id="error_fallback_default_info" />
|
||||
</div>
|
||||
<span>
|
||||
<a href="#" className="reload-button" onClick={this.onClick}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Refresh page to try again."
|
||||
id="error_fallback_default_refresh_suggestion" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
ErrorBoundaryFallback.defaultProps = {className: "as-error-fallback"};
|
||||
|
||||
export class ErrorBoundary extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {hasError: false};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({hasError: true});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return (this.props.children);
|
||||
}
|
||||
|
||||
return <this.props.FallbackComponent className={this.props.className} />;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.defaultProps = {FallbackComponent: ErrorBoundaryFallback};
|
|
@ -0,0 +1,17 @@
|
|||
.as-error-fallback {
|
||||
align-items: center;
|
||||
border-radius: $border-radius;
|
||||
box-shadow: inset $inner-box-shadow;
|
||||
color: var(--newtab-text-conditional-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: $error-fallback-font-size;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
line-height: $error-fallback-line-height;
|
||||
|
||||
a {
|
||||
color: var(--newtab-text-conditional-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {connect} from "react-redux";
|
||||
import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
|
||||
import {injectIntl} from "react-intl";
|
||||
import {LinkMenuOptions} from "content-src/lib/link-menu-options";
|
||||
import React from "react";
|
||||
|
||||
const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
|
||||
|
||||
export class _LinkMenu extends React.PureComponent {
|
||||
getOptions() {
|
||||
const {props} = this;
|
||||
const {site, index, source, isPrivateBrowsingEnabled, siteInfo, platform} = props;
|
||||
|
||||
// Handle special case of default site
|
||||
const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
|
||||
|
||||
const options = propOptions.map(o => LinkMenuOptions[o](site, index, source, isPrivateBrowsingEnabled, siteInfo, platform)).map(option => {
|
||||
const {action, impression, id, string_id, type, userEvent} = option;
|
||||
if (!type && id) {
|
||||
option.label = props.intl.formatMessage({id: string_id || id});
|
||||
option.onClick = () => {
|
||||
props.dispatch(action);
|
||||
if (userEvent) {
|
||||
const userEventData = Object.assign({
|
||||
event: userEvent,
|
||||
source,
|
||||
action_position: index
|
||||
}, siteInfo);
|
||||
props.dispatch(ac.UserEvent(userEventData));
|
||||
}
|
||||
if (impression && props.shouldSendImpressionStats) {
|
||||
props.dispatch(impression);
|
||||
}
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
// This is for accessibility to support making each item tabbable.
|
||||
// We want to know which item is the first and which item
|
||||
// is the last, so we can close the context menu accordingly.
|
||||
options[0].first = true;
|
||||
options[options.length - 1].last = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<ContextMenu
|
||||
onUpdate={this.props.onUpdate}
|
||||
options={this.getOptions()} />);
|
||||
}
|
||||
}
|
||||
|
||||
const getState = state => ({isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, platform: state.Prefs.values.platform});
|
||||
export const LinkMenu = connect(getState)(injectIntl(_LinkMenu));
|
|
@ -0,0 +1,49 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {connect} from "react-redux";
|
||||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Manual migration component used to start the profile import wizard.
|
||||
* Message is presented temporarily and will go away if:
|
||||
* 1. User clicks "No Thanks"
|
||||
* 2. User completed the data import
|
||||
* 3. After 3 active days
|
||||
* 4. User clicks "Cancel" on the import wizard (currently not implemented).
|
||||
*/
|
||||
export class _ManualMigration extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onLaunchTour = this.onLaunchTour.bind(this);
|
||||
this.onCancelTour = this.onCancelTour.bind(this);
|
||||
}
|
||||
|
||||
onLaunchTour() {
|
||||
this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_START}));
|
||||
this.props.dispatch(ac.UserEvent({event: at.MIGRATION_START}));
|
||||
}
|
||||
|
||||
onCancelTour() {
|
||||
this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_CANCEL}));
|
||||
this.props.dispatch(ac.UserEvent({event: at.MIGRATION_CANCEL}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div className="manual-migration-container">
|
||||
<p>
|
||||
<span className="icon icon-import" />
|
||||
<FormattedMessage id="manual_migration_explanation2" />
|
||||
</p>
|
||||
<div className="manual-migration-actions actions">
|
||||
<button className="dismiss" onClick={this.onCancelTour}>
|
||||
<FormattedMessage id="manual_migration_cancel_button" />
|
||||
</button>
|
||||
<button onClick={this.onLaunchTour}>
|
||||
<FormattedMessage id="manual_migration_import_button" />
|
||||
</button>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export const ManualMigration = connect()(_ManualMigration);
|
|
@ -0,0 +1,52 @@
|
|||
.manual-migration-container {
|
||||
color: var(--newtab-text-conditional-color);
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
margin-bottom: $section-spacing;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: $break-point-medium) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
@media (min-width: $break-point-medium) {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: none;
|
||||
@media (min-width: $break-point-medium) {
|
||||
align-self: center;
|
||||
display: block;
|
||||
fill: var(--newtab-icon-secondary-color);
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.manual-migration-actions {
|
||||
border: 0;
|
||||
display: block;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@media (min-width: $break-point-medium) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: center;
|
||||
height: 26px;
|
||||
margin: 0;
|
||||
margin-inline-start: 20px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/* globals ContentSearchUIController */
|
||||
"use strict";
|
||||
|
||||
import {FormattedMessage, injectIntl} from "react-intl";
|
||||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {connect} from "react-redux";
|
||||
import {IS_NEWTAB} from "content-src/lib/constants";
|
||||
import React from "react";
|
||||
|
||||
export class _Search extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onInputMount = this.onInputMount.bind(this);
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
// Also track search events with our own telemetry
|
||||
if (event.detail.type === "Search") {
|
||||
this.props.dispatch(ac.UserEvent({event: "SEARCH"}));
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
window.gContentSearchController.search(event);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
delete window.gContentSearchController;
|
||||
}
|
||||
|
||||
onInputMount(input) {
|
||||
if (input) {
|
||||
// The "healthReportKey" and needs to be "newtab" or "abouthome" so that
|
||||
// BrowserUsageTelemetry.jsm knows to handle events with this name, and
|
||||
// can add the appropriate telemetry probes for search. Without the correct
|
||||
// name, certain tests like browser_UsageTelemetry_content.js will fail
|
||||
// (See github ticket #2348 for more details)
|
||||
const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome";
|
||||
|
||||
// The "searchSource" needs to be "newtab" or "homepage" and is sent with
|
||||
// the search data and acts as context for the search request (See
|
||||
// nsISearchEngine.getSubmission). It is necessary so that search engine
|
||||
// plugins can correctly atribute referrals. (See github ticket #3321 for
|
||||
// more details)
|
||||
const searchSource = IS_NEWTAB ? "newtab" : "homepage";
|
||||
|
||||
// gContentSearchController needs to exist as a global so that tests for
|
||||
// the existing about:home can find it; and so it allows these tests to pass.
|
||||
// In the future, when activity stream is default about:home, this can be renamed
|
||||
window.gContentSearchController = new ContentSearchUIController(input, input.parentNode,
|
||||
healthReportKey, searchSource);
|
||||
addEventListener("ContentSearchClient", this);
|
||||
} else {
|
||||
window.gContentSearchController = null;
|
||||
removeEventListener("ContentSearchClient", this);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Do not change the ID on the input field, as legacy newtab code
|
||||
* specifically looks for the id 'newtab-search-text' on input fields
|
||||
* in order to execute searches in various tests
|
||||
*/
|
||||
render() {
|
||||
return (<div className="search-wrapper">
|
||||
<label htmlFor="newtab-search-text" className="search-label">
|
||||
<span className="sr-only"><FormattedMessage id="search_web_placeholder" /></span>
|
||||
</label>
|
||||
<input
|
||||
id="newtab-search-text"
|
||||
maxLength="256"
|
||||
placeholder={this.props.intl.formatMessage({id: "search_web_placeholder"})}
|
||||
ref={this.onInputMount}
|
||||
title={this.props.intl.formatMessage({id: "search_web_placeholder"})}
|
||||
type="search" />
|
||||
<button
|
||||
id="searchSubmit"
|
||||
className="search-button"
|
||||
onClick={this.onClick}
|
||||
title={this.props.intl.formatMessage({id: "search_button"})}>
|
||||
<span className="sr-only"><FormattedMessage id="search_button" /></span>
|
||||
</button>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export const Search = connect()(injectIntl(_Search));
|
|
@ -0,0 +1,153 @@
|
|||
.search-wrapper {
|
||||
$search-height: 35px;
|
||||
$search-icon-size: 18px;
|
||||
$search-icon-padding: 8px;
|
||||
$search-icon-width: 2 * $search-icon-padding + $search-icon-size;
|
||||
$search-input-left-label-width: 35px;
|
||||
$search-button-width: 36px;
|
||||
$glyph-forward: url('chrome://browser/skin/forward.svg');
|
||||
|
||||
cursor: default;
|
||||
display: flex;
|
||||
height: $search-height;
|
||||
margin-bottom: $section-spacing;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center / $search-icon-size no-repeat;
|
||||
border: solid 1px var(--newtab-search-border-color);
|
||||
box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
|
||||
font-size: 15px;
|
||||
-moz-context-properties: fill;
|
||||
fill: var(--newtab-search-icon-color);
|
||||
padding: 0;
|
||||
padding-inline-end: $search-button-width;
|
||||
padding-inline-start: $search-icon-width;
|
||||
width: 100%;
|
||||
|
||||
&:dir(rtl) {
|
||||
background-position-x: right $search-icon-padding;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover input {
|
||||
box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
|
||||
}
|
||||
|
||||
&:active input,
|
||||
input:focus {
|
||||
border: $input-border-active;
|
||||
box-shadow: var(--newtab-textbox-focus-boxshadow);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background: $glyph-forward no-repeat center center;
|
||||
background-size: 16px 16px;
|
||||
border: 0;
|
||||
border-radius: 0 $border-radius $border-radius 0;
|
||||
-moz-context-properties: fill;
|
||||
fill: var(--newtab-search-icon-color);
|
||||
height: 100%;
|
||||
offset-inline-end: 0;
|
||||
position: absolute;
|
||||
width: $search-button-width;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: $grey-90-10;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $grey-90-20;
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
// Adjust the style of the contentSearchUI-generated table
|
||||
.contentSearchSuggestionTable {
|
||||
background-color: var(--newtab-search-dropdown-color);
|
||||
border: 0;
|
||||
box-shadow: $context-menu-shadow;
|
||||
transform: translateY($textbox-shadow-size);
|
||||
|
||||
.contentSearchHeader {
|
||||
background-color: var(--newtab-search-dropdown-header-color);
|
||||
color: var(--newtab-text-secondary-color);
|
||||
}
|
||||
|
||||
.contentSearchHeader,
|
||||
.contentSearchSettingsButton {
|
||||
border-color: var(--newtab-border-secondary-color);
|
||||
}
|
||||
|
||||
.contentSearchSuggestionsList {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.contentSearchOneOffsTable {
|
||||
background-color: var(--newtab-search-dropdown-header-color);
|
||||
border-top: solid 1px var(--newtab-border-secondary-color);
|
||||
}
|
||||
|
||||
.contentSearchSearchWithHeaderSearchText {
|
||||
color: var(--newtab-text-primary-color);
|
||||
}
|
||||
|
||||
.contentSearchSuggestionsContainer {
|
||||
background-color: var(--newtab-search-dropdown-color);
|
||||
}
|
||||
|
||||
.contentSearchSuggestionRow {
|
||||
&.selected {
|
||||
background: var(--newtab-element-hover-color);
|
||||
color: var(--newtab-text-primary-color);
|
||||
|
||||
&:active {
|
||||
background: var(--newtab-element-active-color);
|
||||
}
|
||||
|
||||
.historyIcon {
|
||||
fill: var(--newtab-icon-secondary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contentSearchOneOffsTable {
|
||||
.contentSearchSuggestionsContainer {
|
||||
background-color: var(--newtab-search-dropdown-header-color);
|
||||
}
|
||||
}
|
||||
|
||||
.contentSearchOneOffItem {
|
||||
// Make the border slightly shorter by offsetting from the top and bottom
|
||||
$border-offset: 18%;
|
||||
|
||||
background-image: none;
|
||||
border-image: linear-gradient(transparent $border-offset, var(--newtab-border-secondary-color) $border-offset, var(--newtab-border-secondary-color) 100% - $border-offset, transparent 100% - $border-offset) 1;
|
||||
border-inline-end: 1px solid;
|
||||
position: relative;
|
||||
|
||||
&.selected {
|
||||
background: var(--newtab-element-hover-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--newtab-element-active-color);
|
||||
}
|
||||
}
|
||||
|
||||
.contentSearchSettingsButton {
|
||||
&:hover {
|
||||
background: var(--newtab-element-hover-color);
|
||||
color: var(--newtab-text-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
|
||||
import {injectIntl} from "react-intl";
|
||||
import React from "react";
|
||||
import {SectionMenuOptions} from "content-src/lib/section-menu-options";
|
||||
|
||||
const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
|
||||
const WEBEXT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "CheckCollapsed", "Separator", "ManageWebExtension"];
|
||||
|
||||
export class _SectionMenu extends React.PureComponent {
|
||||
getOptions() {
|
||||
const {props} = this;
|
||||
|
||||
const propOptions = props.isWebExtension ? [...WEBEXT_SECTION_MENU_OPTIONS] : [...DEFAULT_SECTION_MENU_OPTIONS];
|
||||
// Prepend custom options and a separator
|
||||
if (props.extraOptions) {
|
||||
propOptions.splice(0, 0, ...props.extraOptions, "Separator");
|
||||
}
|
||||
// Insert privacy notice before the last option ("ManageSection")
|
||||
if (props.privacyNoticeURL) {
|
||||
propOptions.splice(-1, 0, "PrivacyNotice");
|
||||
}
|
||||
|
||||
const options = propOptions.map(o => SectionMenuOptions[o](props)).map(option => {
|
||||
const {action, id, type, userEvent} = option;
|
||||
if (!type && id) {
|
||||
option.label = props.intl.formatMessage({id});
|
||||
option.onClick = () => {
|
||||
props.dispatch(action);
|
||||
if (userEvent) {
|
||||
props.dispatch(ac.UserEvent({
|
||||
event: userEvent,
|
||||
source: props.source
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
// This is for accessibility to support making each item tabbable.
|
||||
// We want to know which item is the first and which item
|
||||
// is the last, so we can close the context menu accordingly.
|
||||
options[0].first = true;
|
||||
options[options.length - 1].last = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<ContextMenu
|
||||
onUpdate={this.props.onUpdate}
|
||||
options={this.getOptions()} />);
|
||||
}
|
||||
}
|
||||
|
||||
export const SectionMenu = injectIntl(_SectionMenu);
|
|
@ -0,0 +1,258 @@
|
|||
import {Card, PlaceholderCard} from "content-src/components/Card/Card";
|
||||
import {FormattedMessage, injectIntl} from "react-intl";
|
||||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
|
||||
import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
|
||||
import {connect} from "react-redux";
|
||||
import React from "react";
|
||||
import {Topics} from "content-src/components/Topics/Topics";
|
||||
import {TopSites} from "content-src/components/TopSites/TopSites";
|
||||
|
||||
const VISIBLE = "visible";
|
||||
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||||
const CARDS_PER_ROW_DEFAULT = 3;
|
||||
const CARDS_PER_ROW_COMPACT_WIDE = 4;
|
||||
|
||||
function getFormattedMessage(message) {
|
||||
return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
|
||||
}
|
||||
|
||||
export class Section extends React.PureComponent {
|
||||
get numRows() {
|
||||
const {rowsPref, maxRows, Prefs} = this.props;
|
||||
return rowsPref ? Prefs.values[rowsPref] : maxRows;
|
||||
}
|
||||
|
||||
_dispatchImpressionStats() {
|
||||
const {props} = this;
|
||||
let cardsPerRow = CARDS_PER_ROW_DEFAULT;
|
||||
if (props.compactCards && global.matchMedia(`(min-width: 1072px)`).matches) {
|
||||
// If the section has compact cards and the viewport is wide enough, we show
|
||||
// 4 columns instead of 3.
|
||||
// $break-point-widest = 1072px (from _variables.scss)
|
||||
cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
|
||||
}
|
||||
const maxCards = cardsPerRow * this.numRows;
|
||||
const cards = props.rows.slice(0, maxCards);
|
||||
|
||||
if (this.needsImpressionStats(cards)) {
|
||||
props.dispatch(ac.ImpressionStats({
|
||||
source: props.eventSource,
|
||||
tiles: cards.map(link => ({id: link.guid}))
|
||||
}));
|
||||
this.impressionCardGuids = cards.map(link => link.guid);
|
||||
}
|
||||
}
|
||||
|
||||
// This sends an event when a user sees a set of new content. If content
|
||||
// changes while the page is hidden (i.e. preloaded or on a hidden tab),
|
||||
// only send the event if the page becomes visible again.
|
||||
sendImpressionStatsOrAddListener() {
|
||||
const {props} = this;
|
||||
|
||||
if (!props.shouldSendImpressionStats || !props.dispatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.document.visibilityState === VISIBLE) {
|
||||
this._dispatchImpressionStats();
|
||||
} else {
|
||||
// We should only ever send the latest impression stats ping, so remove any
|
||||
// older listeners.
|
||||
if (this._onVisibilityChange) {
|
||||
props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
|
||||
// When the page becomes visible, send the impression stats ping if the section isn't collapsed.
|
||||
this._onVisibilityChange = () => {
|
||||
if (props.document.visibilityState === VISIBLE) {
|
||||
if (!this.props.pref.collapsed) {
|
||||
this._dispatchImpressionStats();
|
||||
}
|
||||
props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
};
|
||||
props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.rows.length && !this.props.pref.collapsed) {
|
||||
this.sendImpressionStatsOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {props} = this;
|
||||
const isCollapsed = props.pref.collapsed;
|
||||
const wasCollapsed = prevProps.pref.collapsed;
|
||||
if (
|
||||
// Don't send impression stats for the empty state
|
||||
props.rows.length &&
|
||||
(
|
||||
// We only want to send impression stats if the content of the cards has changed
|
||||
// and the section is not collapsed...
|
||||
(props.rows !== prevProps.rows && !isCollapsed) ||
|
||||
// or if we are expanding a section that was collapsed.
|
||||
(wasCollapsed && !isCollapsed)
|
||||
)
|
||||
) {
|
||||
this.sendImpressionStatsOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._onVisibilityChange) {
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
needsImpressionStats(cards) {
|
||||
if (!this.impressionCardGuids || (this.impressionCardGuids.length !== cards.length)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
if (cards[i].guid !== this.impressionCardGuids[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, eventSource, title, icon, rows,
|
||||
emptyState, dispatch, compactCards,
|
||||
contextMenuOptions, initialized, disclaimer,
|
||||
pref, privacyNoticeURL, isFirst, isLast
|
||||
} = this.props;
|
||||
|
||||
const maxCardsPerRow = compactCards ? CARDS_PER_ROW_COMPACT_WIDE : CARDS_PER_ROW_DEFAULT;
|
||||
const {numRows} = this;
|
||||
const maxCards = maxCardsPerRow * numRows;
|
||||
const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
|
||||
|
||||
// Show topics only for top stories and if it's not initialized yet (so
|
||||
// content doesn't shift when it is loaded) or has loaded with topics
|
||||
const shouldShowTopics = (id === "topstories" &&
|
||||
(!this.props.topics || this.props.topics.length > 0));
|
||||
|
||||
const realRows = rows.slice(0, maxCards);
|
||||
|
||||
// The empty state should only be shown after we have initialized and there is no content.
|
||||
// Otherwise, we should show placeholders.
|
||||
const shouldShowEmptyState = initialized && !rows.length;
|
||||
|
||||
const cards = [];
|
||||
if (!shouldShowEmptyState) {
|
||||
for (let i = 0; i < maxCards; i++) {
|
||||
const link = realRows[i];
|
||||
// On narrow viewports, we only show 3 cards per row. We'll mark the rest as
|
||||
// .hide-for-narrow to hide in CSS via @media query.
|
||||
const className = (i >= maxCardsOnNarrow) ? "hide-for-narrow" : "";
|
||||
cards.push(link ? (
|
||||
<Card key={i}
|
||||
index={i}
|
||||
className={className}
|
||||
dispatch={dispatch}
|
||||
link={link}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
eventSource={eventSource}
|
||||
shouldSendImpressionStats={this.props.shouldSendImpressionStats}
|
||||
isWebExtension={this.props.isWebExtension} />
|
||||
) : (
|
||||
<PlaceholderCard key={i} className={className} />
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const sectionClassName = [
|
||||
"section",
|
||||
compactCards ? "compact-cards" : "normal-cards"
|
||||
].join(" ");
|
||||
|
||||
// <Section> <-- React component
|
||||
// <section> <-- HTML5 element
|
||||
return (<ComponentPerfTimer {...this.props}>
|
||||
<CollapsibleSection className={sectionClassName} icon={icon}
|
||||
title={title}
|
||||
id={id}
|
||||
eventSource={eventSource}
|
||||
disclaimer={disclaimer}
|
||||
collapsed={this.props.pref.collapsed}
|
||||
showPrefName={(pref && pref.feed) || id}
|
||||
privacyNoticeURL={privacyNoticeURL}
|
||||
Prefs={this.props.Prefs}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
dispatch={this.props.dispatch}
|
||||
isWebExtension={this.props.isWebExtension}>
|
||||
|
||||
{!shouldShowEmptyState && (<ul className="section-list" style={{padding: 0}}>
|
||||
{cards}
|
||||
</ul>)}
|
||||
{shouldShowEmptyState &&
|
||||
<div className="section-empty-state">
|
||||
<div className="empty-state">
|
||||
{emptyState.icon && emptyState.icon.startsWith("moz-extension://") ?
|
||||
<img className="empty-state-icon icon" style={{"background-image": `url('${emptyState.icon}')`}} /> :
|
||||
<img className={`empty-state-icon icon icon-${emptyState.icon}`} />}
|
||||
<p className="empty-state-message">
|
||||
{getFormattedMessage(emptyState.message)}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
{shouldShowTopics && <Topics topics={this.props.topics} read_more_endpoint={this.props.read_more_endpoint} />}
|
||||
</CollapsibleSection>
|
||||
</ComponentPerfTimer>);
|
||||
}
|
||||
}
|
||||
|
||||
Section.defaultProps = {
|
||||
document: global.document,
|
||||
rows: [],
|
||||
emptyState: {},
|
||||
pref: {},
|
||||
title: ""
|
||||
};
|
||||
|
||||
export const SectionIntl = connect(state => ({Prefs: state.Prefs}))(injectIntl(Section));
|
||||
|
||||
export class _Sections extends React.PureComponent {
|
||||
renderSections() {
|
||||
const sections = [];
|
||||
const enabledSections = this.props.Sections.filter(section => section.enabled);
|
||||
const {sectionOrder, "feeds.topsites": showTopSites} = this.props.Prefs.values;
|
||||
// Enabled sections doesn't include Top Sites, so we add it if enabled.
|
||||
const expectedCount = enabledSections.length + ~~showTopSites;
|
||||
|
||||
for (const sectionId of sectionOrder.split(",")) {
|
||||
const commonProps = {
|
||||
key: sectionId,
|
||||
isFirst: sections.length === 0,
|
||||
isLast: sections.length === expectedCount - 1
|
||||
};
|
||||
if (sectionId === "topsites" && showTopSites) {
|
||||
sections.push(<TopSites {...commonProps} />);
|
||||
} else {
|
||||
const section = enabledSections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
sections.push(<SectionIntl {...section} {...commonProps} />);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sections-list">
|
||||
{this.renderSections()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Sections = connect(state => ({Sections: state.Sections, Prefs: state.Prefs}))(_Sections);
|
|
@ -0,0 +1,77 @@
|
|||
.sections-list {
|
||||
.section-list {
|
||||
display: grid;
|
||||
grid-gap: $base-gutter;
|
||||
grid-template-columns: repeat(auto-fit, $card-width);
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: $break-point-medium) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-medium) and (max-width: $break-point-large) {
|
||||
:nth-child(2n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
|
||||
:nth-child(3n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
|
||||
:nth-child(3n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-empty-state {
|
||||
border: $border-secondary;
|
||||
border-radius: $border-radius;
|
||||
display: flex;
|
||||
height: $card-height;
|
||||
width: 100%;
|
||||
|
||||
.empty-state {
|
||||
margin: auto;
|
||||
max-width: 350px;
|
||||
|
||||
.empty-state-icon {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 50px 50px;
|
||||
-moz-context-properties: fill;
|
||||
display: block;
|
||||
fill: var(--newtab-icon-secondary-color);
|
||||
height: 50px;
|
||||
margin: 0 auto;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.empty-state-message {
|
||||
color: var(--newtab-text-primary-color);
|
||||
font-size: 13px;
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-widest) {
|
||||
height: $card-height-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-widest) {
|
||||
.sections-list {
|
||||
// Compact cards stay the same size but normal cards get bigger.
|
||||
.normal-cards {
|
||||
.section-list {
|
||||
grid-template-columns: repeat(auto-fit, $card-width-large);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import {FormattedMessage, injectIntl} from "react-intl";
|
||||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import {connect} from "react-redux";
|
||||
import React from "react";
|
||||
|
||||
export class _StartupOverlay extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onInputChange = this.onInputChange.bind(this);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.clickSkip = this.clickSkip.bind(this);
|
||||
this.initScene = this.initScene.bind(this);
|
||||
this.removeOverlay = this.removeOverlay.bind(this);
|
||||
|
||||
this.state = {emailInput: ""};
|
||||
this.initScene();
|
||||
}
|
||||
|
||||
initScene() {
|
||||
// Timeout to allow the scene to render once before attaching the attribute
|
||||
// to trigger the animation.
|
||||
setTimeout(() => {
|
||||
this.setState({show: true});
|
||||
}, 10);
|
||||
}
|
||||
|
||||
removeOverlay() {
|
||||
window.removeEventListener("visibilitychange", this.removeOverlay);
|
||||
this.setState({show: false});
|
||||
setTimeout(() => {
|
||||
// Allow scrolling and fully remove overlay after animation finishes.
|
||||
document.body.classList.remove("welcome");
|
||||
}, 400);
|
||||
}
|
||||
|
||||
onInputChange(e) {
|
||||
this.setState({emailInput: e.target.value});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.props.dispatch(ac.UserEvent({event: "SUBMIT_EMAIL"}));
|
||||
window.addEventListener("visibilitychange", this.removeOverlay);
|
||||
}
|
||||
|
||||
clickSkip() {
|
||||
this.props.dispatch(ac.UserEvent({event: "SKIPPED_SIGNIN"}));
|
||||
this.removeOverlay();
|
||||
}
|
||||
|
||||
render() {
|
||||
let termsLink = (<a href="https://accounts.firefox.com/legal/terms" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_terms_of_service" /></a>);
|
||||
let privacyLink = (<a href="https://accounts.firefox.com/legal/privacy" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_privacy_notice" /></a>);
|
||||
return (
|
||||
<div className={`overlay-wrapper ${this.state.show ? "show " : ""}`}>
|
||||
<div className="background" />
|
||||
<div className="firstrun-scene">
|
||||
<div className="fxaccounts-container">
|
||||
<div className="firstrun-left-divider">
|
||||
<h1 className="firstrun-title"><FormattedMessage id="firstrun_title" /></h1>
|
||||
<p className="firstrun-content"><FormattedMessage id="firstrun_content" /></p>
|
||||
<a className="firstrun-link" href="https://www.mozilla.org/firefox/features/sync/" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_learn_more_link" /></a>
|
||||
</div>
|
||||
<div className="firstrun-sign-in">
|
||||
<p className="form-header"><FormattedMessage id="firstrun_form_header" /><span><FormattedMessage id="firstrun_form_sub_header" /></span></p>
|
||||
<form method="get" action="https://accounts.firefox.com?entrypoint=activity-stream-firstrun&utm_source=activity-stream&utm_campaign=firstrun" target="_blank" rel="noopener noreferrer" onSubmit={this.onSubmit}>
|
||||
<input name="service" type="hidden" value="sync" />
|
||||
<input name="action" type="hidden" value="email" />
|
||||
<input name="context" type="hidden" value="fx_desktop_v3" />
|
||||
<input className="email-input" name="email" type="email" required="true" placeholder={this.props.intl.formatMessage({id: "firstrun_email_input_placeholder"})} onChange={this.onInputChange} />
|
||||
<div className="extra-links">
|
||||
<FormattedMessage
|
||||
id="firstrun_extra_legal_links"
|
||||
values={{
|
||||
terms: termsLink,
|
||||
privacy: privacyLink
|
||||
}} />
|
||||
</div>
|
||||
<button className="continue-button" type="submit"><FormattedMessage id="firstrun_continue_to_login" /></button>
|
||||
</form>
|
||||
<button className="skip-button" disabled={!!this.state.emailInput} onClick={this.clickSkip}><FormattedMessage id="firstrun_skip_login" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const StartupOverlay = connect()(injectIntl(_StartupOverlay));
|
|
@ -0,0 +1,247 @@
|
|||
.activity-stream {
|
||||
&.welcome {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:not(.welcome) {
|
||||
.overlay-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 21000;
|
||||
font-weight: 200;
|
||||
transition: opacity 0.4s;
|
||||
opacity: 0;
|
||||
|
||||
&.show {
|
||||
transition: none;
|
||||
opacity: 1;
|
||||
|
||||
.firstrun-sign-in {
|
||||
transition: opacity 1.5s, transform 1.5s;
|
||||
transition-delay: 0.2s;
|
||||
transform: translateY(-50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.firstrun-firefox-logo {
|
||||
transition: opacity 2.3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.firstrun-title,
|
||||
.firstrun-content,
|
||||
.firstrun-link {
|
||||
transition: transform 0.5s, opacity 0.8s;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.firstrun-title {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.firstrun-content {
|
||||
transition-delay: 0.4s;
|
||||
}
|
||||
|
||||
.firstrun-link {
|
||||
transition-delay: 0.6s;
|
||||
}
|
||||
|
||||
.fxaccounts-container {
|
||||
transition: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: url('#{$image-path}fox-tail.png') top -200px center no-repeat,
|
||||
linear-gradient(to bottom, $blue-70 40%, #004EC2 60%, $blue-60 80%, #0080FF 90%, #00C7FF 100%) top center no-repeat,
|
||||
$blue-70;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.firstrun-sign-in {
|
||||
transform: translateY(-50%) scale(0.8);
|
||||
position: relative;
|
||||
top: 50%;
|
||||
width: 358px;
|
||||
opacity: 0;
|
||||
background-color: $white;
|
||||
float: inline-end;
|
||||
color: $grey-90;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
|
||||
.extra-links {
|
||||
font-size: 12px;
|
||||
max-width: 340px;
|
||||
margin: 14px 50px;
|
||||
color: #676F7E;
|
||||
cursor: default;
|
||||
|
||||
a {
|
||||
color: $grey-50;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:active,
|
||||
a:focus {
|
||||
color: $blue-50;
|
||||
}
|
||||
}
|
||||
|
||||
.email-input {
|
||||
box-shadow: none;
|
||||
margin: auto;
|
||||
width: 244px;
|
||||
display: block;
|
||||
height: 40px;
|
||||
padding-inline-start: 20px;
|
||||
border: 1px solid $grey-50;
|
||||
border-radius: 2px;
|
||||
font-size: 16px;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.form-header {
|
||||
font-size: 18px;
|
||||
margin: 15px auto;
|
||||
}
|
||||
|
||||
.form-header span {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
margin: 10px auto 0;
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
font-size: 18px;
|
||||
height: 43px;
|
||||
width: 250px;
|
||||
padding: 8px 0;
|
||||
border: 1px solid $blue-60;
|
||||
color: $white;
|
||||
background-color: $blue-50;
|
||||
transition-duration: 150ms;
|
||||
transition-property: background-color;
|
||||
|
||||
&:not([disabled]):active {
|
||||
background: $blue-70;
|
||||
border-color: $blue-80;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-button {
|
||||
font-size: 13px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 20px;
|
||||
background-color: #FCFCFC;
|
||||
color: $blue-50;
|
||||
border: 1px solid $blue-50;
|
||||
min-height: 24px;
|
||||
padding: 5px 10px;
|
||||
transition: background-color 150ms, color 150ms, border-color 150ms;
|
||||
|
||||
&[disabled] {
|
||||
background-color: #EBEBEB;
|
||||
border-color: #B1B1B1;
|
||||
color: #6A6A6A;
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:not([disabled]):hover {
|
||||
background-color: $blue-50;
|
||||
border-color: $blue-60;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.firstrun-left-divider {
|
||||
position: relative;
|
||||
float: inline-start;
|
||||
clear: both;
|
||||
width: 435px;
|
||||
}
|
||||
|
||||
.firstrun-content {
|
||||
line-height: 1.5;
|
||||
margin-bottom: 48px;
|
||||
max-width: 352px;
|
||||
background: url('#{$image-path}sync-devices.svg') bottom center no-repeat;
|
||||
padding-bottom: 210px;
|
||||
}
|
||||
|
||||
.firstrun-link {
|
||||
color: $white;
|
||||
display: block;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.firstrun-title {
|
||||
background: url('chrome://branding/content/about-logo.png') top left no-repeat;
|
||||
background-size: 90px 90px;
|
||||
margin: 40px 0 10px;
|
||||
padding-top: 110px;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
[dir='rtl'] {
|
||||
.firstrun-title {
|
||||
background-position: top right;
|
||||
}
|
||||
}
|
||||
|
||||
.fxaccounts-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: $white;
|
||||
height: 515px;
|
||||
margin: auto;
|
||||
width: 819px;
|
||||
z-index: 10;
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.firstrun-title,
|
||||
.firstrun-content,
|
||||
.firstrun-link {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
|
@ -0,0 +1,425 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {FormattedMessage, injectIntl} from "react-intl";
|
||||
import {
|
||||
MIN_CORNER_FAVICON_SIZE,
|
||||
MIN_RICH_FAVICON_SIZE,
|
||||
TOP_SITES_CONTEXT_MENU_OPTIONS,
|
||||
TOP_SITES_SOURCE
|
||||
} from "./TopSitesConstants";
|
||||
import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
|
||||
import React from "react";
|
||||
import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
|
||||
|
||||
export class TopSiteLink extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onDragEvent = this.onDragEvent.bind(this);
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper to determine whether the drop zone should allow a drop. We only allow
|
||||
* dropping top sites for now.
|
||||
*/
|
||||
_allowDrop(e) {
|
||||
return e.dataTransfer.types.includes("text/topsite-index");
|
||||
}
|
||||
|
||||
onDragEvent(event) {
|
||||
switch (event.type) {
|
||||
case "click":
|
||||
// Stop any link clicks if we started any dragging
|
||||
if (this.dragged) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
case "dragstart":
|
||||
this.dragged = true;
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/topsite-index", this.props.index);
|
||||
event.target.blur();
|
||||
this.props.onDragEvent(event, this.props.index, this.props.link, this.props.title);
|
||||
break;
|
||||
case "dragend":
|
||||
this.props.onDragEvent(event);
|
||||
break;
|
||||
case "dragenter":
|
||||
case "dragover":
|
||||
case "drop":
|
||||
if (this._allowDrop(event)) {
|
||||
event.preventDefault();
|
||||
this.props.onDragEvent(event, this.props.index);
|
||||
}
|
||||
break;
|
||||
case "mousedown":
|
||||
// Reset at the first mouse event of a potential drag
|
||||
this.dragged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
|
||||
const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}`;
|
||||
const {tippyTopIcon, faviconSize} = link;
|
||||
const [letterFallback] = title;
|
||||
let imageClassName;
|
||||
let imageStyle;
|
||||
let showSmallFavicon = false;
|
||||
let smallFaviconStyle;
|
||||
let smallFaviconFallback;
|
||||
if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
|
||||
smallFaviconFallback = false;
|
||||
} else if (link.customScreenshotURL) {
|
||||
// assume high quality custom screenshot and use rich icon styles and class names
|
||||
imageClassName = "top-site-icon rich-icon";
|
||||
imageStyle = {
|
||||
backgroundColor: link.backgroundColor,
|
||||
backgroundImage: `url(${link.screenshot})`
|
||||
};
|
||||
} else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
|
||||
// styles and class names for top sites with rich icons
|
||||
imageClassName = "top-site-icon rich-icon";
|
||||
imageStyle = {
|
||||
backgroundColor: link.backgroundColor,
|
||||
backgroundImage: `url(${tippyTopIcon || link.favicon})`
|
||||
};
|
||||
} else {
|
||||
// styles and class names for top sites with screenshot + small icon in top left corner
|
||||
imageClassName = `screenshot${link.screenshot ? " active" : ""}`;
|
||||
imageStyle = {backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none"};
|
||||
|
||||
// only show a favicon in top left if it's greater than 16x16
|
||||
if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
|
||||
showSmallFavicon = true;
|
||||
smallFaviconStyle = {backgroundImage: `url(${link.favicon})`};
|
||||
} else if (link.screenshot) {
|
||||
// Don't show a small favicon if there is no screenshot, because that
|
||||
// would result in two fallback icons
|
||||
showSmallFavicon = true;
|
||||
smallFaviconFallback = true;
|
||||
}
|
||||
}
|
||||
let draggableProps = {};
|
||||
if (isDraggable) {
|
||||
draggableProps = {
|
||||
onClick: this.onDragEvent,
|
||||
onDragEnd: this.onDragEvent,
|
||||
onDragStart: this.onDragEvent,
|
||||
onMouseDown: this.onDragEvent
|
||||
};
|
||||
}
|
||||
return (<li className={topSiteOuterClassName} onDrop={this.onDragEvent} onDragOver={this.onDragEvent} onDragEnter={this.onDragEvent} onDragLeave={this.onDragEvent} {...draggableProps}>
|
||||
<div className="top-site-inner">
|
||||
<a href={link.url} onClick={onClick}>
|
||||
<div className="tile" aria-hidden={true} data-fallback={letterFallback}>
|
||||
<div className={imageClassName} style={imageStyle} />
|
||||
{showSmallFavicon && <div
|
||||
className="top-site-icon default-icon"
|
||||
data-fallback={smallFaviconFallback && letterFallback}
|
||||
style={smallFaviconStyle} />}
|
||||
</div>
|
||||
<div className={`title ${link.isPinned ? "pinned" : ""}`}>
|
||||
{link.isPinned && <div className="icon icon-pin-small" />}
|
||||
<span dir="auto">{title}</span>
|
||||
</div>
|
||||
</a>
|
||||
{children}
|
||||
</div>
|
||||
</li>);
|
||||
}
|
||||
}
|
||||
TopSiteLink.defaultProps = {
|
||||
title: "",
|
||||
link: {},
|
||||
isDraggable: true
|
||||
};
|
||||
|
||||
export class TopSite extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {showContextMenu: false};
|
||||
this.onLinkClick = this.onLinkClick.bind(this);
|
||||
this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
|
||||
this.onMenuUpdate = this.onMenuUpdate.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report to telemetry additional information about the item.
|
||||
*/
|
||||
_getTelemetryInfo() {
|
||||
const value = {icon_type: this.props.link.iconType};
|
||||
// Filter out "not_pinned" type for being the default
|
||||
if (this.props.link.isPinned) {
|
||||
value.card_type = "pinned";
|
||||
}
|
||||
return {value};
|
||||
}
|
||||
|
||||
userEvent(event) {
|
||||
this.props.dispatch(ac.UserEvent(Object.assign({
|
||||
event,
|
||||
source: TOP_SITES_SOURCE,
|
||||
action_position: this.props.index
|
||||
}, this._getTelemetryInfo())));
|
||||
}
|
||||
|
||||
onLinkClick(event) {
|
||||
this.userEvent("CLICK");
|
||||
|
||||
// Specially handle a top site link click for "typed" frecency bonus as
|
||||
// specified as a property on the link.
|
||||
event.preventDefault();
|
||||
const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
|
||||
this.props.dispatch(ac.OnlyToMain({
|
||||
type: at.OPEN_LINK,
|
||||
data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}})
|
||||
}));
|
||||
}
|
||||
|
||||
onMenuButtonClick(event) {
|
||||
event.preventDefault();
|
||||
this.props.onActivate(this.props.index);
|
||||
this.setState({showContextMenu: true});
|
||||
}
|
||||
|
||||
onMenuUpdate(showContextMenu) {
|
||||
this.setState({showContextMenu});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {link} = props;
|
||||
const isContextMenuOpen = this.state.showContextMenu && props.activeIndex === props.index;
|
||||
const title = link.label || link.hostname;
|
||||
return (<TopSiteLink {...props} onClick={this.onLinkClick} onDragEvent={this.props.onDragEvent} className={`${props.className || ""}${isContextMenuOpen ? " active" : ""}`} title={title}>
|
||||
<div>
|
||||
<button className="context-menu-button icon" onClick={this.onMenuButtonClick}>
|
||||
<span className="sr-only">
|
||||
<FormattedMessage id="context_menu_button_sr" values={{title}} />
|
||||
</span>
|
||||
</button>
|
||||
{isContextMenuOpen &&
|
||||
<LinkMenu
|
||||
dispatch={props.dispatch}
|
||||
index={props.index}
|
||||
onUpdate={this.onMenuUpdate}
|
||||
options={TOP_SITES_CONTEXT_MENU_OPTIONS}
|
||||
site={link}
|
||||
siteInfo={this._getTelemetryInfo()}
|
||||
source={TOP_SITES_SOURCE} />
|
||||
}
|
||||
</div>
|
||||
</TopSiteLink>);
|
||||
}
|
||||
}
|
||||
TopSite.defaultProps = {
|
||||
link: {},
|
||||
onActivate() {}
|
||||
};
|
||||
|
||||
export class TopSitePlaceholder extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onEditButtonClick = this.onEditButtonClick.bind(this);
|
||||
}
|
||||
|
||||
onEditButtonClick() {
|
||||
this.props.dispatch(
|
||||
{type: at.TOP_SITES_EDIT, data: {index: this.props.index}});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<TopSiteLink {...this.props} className={`placeholder ${this.props.className || ""}`} isDraggable={false}>
|
||||
<button className="context-menu-button edit-button icon"
|
||||
title={this.props.intl.formatMessage({id: "edit_topsites_edit_button"})}
|
||||
onClick={this.onEditButtonClick} />
|
||||
</TopSiteLink>);
|
||||
}
|
||||
}
|
||||
|
||||
export class _TopSiteList extends React.PureComponent {
|
||||
static get DEFAULT_STATE() {
|
||||
return {
|
||||
activeIndex: null,
|
||||
draggedIndex: null,
|
||||
draggedSite: null,
|
||||
draggedTitle: null,
|
||||
topSitesPreview: null
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = _TopSiteList.DEFAULT_STATE;
|
||||
this.onDragEvent = this.onDragEvent.bind(this);
|
||||
this.onActivate = this.onActivate.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.state.draggedSite) {
|
||||
const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
|
||||
const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
|
||||
if (prevTopSites && prevTopSites[this.state.draggedIndex] &&
|
||||
prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url &&
|
||||
(!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url)) {
|
||||
// We got the new order from the redux store via props. We can clear state now.
|
||||
this.setState(_TopSiteList.DEFAULT_STATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userEvent(event, index) {
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
event,
|
||||
source: TOP_SITES_SOURCE,
|
||||
action_position: index
|
||||
}));
|
||||
}
|
||||
|
||||
onDragEvent(event, index, link, title) {
|
||||
switch (event.type) {
|
||||
case "dragstart":
|
||||
this.dropped = false;
|
||||
this.setState({
|
||||
draggedIndex: index,
|
||||
draggedSite: link,
|
||||
draggedTitle: title,
|
||||
activeIndex: null
|
||||
});
|
||||
this.userEvent("DRAG", index);
|
||||
break;
|
||||
case "dragend":
|
||||
if (!this.dropped) {
|
||||
// If there was no drop event, reset the state to the default.
|
||||
this.setState(_TopSiteList.DEFAULT_STATE);
|
||||
}
|
||||
break;
|
||||
case "dragenter":
|
||||
if (index === this.state.draggedIndex) {
|
||||
this.setState({topSitesPreview: null});
|
||||
} else {
|
||||
this.setState({topSitesPreview: this._makeTopSitesPreview(index)});
|
||||
}
|
||||
break;
|
||||
case "drop":
|
||||
if (index !== this.state.draggedIndex) {
|
||||
this.dropped = true;
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.TOP_SITES_INSERT,
|
||||
data: {
|
||||
site: {
|
||||
url: this.state.draggedSite.url,
|
||||
label: this.state.draggedTitle,
|
||||
customScreenshotURL: this.state.draggedSite.customScreenshotURL
|
||||
},
|
||||
index,
|
||||
draggedFromIndex: this.state.draggedIndex
|
||||
}
|
||||
}));
|
||||
this.userEvent("DROP", index);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_getTopSites() {
|
||||
// Make a copy of the sites to truncate or extend to desired length
|
||||
let topSites = this.props.TopSites.rows.slice();
|
||||
topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
|
||||
return topSites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a preview of the topsites that will be the result of dropping the currently
|
||||
* dragged site at the specified index.
|
||||
*/
|
||||
_makeTopSitesPreview(index) {
|
||||
const topSites = this._getTopSites();
|
||||
topSites[this.state.draggedIndex] = null;
|
||||
const pinnedOnly = topSites.map(site => ((site && site.isPinned) ? site : null));
|
||||
const unpinned = topSites.filter(site => site && !site.isPinned);
|
||||
const siteToInsert = Object.assign({}, this.state.draggedSite, {isPinned: true, isDragged: true});
|
||||
if (!pinnedOnly[index]) {
|
||||
pinnedOnly[index] = siteToInsert;
|
||||
} else {
|
||||
// Find the hole to shift the pinned site(s) towards. We shift towards the
|
||||
// hole left by the site being dragged.
|
||||
let holeIndex = index;
|
||||
const indexStep = index > this.state.draggedIndex ? -1 : 1;
|
||||
while (pinnedOnly[holeIndex]) {
|
||||
holeIndex += indexStep;
|
||||
}
|
||||
|
||||
// Shift towards the hole.
|
||||
const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
|
||||
while (holeIndex !== index) {
|
||||
const nextIndex = holeIndex + shiftingStep;
|
||||
pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
|
||||
holeIndex = nextIndex;
|
||||
}
|
||||
pinnedOnly[index] = siteToInsert;
|
||||
}
|
||||
|
||||
// Fill in the remaining holes with unpinned sites.
|
||||
const preview = pinnedOnly;
|
||||
for (let i = 0; i < preview.length; i++) {
|
||||
if (!preview[i]) {
|
||||
preview[i] = unpinned.shift() || null;
|
||||
}
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
onActivate(index) {
|
||||
this.setState({activeIndex: index});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const topSites = this.state.topSitesPreview || this._getTopSites();
|
||||
const topSitesUI = [];
|
||||
const commonProps = {
|
||||
onDragEvent: this.onDragEvent,
|
||||
dispatch: props.dispatch,
|
||||
intl: props.intl
|
||||
};
|
||||
// We assign a key to each placeholder slot. We need it to be independent
|
||||
// of the slot index (i below) so that the keys used stay the same during
|
||||
// drag and drop reordering and the underlying DOM nodes are reused.
|
||||
// This mostly (only?) affects linux so be sure to test on linux before changing.
|
||||
let holeIndex = 0;
|
||||
|
||||
// On narrow viewports, we only show 6 sites per row. We'll mark the rest as
|
||||
// .hide-for-narrow to hide in CSS via @media query.
|
||||
const maxNarrowVisibleIndex = props.TopSitesRows * 6;
|
||||
|
||||
for (let i = 0, l = topSites.length; i < l; i++) {
|
||||
const link = topSites[i] && Object.assign({}, topSites[i], {iconType: this.props.topSiteIconType(topSites[i])});
|
||||
const slotProps = {
|
||||
key: link ? link.url : holeIndex++,
|
||||
index: i
|
||||
};
|
||||
if (i >= maxNarrowVisibleIndex) {
|
||||
slotProps.className = "hide-for-narrow";
|
||||
}
|
||||
topSitesUI.push(!link ? (
|
||||
<TopSitePlaceholder
|
||||
{...slotProps}
|
||||
{...commonProps} />
|
||||
) : (
|
||||
<TopSite
|
||||
link={link}
|
||||
activeIndex={this.state.activeIndex}
|
||||
onActivate={this.onActivate}
|
||||
{...slotProps}
|
||||
{...commonProps} />
|
||||
));
|
||||
}
|
||||
return (<ul className={`top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`}>
|
||||
{topSitesUI}
|
||||
</ul>);
|
||||
}
|
||||
}
|
||||
|
||||
export const TopSiteList = injectIntl(_TopSiteList);
|
|
@ -0,0 +1,251 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
import {TOP_SITES_SOURCE} from "./TopSitesConstants";
|
||||
import {TopSiteFormInput} from "./TopSiteFormInput";
|
||||
import {TopSiteLink} from "./TopSite";
|
||||
|
||||
export class TopSiteForm extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const {site} = props;
|
||||
this.state = {
|
||||
label: site ? (site.label || site.hostname) : "",
|
||||
url: site ? site.url : "",
|
||||
validationError: false,
|
||||
customScreenshotUrl: site ? site.customScreenshotURL : "",
|
||||
showCustomScreenshotForm: site ? site.customScreenshotURL : false
|
||||
};
|
||||
this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
|
||||
this.onLabelChange = this.onLabelChange.bind(this);
|
||||
this.onUrlChange = this.onUrlChange.bind(this);
|
||||
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
|
||||
this.onClearUrlClick = this.onClearUrlClick.bind(this);
|
||||
this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
|
||||
this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(this);
|
||||
this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
|
||||
this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
|
||||
this.validateUrl = this.validateUrl.bind(this);
|
||||
}
|
||||
|
||||
onLabelChange(event) {
|
||||
this.setState({"label": event.target.value});
|
||||
}
|
||||
|
||||
onUrlChange(event) {
|
||||
this.setState({
|
||||
url: event.target.value,
|
||||
validationError: false
|
||||
});
|
||||
}
|
||||
|
||||
onClearUrlClick() {
|
||||
this.setState({
|
||||
url: "",
|
||||
validationError: false
|
||||
});
|
||||
}
|
||||
|
||||
onEnableScreenshotUrlForm() {
|
||||
this.setState({showCustomScreenshotForm: true});
|
||||
}
|
||||
|
||||
_updateCustomScreenshotInput(customScreenshotUrl) {
|
||||
this.setState({
|
||||
customScreenshotUrl,
|
||||
validationError: false
|
||||
});
|
||||
this.props.dispatch({type: at.PREVIEW_REQUEST_CANCEL});
|
||||
}
|
||||
|
||||
onCustomScreenshotUrlChange(event) {
|
||||
this._updateCustomScreenshotInput(event.target.value);
|
||||
}
|
||||
|
||||
onClearScreenshotInput() {
|
||||
this._updateCustomScreenshotInput("");
|
||||
}
|
||||
|
||||
onCancelButtonClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
onDoneButtonClick(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.validateForm()) {
|
||||
const site = {url: this.cleanUrl(this.state.url)};
|
||||
const {index} = this.props;
|
||||
if (this.state.label !== "") {
|
||||
site.label = this.state.label;
|
||||
}
|
||||
|
||||
if (this.state.customScreenshotUrl) {
|
||||
site.customScreenshotURL = this.cleanUrl(this.state.customScreenshotUrl);
|
||||
} else if (this.props.site && this.props.site.customScreenshotURL) {
|
||||
// Used to flag that previously cached screenshot should be removed
|
||||
site.customScreenshotURL = null;
|
||||
}
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.TOP_SITES_PIN,
|
||||
data: {site, index}
|
||||
}));
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
source: TOP_SITES_SOURCE,
|
||||
event: "TOP_SITES_EDIT",
|
||||
action_position: index
|
||||
}));
|
||||
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onPreviewButtonClick(event) {
|
||||
event.preventDefault();
|
||||
if (this.validateForm()) {
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.PREVIEW_REQUEST,
|
||||
data: {url: this.cleanUrl(this.state.customScreenshotUrl)}
|
||||
}));
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
source: TOP_SITES_SOURCE,
|
||||
event: "PREVIEW_REQUEST"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
cleanUrl(url) {
|
||||
// If we are missing a protocol, prepend http://
|
||||
if (!url.startsWith("http:") && !url.startsWith("https:")) {
|
||||
return `http://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
_tryParseUrl(url) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
validateUrl(url) {
|
||||
const validProtocols = ["http:", "https:"];
|
||||
const urlObj = this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
|
||||
|
||||
return urlObj && validProtocols.includes(urlObj.protocol);
|
||||
}
|
||||
|
||||
validateCustomScreenshotUrl() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
const validate = this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
|
||||
|
||||
if (!validate) {
|
||||
this.setState({validationError: true});
|
||||
}
|
||||
|
||||
return validate;
|
||||
}
|
||||
|
||||
_renderCustomScreenshotInput() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
const requestFailed = this.props.previewResponse === "";
|
||||
const validationError = (this.state.validationError && !this.validateCustomScreenshotUrl()) || requestFailed;
|
||||
// Set focus on error if the url field is valid or when the input is first rendered and is empty
|
||||
const shouldFocus = (validationError && this.validateUrl(this.state.url)) || !customScreenshotUrl;
|
||||
const isLoading = this.props.previewResponse === null &&
|
||||
customScreenshotUrl && this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
|
||||
|
||||
if (!this.state.showCustomScreenshotForm) {
|
||||
return (<a className="enable-custom-image-input" onClick={this.onEnableScreenshotUrlForm}>
|
||||
<FormattedMessage id="topsites_form_use_image_link" />
|
||||
</a>);
|
||||
}
|
||||
return (<div className="custom-image-input-container">
|
||||
<TopSiteFormInput
|
||||
errorMessageId={requestFailed ? "topsites_form_image_validation" : "topsites_form_url_validation"}
|
||||
loading={isLoading}
|
||||
onChange={this.onCustomScreenshotUrlChange}
|
||||
onClear={this.onClearScreenshotInput}
|
||||
shouldFocus={shouldFocus}
|
||||
typeUrl={true}
|
||||
value={customScreenshotUrl}
|
||||
validationError={validationError}
|
||||
titleId="topsites_form_image_url_label"
|
||||
placeholderId="topsites_form_url_placeholder"
|
||||
intl={this.props.intl} />
|
||||
</div>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
const requestFailed = this.props.previewResponse === "";
|
||||
// For UI purposes, editing without an existing link is "add"
|
||||
const showAsAdd = !this.props.site;
|
||||
const previous = (this.props.site && this.props.site.customScreenshotURL) || "";
|
||||
const changed = customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
|
||||
// Preview mode if changes were made to the custom screenshot URL and no preview was received yet
|
||||
// or the request failed
|
||||
const previewMode = changed && !this.props.previewResponse;
|
||||
const previewLink = Object.assign({}, this.props.site);
|
||||
if (this.props.previewResponse) {
|
||||
previewLink.screenshot = this.props.previewResponse;
|
||||
previewLink.customScreenshotURL = this.props.previewUrl;
|
||||
}
|
||||
return (
|
||||
<form className="topsite-form">
|
||||
<div className="form-input-container">
|
||||
<h3 className="section-title">
|
||||
<FormattedMessage id={showAsAdd ? "topsites_form_add_header" : "topsites_form_edit_header"} />
|
||||
</h3>
|
||||
<div className="fields-and-preview">
|
||||
<div className="form-wrapper">
|
||||
<TopSiteFormInput onChange={this.onLabelChange}
|
||||
value={this.state.label}
|
||||
titleId="topsites_form_title_label"
|
||||
placeholderId="topsites_form_title_placeholder"
|
||||
intl={this.props.intl} />
|
||||
<TopSiteFormInput onChange={this.onUrlChange}
|
||||
shouldFocus={this.state.validationError && !this.validateUrl(this.state.url)}
|
||||
value={this.state.url}
|
||||
onClear={this.onClearUrlClick}
|
||||
validationError={this.state.validationError && !this.validateUrl(this.state.url)}
|
||||
titleId="topsites_form_url_label"
|
||||
typeUrl={true}
|
||||
placeholderId="topsites_form_url_placeholder"
|
||||
errorMessageId="topsites_form_url_validation"
|
||||
intl={this.props.intl} />
|
||||
{this._renderCustomScreenshotInput()}
|
||||
</div>
|
||||
<TopSiteLink link={previewLink}
|
||||
defaultStyle={requestFailed}
|
||||
title={this.state.label} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="actions">
|
||||
<button className="cancel" type="button" onClick={this.onCancelButtonClick}>
|
||||
<FormattedMessage id="topsites_form_cancel_button" />
|
||||
</button>
|
||||
{previewMode ?
|
||||
<button className="done preview" type="submit" onClick={this.onPreviewButtonClick}>
|
||||
<FormattedMessage id="topsites_form_preview_button" />
|
||||
</button> :
|
||||
<button className="done" type="submit" onClick={this.onDoneButtonClick}>
|
||||
<FormattedMessage id={showAsAdd ? "topsites_form_add_button" : "topsites_form_save_button"} />
|
||||
</button>}
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TopSiteForm.defaultProps = {
|
||||
site: null,
|
||||
index: -1
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
|
||||
export class TopSiteFormInput extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {validationError: this.props.validationError};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onMount = this.onMount.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.shouldFocus && !this.props.shouldFocus) {
|
||||
this.input.focus();
|
||||
}
|
||||
if (nextProps.validationError && !this.props.validationError) {
|
||||
this.setState({validationError: true});
|
||||
}
|
||||
// If the component is in an error state but the value was cleared by the parent
|
||||
if (this.state.validationError && !nextProps.value) {
|
||||
this.setState({validationError: false});
|
||||
}
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
if (this.state.validationError) {
|
||||
this.setState({validationError: false});
|
||||
}
|
||||
this.props.onChange(ev);
|
||||
}
|
||||
|
||||
onMount(input) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
render() {
|
||||
const showClearButton = this.props.value && this.props.onClear;
|
||||
const {typeUrl} = this.props;
|
||||
const {validationError} = this.state;
|
||||
|
||||
return (<label><FormattedMessage id={this.props.titleId} />
|
||||
<div className={`field ${typeUrl ? "url" : ""}${validationError ? " invalid" : ""}`}>
|
||||
{this.props.loading ?
|
||||
<div className="loading-container"><div className="loading-animation" /></div> :
|
||||
showClearButton && <div className="icon icon-clear-input" onClick={this.props.onClear} />}
|
||||
<input type="text"
|
||||
value={this.props.value}
|
||||
ref={this.onMount}
|
||||
onChange={this.onChange}
|
||||
placeholder={this.props.intl.formatMessage({id: this.props.placeholderId})}
|
||||
autoFocus={this.props.shouldFocus}
|
||||
disabled={this.props.loading} />
|
||||
{validationError &&
|
||||
<aside className="error-tooltip">
|
||||
<FormattedMessage id={this.props.errorMessageId} />
|
||||
</aside>}
|
||||
</div>
|
||||
</label>);
|
||||
}
|
||||
}
|
||||
|
||||
TopSiteFormInput.defaultProps = {
|
||||
showClearButton: false,
|
||||
value: "",
|
||||
validationError: false
|
||||
};
|
|
@ -0,0 +1,143 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {MIN_CORNER_FAVICON_SIZE, MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE} from "./TopSitesConstants";
|
||||
import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
|
||||
import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
|
||||
import {connect} from "react-redux";
|
||||
import {injectIntl} from "react-intl";
|
||||
import React from "react";
|
||||
import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
|
||||
import {TopSiteForm} from "./TopSiteForm";
|
||||
import {TopSiteList} from "./TopSite";
|
||||
|
||||
function topSiteIconType(link) {
|
||||
if (link.customScreenshotURL) {
|
||||
return "custom_screenshot";
|
||||
}
|
||||
if (link.tippyTopIcon || link.faviconRef === "tippytop") {
|
||||
return "tippytop";
|
||||
}
|
||||
if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
|
||||
return "rich_icon";
|
||||
}
|
||||
if (link.screenshot && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) {
|
||||
return "screenshot_with_icon";
|
||||
}
|
||||
if (link.screenshot) {
|
||||
return "screenshot";
|
||||
}
|
||||
return "no_image";
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through TopSites and counts types of images.
|
||||
* @param acc Accumulator for reducer.
|
||||
* @param topsite Entry in TopSites.
|
||||
*/
|
||||
function countTopSitesIconsTypes(topSites) {
|
||||
const countTopSitesTypes = (acc, link) => {
|
||||
acc[topSiteIconType(link)]++;
|
||||
return acc;
|
||||
};
|
||||
|
||||
return topSites.reduce(countTopSitesTypes, {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
"rich_icon": 0,
|
||||
"no_image": 0
|
||||
});
|
||||
}
|
||||
|
||||
export class _TopSites extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onFormClose = this.onFormClose.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch session statistics about the quality of TopSites icons and pinned count.
|
||||
*/
|
||||
_dispatchTopSitesStats() {
|
||||
const topSites = this._getVisibleTopSites();
|
||||
const topSitesIconsStats = countTopSitesIconsTypes(topSites);
|
||||
const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
|
||||
// Dispatch telemetry event with the count of TopSites images types.
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {topsites_icon_stats: topSitesIconsStats, topsites_pinned: topSitesPinned}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the TopSites that are visible based on prefs and window width.
|
||||
*/
|
||||
_getVisibleTopSites() {
|
||||
// We hide 2 sites per row when not in the wide layout.
|
||||
let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
|
||||
// $break-point-widest = 1072px (from _variables.scss)
|
||||
if (!global.matchMedia(`(min-width: 1072px)`).matches) {
|
||||
sitesPerRow -= 2;
|
||||
}
|
||||
return this.props.TopSites.rows.slice(0, this.props.TopSitesRows * sitesPerRow);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._dispatchTopSitesStats();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._dispatchTopSitesStats();
|
||||
}
|
||||
|
||||
onFormClose() {
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
source: TOP_SITES_SOURCE,
|
||||
event: "TOP_SITES_EDIT_CLOSE"
|
||||
}));
|
||||
this.props.dispatch({type: at.TOP_SITES_CANCEL_EDIT});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {editForm} = props.TopSites;
|
||||
|
||||
return (<ComponentPerfTimer id="topsites" initialized={props.TopSites.initialized} dispatch={props.dispatch}>
|
||||
<CollapsibleSection
|
||||
className="top-sites"
|
||||
icon="topsites"
|
||||
id="topsites"
|
||||
title={{id: "header_top_sites"}}
|
||||
extraMenuOptions={["AddTopSite"]}
|
||||
showPrefName="feeds.topsites"
|
||||
eventSource={TOP_SITES_SOURCE}
|
||||
collapsed={props.TopSites.pref ? props.TopSites.pref.collapsed : undefined}
|
||||
isFirst={props.isFirst}
|
||||
isLast={props.isLast}
|
||||
dispatch={props.dispatch}>
|
||||
<TopSiteList TopSites={props.TopSites} TopSitesRows={props.TopSitesRows} dispatch={props.dispatch} intl={props.intl} topSiteIconType={topSiteIconType} />
|
||||
<div className="edit-topsites-wrapper">
|
||||
{editForm &&
|
||||
<div className="edit-topsites">
|
||||
<div className="modal-overlay" onClick={this.onFormClose} />
|
||||
<div className="modal">
|
||||
<TopSiteForm
|
||||
site={props.TopSites.rows[editForm.index]}
|
||||
onClose={this.onFormClose}
|
||||
dispatch={this.props.dispatch}
|
||||
intl={this.props.intl}
|
||||
{...editForm} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</ComponentPerfTimer>);
|
||||
}
|
||||
}
|
||||
|
||||
export const TopSites = connect(state => ({
|
||||
TopSites: state.TopSites,
|
||||
Prefs: state.Prefs,
|
||||
TopSitesRows: state.Prefs.values.topSitesRows
|
||||
}))(injectIntl(_TopSites));
|
|
@ -0,0 +1,7 @@
|
|||
export const TOP_SITES_SOURCE = "TOP_SITES";
|
||||
export const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator",
|
||||
"OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
|
||||
// minimum size necessary to show a rich icon instead of a screenshot
|
||||
export const MIN_RICH_FAVICON_SIZE = 96;
|
||||
// minimum size necessary to show any icon in the top left corner with a screenshot
|
||||
export const MIN_CORNER_FAVICON_SIZE = 16;
|
|
@ -0,0 +1,485 @@
|
|||
$top-sites-size: $grid-unit;
|
||||
$top-sites-border-radius: 6px;
|
||||
$top-sites-title-height: 30px;
|
||||
$top-sites-vertical-space: 8px;
|
||||
$screenshot-size: cover;
|
||||
$rich-icon-size: 96px;
|
||||
$default-icon-wrapper-size: 42px;
|
||||
$default-icon-size: 32px;
|
||||
$default-icon-offset: 6px;
|
||||
$half-base-gutter: $base-gutter / 2;
|
||||
|
||||
.top-sites {
|
||||
// Take back the margin from the bottom row of vertical spacing as well as the
|
||||
// extra whitespace below the title text as it's vertically centered.
|
||||
margin-bottom: $section-spacing - ($top-sites-vertical-space + $top-sites-title-height / 3);
|
||||
}
|
||||
|
||||
.top-sites-list {
|
||||
list-style: none;
|
||||
margin: 0 (-$half-base-gutter);
|
||||
padding: 0;
|
||||
|
||||
// Two columns
|
||||
@media (max-width: $break-point-small) {
|
||||
:nth-child(2n+1) {
|
||||
@include context-menu-open-middle;
|
||||
}
|
||||
|
||||
:nth-child(2n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
// Three columns
|
||||
@media (min-width: $break-point-small) and (max-width: $break-point-medium) {
|
||||
:nth-child(3n+2),
|
||||
:nth-child(3n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
// Four columns
|
||||
@media (min-width: $break-point-medium) and (max-width: $break-point-large) {
|
||||
:nth-child(4n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
@media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
|
||||
:nth-child(4n+3) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
// Six columns
|
||||
@media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
|
||||
:nth-child(6n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
@media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
|
||||
:nth-child(6n+5) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
// Eight columns
|
||||
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
|
||||
:nth-child(8n) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
@media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
|
||||
:nth-child(8n+7) {
|
||||
@include context-menu-open-left;
|
||||
}
|
||||
}
|
||||
|
||||
@media not all and (min-width: $break-point-widest) {
|
||||
.hide-for-narrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 $top-sites-vertical-space;
|
||||
}
|
||||
|
||||
&:not(.dnd-active) {
|
||||
.top-site-outer:-moz-any(.active, :focus, :hover) {
|
||||
.tile {
|
||||
@include fade-in;
|
||||
}
|
||||
|
||||
@include context-menu-button-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// container for drop zone
|
||||
.top-site-outer {
|
||||
padding: 0 $half-base-gutter;
|
||||
display: inline-block;
|
||||
|
||||
// container for context menu
|
||||
.top-site-inner {
|
||||
position: relative;
|
||||
|
||||
> a {
|
||||
color: inherit;
|
||||
display: block;
|
||||
outline: none;
|
||||
|
||||
&:-moz-any(.active, :focus) {
|
||||
.tile {
|
||||
@include fade-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include context-menu-button;
|
||||
|
||||
.tile { // sass-lint:disable-block property-sort-order
|
||||
border-radius: $top-sites-border-radius;
|
||||
box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow);
|
||||
height: $top-sites-size;
|
||||
position: relative;
|
||||
width: $top-sites-size;
|
||||
|
||||
// For letter fallback
|
||||
align-items: center;
|
||||
color: var(--newtab-text-secondary-color);
|
||||
display: flex;
|
||||
font-size: 32px;
|
||||
font-weight: 200;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: attr(data-fallback);
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
background-color: $white;
|
||||
background-position: top left;
|
||||
background-size: $screenshot-size;
|
||||
border-radius: $top-sites-border-radius;
|
||||
box-shadow: inset $inner-box-shadow;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: opacity 1s;
|
||||
width: 100%;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Some common styles for all icons (rich and default) in top sites
|
||||
.top-site-icon {
|
||||
background-color: var(--newtab-topsites-background-color);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: $top-sites-border-radius;
|
||||
box-shadow: var(--newtab-topsites-icon-shadow);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.rich-icon {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
offset-inline-start: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.default-icon { // sass-lint:disable block property-sort-order
|
||||
background-size: $default-icon-size;
|
||||
bottom: -$default-icon-offset;
|
||||
height: $default-icon-wrapper-size;
|
||||
offset-inline-end: -$default-icon-offset;
|
||||
width: $default-icon-wrapper-size;
|
||||
|
||||
// for corner letter fallback
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 20px;
|
||||
justify-content: center;
|
||||
|
||||
&[data-fallback]::before {
|
||||
content: attr(data-fallback);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--newtab-topsites-label-color);
|
||||
font: message-box;
|
||||
height: $top-sites-title-height;
|
||||
line-height: $top-sites-title-height;
|
||||
text-align: center;
|
||||
width: $top-sites-size;
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
fill: var(--newtab-icon-tertiary-color);
|
||||
offset-inline-start: 0;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
height: $top-sites-title-height;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
span {
|
||||
padding: 0 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
background-image: url('#{$image-path}glyph-edit-16.svg');
|
||||
}
|
||||
|
||||
&.placeholder {
|
||||
.tile {
|
||||
box-shadow: inset $inner-box-shadow;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragged {
|
||||
.tile {
|
||||
background: $grey-20;
|
||||
box-shadow: none;
|
||||
|
||||
*,
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-topsites-wrapper {
|
||||
.modal {
|
||||
box-shadow: $shadow-secondary;
|
||||
left: 0;
|
||||
margin: 0 auto;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 40px;
|
||||
width: $wrapper-default-width;
|
||||
|
||||
@media (min-width: $break-point-small) {
|
||||
width: $wrapper-max-width-small;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-medium) {
|
||||
width: $wrapper-max-width-medium;
|
||||
}
|
||||
|
||||
@media (min-width: $break-point-large) {
|
||||
width: $wrapper-max-width-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topsite-form {
|
||||
$form-width: 300px;
|
||||
$form-spacing: 32px;
|
||||
|
||||
.form-input-container {
|
||||
max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
|
||||
margin: 0 auto;
|
||||
padding: $form-spacing;
|
||||
|
||||
.top-site-outer {
|
||||
padding: 0;
|
||||
margin: 24px 0 0;
|
||||
margin-inline-start: $form-spacing;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-transform: none;
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.fields-and-preview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: $section-title-font-size;
|
||||
}
|
||||
|
||||
.form-wrapper {
|
||||
width: 100%;
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
|
||||
.icon-clear-input {
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
offset-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.url {
|
||||
input:dir(ltr) {
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
input:dir(rtl) {
|
||||
padding-left: 32px;
|
||||
|
||||
&:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.enable-custom-image-input {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-image-input-container {
|
||||
margin-top: 4px;
|
||||
|
||||
.loading-container {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
offset-inline-end: 8px;
|
||||
}
|
||||
|
||||
// This animation is derived from Firefox's tab loading animation
|
||||
// See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
|
||||
.loading-animation {
|
||||
@keyframes tab-throbber-animation {
|
||||
100% { transform: translateX(-960px); }
|
||||
}
|
||||
|
||||
@keyframes tab-throbber-animation-rtl {
|
||||
100% { transform: translateX(960px); }
|
||||
}
|
||||
|
||||
width: 960px;
|
||||
height: 16px;
|
||||
-moz-context-properties: fill;
|
||||
fill: $blue-50;
|
||||
background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
|
||||
animation: tab-throbber-animation 1.05s steps(60) infinite;
|
||||
|
||||
&:dir(rtl) {
|
||||
animation-name: tab-throbber-animation-rtl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&[type='text'] {
|
||||
background-color: var(--newtab-textbox-background-color);
|
||||
border: $input-border;
|
||||
margin: 8px 0;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
font-size: 15px;
|
||||
|
||||
&:focus {
|
||||
border: $input-border-active;
|
||||
box-shadow: var(--newtab-textbox-focus-boxshadow);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
border: $input-border;
|
||||
box-shadow: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invalid {
|
||||
input {
|
||||
&[type='text'] {
|
||||
border: $input-error-border;
|
||||
box-shadow: $input-error-boxshadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-tooltip {
|
||||
animation: fade-up-tt 450ms;
|
||||
background: $red-60;
|
||||
border-radius: 2px;
|
||||
color: $white;
|
||||
offset-inline-start: 3px;
|
||||
padding: 5px 12px;
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
z-index: 1;
|
||||
|
||||
// tooltip caret
|
||||
&::before {
|
||||
background: $red-60;
|
||||
bottom: -8px;
|
||||
content: '.';
|
||||
height: 16px;
|
||||
offset-inline-start: 12px;
|
||||
position: absolute;
|
||||
text-indent: -999px;
|
||||
top: -7px;
|
||||
transform: rotate(45deg);
|
||||
white-space: nowrap;
|
||||
width: 16px;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
margin-inline-start: 10px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $break-point-small) {
|
||||
.fields-and-preview {
|
||||
flex-direction: column;
|
||||
|
||||
.top-site-outer {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//used for tooltips below form element
|
||||
@keyframes fade-up-tt {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(15px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import {FormattedMessage} from "react-intl";
|
||||
import React from "react";
|
||||
|
||||
export class Topic extends React.PureComponent {
|
||||
render() {
|
||||
const {url, name} = this.props;
|
||||
return (<li><a key={name} className="topic-link" href={url}>{name}</a></li>);
|
||||
}
|
||||
}
|
||||
|
||||
export class Topics extends React.PureComponent {
|
||||
render() {
|
||||
const {topics, read_more_endpoint} = this.props;
|
||||
return (
|
||||
<div className="topic">
|
||||
<span><FormattedMessage id="pocket_read_more" /></span>
|
||||
<ul>{topics && topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)}</ul>
|
||||
|
||||
{read_more_endpoint && <a className="topic-read-more" href={read_more_endpoint}>
|
||||
<FormattedMessage id="pocket_read_even_more" />
|
||||
</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
.topic {
|
||||
color: var(--newtab-section-navigation-text-color);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: $topic-margin-top;
|
||||
|
||||
@media (min-width: $break-point-large) {
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@media (min-width: $break-point-large) {
|
||||
display: inline;
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ul li {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '•';
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-link {
|
||||
color: var(--newtab-link-secondary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.topic-read-more {
|
||||
color: var(--newtab-link-secondary-color);
|
||||
font-weight: bold;
|
||||
|
||||
@media (min-width: $break-point-large) {
|
||||
// This is floating to accomodate a very large number of topics and/or
|
||||
// very long topic names due to l10n.
|
||||
float: right;
|
||||
|
||||
&:dir(rtl) {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center;
|
||||
content: '';
|
||||
-moz-context-properties: fill;
|
||||
display: inline-block;
|
||||
fill: var(--newtab-link-secondary-color);
|
||||
height: 16px;
|
||||
margin-inline-start: 5px;
|
||||
vertical-align: top;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
&:dir(rtl)::after {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
// This is a clearfix to for the topics-read-more link which is floating and causes
|
||||
// some jank when we set overflow:hidden for the animation.
|
||||
&::after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export const IS_NEWTAB = global.document && global.document.documentURI === "about:newtab";
|
|
@ -0,0 +1,65 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {perfService as perfSvc} from "common/PerfService.jsm";
|
||||
|
||||
const VISIBLE = "visible";
|
||||
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||||
|
||||
export class DetectUserSessionStart {
|
||||
constructor(store, options = {}) {
|
||||
this._store = store;
|
||||
// Overrides for testing
|
||||
this.document = options.document || global.document;
|
||||
this._perfService = options.perfService || perfSvc;
|
||||
this._onVisibilityChange = this._onVisibilityChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* sendEventOrAddListener - Notify immediately if the page is already visible,
|
||||
* or else set up a listener for when visibility changes.
|
||||
* This is needed for accurate session tracking for telemetry,
|
||||
* because tabs are pre-loaded.
|
||||
*/
|
||||
sendEventOrAddListener() {
|
||||
if (this.document.visibilityState === VISIBLE) {
|
||||
// If the document is already visible, to the user, send a notification
|
||||
// immediately that a session has started.
|
||||
this._sendEvent();
|
||||
} else {
|
||||
// If the document is not visible, listen for when it does become visible.
|
||||
this.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* _sendEvent - Sends a message to the main process to indicate the current
|
||||
* tab is now visible to the user, includes the
|
||||
* visibility_event_rcvd_ts time in ms from the UNIX epoch.
|
||||
*/
|
||||
_sendEvent() {
|
||||
this._perfService.mark("visibility_event_rcvd_ts");
|
||||
|
||||
try {
|
||||
let visibility_event_rcvd_ts = this._perfService
|
||||
.getMostRecentAbsMarkStartByName("visibility_event_rcvd_ts");
|
||||
|
||||
this._store.dispatch(ac.AlsoToMain({
|
||||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {visibility_event_rcvd_ts}
|
||||
}));
|
||||
} catch (ex) {
|
||||
// If this failed, it's likely because the `privacy.resistFingerprinting`
|
||||
// pref is true. We should at least not blow up.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* _onVisibilityChange - If the visibility has changed to visible, sends a notification
|
||||
* and removes the event listener. This should only be called once per tab.
|
||||
*/
|
||||
_onVisibilityChange() {
|
||||
if (this.document.visibilityState === VISIBLE) {
|
||||
this._sendEvent();
|
||||
this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
/* eslint-env mozilla/frame-script */
|
||||
|
||||
import {actionCreators as ac, actionTypes as at, actionUtils as au} from "common/Actions.jsm";
|
||||
import {applyMiddleware, combineReducers, createStore} from "redux";
|
||||
|
||||
export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
|
||||
export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
|
||||
export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
|
||||
export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA, at.PAGE_PRERENDERED];
|
||||
|
||||
/**
|
||||
* A higher-order function which returns a reducer that, on MERGE_STORE action,
|
||||
* will return the action.data object merged into the previous state.
|
||||
*
|
||||
* For all other actions, it merely calls mainReducer.
|
||||
*
|
||||
* Because we want this to merge the entire state object, it's written as a
|
||||
* higher order function which takes the main reducer (itself often a call to
|
||||
* combineReducers) as a parameter.
|
||||
*
|
||||
* @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION
|
||||
* @return {function} a reducer that, on MERGE_STORE_ACTION action,
|
||||
* will return the action.data object merged
|
||||
* into the previous state, and the result
|
||||
* of calling mainReducer otherwise.
|
||||
*/
|
||||
function mergeStateReducer(mainReducer) {
|
||||
return (prevState, action) => {
|
||||
if (action.type === MERGE_STORE_ACTION) {
|
||||
return {...prevState, ...action.data};
|
||||
}
|
||||
|
||||
return mainReducer(prevState, action);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary
|
||||
*/
|
||||
const messageMiddleware = store => next => action => {
|
||||
const skipLocal = action.meta && action.meta.skipLocal;
|
||||
if (au.isSendToMain(action)) {
|
||||
sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
|
||||
}
|
||||
if (!skipLocal) {
|
||||
next(action);
|
||||
}
|
||||
};
|
||||
|
||||
export const rehydrationMiddleware = store => next => action => {
|
||||
if (store._didRehydrate) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const isMergeStoreAction = action.type === MERGE_STORE_ACTION;
|
||||
const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST;
|
||||
|
||||
if (isRehydrationRequest) {
|
||||
store._didRequestInitialState = true;
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (isMergeStoreAction) {
|
||||
store._didRehydrate = true;
|
||||
return next(action);
|
||||
}
|
||||
|
||||
// If init happened after our request was made, we need to re-request
|
||||
if (store._didRequestInitialState && action.type === at.INIT) {
|
||||
return next(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
|
||||
}
|
||||
|
||||
if (au.isBroadcastToContent(action) || au.isSendToOneContent(action) || au.isSendToPreloaded(action)) {
|
||||
// Note that actions received before didRehydrate will not be dispatched
|
||||
// because this could negatively affect preloading and the the state
|
||||
// will be replaced by rehydration anyway.
|
||||
return null;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
|
||||
/**
|
||||
* This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives
|
||||
* the first action from main. This is useful for those actions for main which
|
||||
* require higher reliability, i.e. the action will not be lost in the case
|
||||
* that it gets sent before the main is ready to receive it. Conversely, any
|
||||
* actions allowed early are accepted to be ignorable or re-sendable.
|
||||
*/
|
||||
export const queueEarlyMessageMiddleware = store => next => action => {
|
||||
if (store._receivedFromMain) {
|
||||
next(action);
|
||||
} else if (au.isFromMain(action)) {
|
||||
next(action);
|
||||
store._receivedFromMain = true;
|
||||
// Sending out all the early actions as main is ready now
|
||||
if (store._earlyActionQueue) {
|
||||
store._earlyActionQueue.forEach(next);
|
||||
store._earlyActionQueue = [];
|
||||
}
|
||||
} else if (EARLY_QUEUED_ACTIONS.includes(action.type)) {
|
||||
store._earlyActionQueue = store._earlyActionQueue || [];
|
||||
store._earlyActionQueue.push(action);
|
||||
} else {
|
||||
// Let any other type of action go through
|
||||
next(action);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* initStore - Create a store and listen for incoming actions
|
||||
*
|
||||
* @param {object} reducers An object containing Redux reducers
|
||||
* @param {object} intialState (optional) The initial state of the store, if desired
|
||||
* @return {object} A redux store
|
||||
*/
|
||||
export function initStore(reducers, initialState) {
|
||||
const store = createStore(
|
||||
mergeStateReducer(combineReducers(reducers)),
|
||||
initialState,
|
||||
global.addMessageListener && applyMiddleware(rehydrationMiddleware, queueEarlyMessageMiddleware, messageMiddleware)
|
||||
);
|
||||
|
||||
store._didRehydrate = false;
|
||||
store._didRequestInitialState = false;
|
||||
|
||||
if (global.addMessageListener) {
|
||||
global.addMessageListener(INCOMING_MESSAGE_NAME, msg => {
|
||||
try {
|
||||
store.dispatch(msg.data);
|
||||
} catch (ex) {
|
||||
console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
|
||||
dump(`Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ex.stack}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
|
@ -0,0 +1,215 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
|
||||
const _OpenInPrivateWindow = site => ({
|
||||
id: "menu_action_open_private_window",
|
||||
icon: "new-window-private",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.OPEN_PRIVATE_WINDOW,
|
||||
data: {url: site.url, referrer: site.referrer}
|
||||
}),
|
||||
userEvent: "OPEN_PRIVATE_WINDOW"
|
||||
});
|
||||
|
||||
export const GetPlatformString = platform => {
|
||||
switch (platform) {
|
||||
case "win":
|
||||
return "menu_action_show_file_windows";
|
||||
case "macosx":
|
||||
return "menu_action_show_file_mac_os";
|
||||
case "linux":
|
||||
return "menu_action_show_file_linux";
|
||||
default:
|
||||
return "menu_action_show_file_default";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List of functions that return items that can be included as menu options in a
|
||||
* LinkMenu. All functions take the site as the first parameter, and optionally
|
||||
* the index of the site.
|
||||
*/
|
||||
export const LinkMenuOptions = {
|
||||
Separator: () => ({type: "separator"}),
|
||||
EmptyItem: () => ({type: "empty"}),
|
||||
RemoveBookmark: site => ({
|
||||
id: "menu_action_remove_bookmark",
|
||||
icon: "bookmark-added",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.DELETE_BOOKMARK_BY_ID,
|
||||
data: site.bookmarkGuid
|
||||
}),
|
||||
userEvent: "BOOKMARK_DELETE"
|
||||
}),
|
||||
AddBookmark: site => ({
|
||||
id: "menu_action_bookmark",
|
||||
icon: "bookmark-hollow",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.BOOKMARK_URL,
|
||||
data: {url: site.url, title: site.title, type: site.type}
|
||||
}),
|
||||
userEvent: "BOOKMARK_ADD"
|
||||
}),
|
||||
OpenInNewWindow: site => ({
|
||||
id: "menu_action_open_new_window",
|
||||
icon: "new-window",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.OPEN_NEW_WINDOW,
|
||||
data: {
|
||||
referrer: site.referrer,
|
||||
typedBonus: site.typedBonus,
|
||||
url: site.url
|
||||
}
|
||||
}),
|
||||
userEvent: "OPEN_NEW_WINDOW"
|
||||
}),
|
||||
BlockUrl: (site, index, eventSource) => ({
|
||||
id: "menu_action_dismiss",
|
||||
icon: "dismiss",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.BLOCK_URL,
|
||||
data: {url: site.url, pocket_id: site.pocket_id}
|
||||
}),
|
||||
impression: ac.ImpressionStats({
|
||||
source: eventSource,
|
||||
block: 0,
|
||||
tiles: [{id: site.guid, pos: index}]
|
||||
}),
|
||||
userEvent: "BLOCK"
|
||||
}),
|
||||
|
||||
// This is an option for web extentions which will result in remove items from
|
||||
// memory and notify the web extenion, rather than using the built-in block list.
|
||||
WebExtDismiss: (site, index, eventSource) => ({
|
||||
id: "menu_action_webext_dismiss",
|
||||
string_id: "menu_action_dismiss",
|
||||
icon: "dismiss",
|
||||
action: ac.WebExtEvent(at.WEBEXT_DISMISS, {
|
||||
source: eventSource,
|
||||
url: site.url,
|
||||
action_position: index
|
||||
})
|
||||
}),
|
||||
DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
|
||||
id: "menu_action_delete",
|
||||
icon: "delete",
|
||||
action: {
|
||||
type: at.DIALOG_OPEN,
|
||||
data: {
|
||||
onConfirm: [
|
||||
ac.AlsoToMain({type: at.DELETE_HISTORY_URL, data: {url: site.url, pocket_id: site.pocket_id, forceBlock: site.bookmarkGuid}}),
|
||||
ac.UserEvent(Object.assign({event: "DELETE", source: eventSource, action_position: index}, siteInfo))
|
||||
],
|
||||
eventSource,
|
||||
body_string_id: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
|
||||
confirm_button_string_id: "menu_action_delete",
|
||||
cancel_button_string_id: "topsites_form_cancel_button",
|
||||
icon: "modal-delete"
|
||||
}
|
||||
},
|
||||
userEvent: "DIALOG_OPEN"
|
||||
}),
|
||||
ShowFile: (site, index, eventSource, isEnabled, siteInfo, platform) => ({
|
||||
id: GetPlatformString(platform),
|
||||
icon: "search",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.SHOW_DOWNLOAD_FILE,
|
||||
data: {url: site.url}
|
||||
})
|
||||
}),
|
||||
OpenFile: site => ({
|
||||
id: "menu_action_open_file",
|
||||
icon: "open-file",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.OPEN_DOWNLOAD_FILE,
|
||||
data: {url: site.url}
|
||||
})
|
||||
}),
|
||||
CopyDownloadLink: site => ({
|
||||
id: "menu_action_copy_download_link",
|
||||
icon: "copy",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.COPY_DOWNLOAD_LINK,
|
||||
data: {url: site.url}
|
||||
})
|
||||
}),
|
||||
GoToDownloadPage: site => ({
|
||||
id: "menu_action_go_to_download_page",
|
||||
icon: "download",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.OPEN_LINK,
|
||||
data: {url: site.referrer}
|
||||
}),
|
||||
disabled: !site.referrer
|
||||
}),
|
||||
RemoveDownload: site => ({
|
||||
id: "menu_action_remove_download",
|
||||
icon: "delete",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.REMOVE_DOWNLOAD_FILE,
|
||||
data: {url: site.url}
|
||||
})
|
||||
}),
|
||||
PinTopSite: (site, index) => ({
|
||||
id: "menu_action_pin",
|
||||
icon: "pin",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.TOP_SITES_PIN,
|
||||
data: {site: {url: site.url}, index}
|
||||
}),
|
||||
userEvent: "PIN"
|
||||
}),
|
||||
UnpinTopSite: site => ({
|
||||
id: "menu_action_unpin",
|
||||
icon: "unpin",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.TOP_SITES_UNPIN,
|
||||
data: {site: {url: site.url}}
|
||||
}),
|
||||
userEvent: "UNPIN"
|
||||
}),
|
||||
SaveToPocket: (site, index, eventSource) => ({
|
||||
id: "menu_action_save_to_pocket",
|
||||
icon: "pocket",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.SAVE_TO_POCKET,
|
||||
data: {site: {url: site.url, title: site.title}}
|
||||
}),
|
||||
impression: ac.ImpressionStats({
|
||||
source: eventSource,
|
||||
pocket: 0,
|
||||
tiles: [{id: site.guid, pos: index}]
|
||||
}),
|
||||
userEvent: "SAVE_TO_POCKET"
|
||||
}),
|
||||
DeleteFromPocket: site => ({
|
||||
id: "menu_action_delete_pocket",
|
||||
icon: "delete",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.DELETE_FROM_POCKET,
|
||||
data: {pocket_id: site.pocket_id}
|
||||
}),
|
||||
userEvent: "DELETE_FROM_POCKET"
|
||||
}),
|
||||
ArchiveFromPocket: site => ({
|
||||
id: "menu_action_archive_pocket",
|
||||
icon: "check",
|
||||
action: ac.AlsoToMain({
|
||||
type: at.ARCHIVE_FROM_POCKET,
|
||||
data: {pocket_id: site.pocket_id}
|
||||
}),
|
||||
userEvent: "ARCHIVE_FROM_POCKET"
|
||||
}),
|
||||
EditTopSite: (site, index) => ({
|
||||
id: "edit_topsites_button_text",
|
||||
icon: "edit",
|
||||
action: {
|
||||
type: at.TOP_SITES_EDIT,
|
||||
data: {index}
|
||||
}
|
||||
}),
|
||||
CheckBookmark: site => (site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site)),
|
||||
CheckPinTopSite: (site, index) => (site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index)),
|
||||
CheckSavedToPocket: (site, index) => (site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index)),
|
||||
CheckBookmarkOrArchive: site => (site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site)),
|
||||
OpenInPrivateWindow: (site, index, eventSource, isEnabled) => (isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem())
|
||||
};
|
|
@ -0,0 +1,74 @@
|
|||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
|
||||
/**
|
||||
* List of functions that return items that can be included as menu options in a
|
||||
* SectionMenu. All functions take the section as the only parameter.
|
||||
*/
|
||||
export const SectionMenuOptions = {
|
||||
Separator: () => ({type: "separator"}),
|
||||
MoveUp: section => ({
|
||||
id: "section_menu_action_move_up",
|
||||
icon: "arrowhead-up",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.SECTION_MOVE,
|
||||
data: {id: section.id, direction: -1}
|
||||
}),
|
||||
userEvent: "MENU_MOVE_UP",
|
||||
disabled: !!section.isFirst
|
||||
}),
|
||||
MoveDown: section => ({
|
||||
id: "section_menu_action_move_down",
|
||||
icon: "arrowhead-down",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.SECTION_MOVE,
|
||||
data: {id: section.id, direction: +1}
|
||||
}),
|
||||
userEvent: "MENU_MOVE_DOWN",
|
||||
disabled: !!section.isLast
|
||||
}),
|
||||
RemoveSection: section => ({
|
||||
id: "section_menu_action_remove_section",
|
||||
icon: "dismiss",
|
||||
action: ac.SetPref(section.showPrefName, false),
|
||||
userEvent: "MENU_REMOVE"
|
||||
}),
|
||||
CollapseSection: section => ({
|
||||
id: "section_menu_action_collapse_section",
|
||||
icon: "minimize",
|
||||
action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: true}}}),
|
||||
userEvent: "MENU_COLLAPSE"
|
||||
}),
|
||||
ExpandSection: section => ({
|
||||
id: "section_menu_action_expand_section",
|
||||
icon: "maximize",
|
||||
action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: false}}}),
|
||||
userEvent: "MENU_EXPAND"
|
||||
}),
|
||||
ManageSection: section => ({
|
||||
id: "section_menu_action_manage_section",
|
||||
icon: "settings",
|
||||
action: ac.OnlyToMain({type: at.SETTINGS_OPEN}),
|
||||
userEvent: "MENU_MANAGE"
|
||||
}),
|
||||
ManageWebExtension: section => ({
|
||||
id: "section_menu_action_manage_webext",
|
||||
icon: "settings",
|
||||
action: ac.OnlyToMain({type: at.OPEN_WEBEXT_SETTINGS, data: section.id})
|
||||
}),
|
||||
AddTopSite: section => ({
|
||||
id: "section_menu_action_add_topsite",
|
||||
icon: "add",
|
||||
action: {type: at.TOP_SITES_EDIT, data: {index: -1}},
|
||||
userEvent: "MENU_ADD_TOPSITE"
|
||||
}),
|
||||
PrivacyNotice: section => ({
|
||||
id: "section_menu_action_privacy_notice",
|
||||
icon: "info",
|
||||
action: ac.OnlyToMain({
|
||||
type: at.OPEN_LINK,
|
||||
data: {url: section.privacyNoticeURL}
|
||||
}),
|
||||
userEvent: "MENU_PRIVACY_NOTICE"
|
||||
}),
|
||||
CheckCollapsed: section => (section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section))
|
||||
};
|
|
@ -0,0 +1,427 @@
|
|||
const DATABASE_NAME = "snippets_db";
|
||||
const DATABASE_VERSION = 1;
|
||||
const SNIPPETS_OBJECTSTORE_NAME = "snippets";
|
||||
export const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
|
||||
|
||||
const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled";
|
||||
const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled";
|
||||
|
||||
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
|
||||
import {ASRouterContent} from "content-src/asrouter/asrouter-content";
|
||||
|
||||
/**
|
||||
* SnippetsMap - A utility for cacheing values related to the snippet. It has
|
||||
* the same interface as a Map, but is optionally backed by
|
||||
* indexedDB for persistent storage.
|
||||
* Call .connect() to open a database connection and restore any
|
||||
* previously cached data, if necessary.
|
||||
*
|
||||
*/
|
||||
export class SnippetsMap extends Map {
|
||||
constructor(dispatch) {
|
||||
super();
|
||||
this._db = null;
|
||||
this._dispatch = dispatch;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
super.set(key, value);
|
||||
return this._dbTransaction(db => db.put(value, key));
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
super.delete(key);
|
||||
return this._dbTransaction(db => db.delete(key));
|
||||
}
|
||||
|
||||
clear() {
|
||||
super.clear();
|
||||
this._dispatch(ac.OnlyToMain({type: at.SNIPPETS_BLOCKLIST_CLEARED}));
|
||||
return this._dbTransaction(db => db.clear());
|
||||
}
|
||||
|
||||
get blockList() {
|
||||
return this.get("blockList") || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* blockSnippetById - Blocks a snippet given an id
|
||||
*
|
||||
* @param {str|int} id The id of the snippet
|
||||
* @return {Promise} Resolves when the id has been written to indexedDB,
|
||||
* or immediately if the snippetMap is not connected
|
||||
*/
|
||||
async blockSnippetById(id) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const {blockList} = this;
|
||||
if (!blockList.includes(id)) {
|
||||
blockList.push(id);
|
||||
this._dispatch(ac.AlsoToMain({type: at.SNIPPETS_BLOCKLIST_UPDATED, data: id}));
|
||||
await this.set("blockList", blockList);
|
||||
}
|
||||
}
|
||||
|
||||
disableOnboarding() {
|
||||
this._dispatch(ac.AlsoToMain({type: at.DISABLE_ONBOARDING}));
|
||||
}
|
||||
|
||||
showFirefoxAccounts() {
|
||||
this._dispatch(ac.AlsoToMain({type: at.SHOW_FIREFOX_ACCOUNTS}));
|
||||
}
|
||||
|
||||
getTotalBookmarksCount() {
|
||||
return new Promise(resolve => {
|
||||
this._dispatch(ac.OnlyToMain({type: at.TOTAL_BOOKMARKS_REQUEST}));
|
||||
global.addMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
|
||||
if (action.type === at.TOTAL_BOOKMARKS_RESPONSE) {
|
||||
resolve(action.data);
|
||||
global.removeMessageListener("ActivityStream:MainToContent", onMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getAddonsInfo() {
|
||||
return new Promise(resolve => {
|
||||
this._dispatch(ac.OnlyToMain({type: at.ADDONS_INFO_REQUEST}));
|
||||
global.addMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
|
||||
if (action.type === at.ADDONS_INFO_RESPONSE) {
|
||||
resolve(action.data);
|
||||
global.removeMessageListener("ActivityStream:MainToContent", onMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* connect - Attaches an indexedDB back-end to the Map so that any set values
|
||||
* are also cached in a store. It also restores any existing values
|
||||
* that are already stored in the indexedDB store.
|
||||
*
|
||||
* @return {type} description
|
||||
*/
|
||||
async connect() {
|
||||
// Open the connection
|
||||
const db = await this._openDB();
|
||||
|
||||
// Restore any existing values
|
||||
await this._restoreFromDb(db);
|
||||
|
||||
// Attach a reference to the db
|
||||
this._db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* _dbTransaction - Returns a db transaction wrapped with the given modifier
|
||||
* function as a Promise. If the db has not been connected,
|
||||
* it resolves immediately.
|
||||
*
|
||||
* @param {func} modifier A function to call with the transaction
|
||||
* @return {obj} A Promise that resolves when the transaction has
|
||||
* completed or errored
|
||||
*/
|
||||
_dbTransaction(modifier) {
|
||||
if (!this._db) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = modifier(
|
||||
this._db
|
||||
.transaction(SNIPPETS_OBJECTSTORE_NAME, "readwrite")
|
||||
.objectStore(SNIPPETS_OBJECTSTORE_NAME)
|
||||
);
|
||||
transaction.onsuccess = event => resolve();
|
||||
|
||||
/* istanbul ignore next */
|
||||
transaction.onerror = event => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
_openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
|
||||
/* istanbul ignore next */
|
||||
openRequest.onerror = event => {
|
||||
// Try to delete the old database so that we can start this process over
|
||||
// next time.
|
||||
indexedDB.deleteDatabase(DATABASE_NAME);
|
||||
reject(event);
|
||||
};
|
||||
|
||||
openRequest.onupgradeneeded = event => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(SNIPPETS_OBJECTSTORE_NAME)) {
|
||||
db.createObjectStore(SNIPPETS_OBJECTSTORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
openRequest.onsuccess = event => {
|
||||
let db = event.target.result;
|
||||
|
||||
/* istanbul ignore next */
|
||||
db.onerror = err => console.error(err); // eslint-disable-line no-console
|
||||
/* istanbul ignore next */
|
||||
db.onversionchange = versionChangeEvent => versionChangeEvent.target.close();
|
||||
|
||||
resolve(db);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
_restoreFromDb(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cursorRequest;
|
||||
try {
|
||||
cursorRequest = db.transaction(SNIPPETS_OBJECTSTORE_NAME)
|
||||
.objectStore(SNIPPETS_OBJECTSTORE_NAME).openCursor();
|
||||
} catch (err) {
|
||||
// istanbul ignore next
|
||||
reject(err);
|
||||
// istanbul ignore next
|
||||
return;
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
cursorRequest.onerror = event => reject(event);
|
||||
|
||||
cursorRequest.onsuccess = event => {
|
||||
let cursor = event.target.result;
|
||||
// Populate the cache from the persistent storage.
|
||||
if (cursor) {
|
||||
if (cursor.value !== "blockList") {
|
||||
this.set(cursor.key, cursor.value);
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
// We are done.
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SnippetsProvider - Initializes a SnippetsMap and loads snippets from a
|
||||
* remote location, or else default snippets if the remote
|
||||
* snippets cannot be retrieved.
|
||||
*/
|
||||
export class SnippetsProvider {
|
||||
constructor(dispatch) {
|
||||
// Initialize the Snippets Map and attaches it to a global so that
|
||||
// the snippet payload can interact with it.
|
||||
global.gSnippetsMap = new SnippetsMap(dispatch);
|
||||
this._onAction = this._onAction.bind(this);
|
||||
}
|
||||
|
||||
get snippetsMap() {
|
||||
return global.gSnippetsMap;
|
||||
}
|
||||
|
||||
async _refreshSnippets() {
|
||||
// Check if the cached version of of the snippets in snippetsMap. If it's too
|
||||
// old, blow away the entire snippetsMap.
|
||||
const cachedVersion = this.snippetsMap.get("snippets-cached-version");
|
||||
|
||||
if (cachedVersion !== this.appData.version) {
|
||||
this.snippetsMap.clear();
|
||||
}
|
||||
|
||||
// Has enough time passed for us to require an update?
|
||||
const lastUpdate = this.snippetsMap.get("snippets-last-update");
|
||||
const needsUpdate = !(lastUpdate >= 0) || Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
|
||||
|
||||
if (needsUpdate && this.appData.snippetsURL) {
|
||||
this.snippetsMap.set("snippets-last-update", Date.now());
|
||||
try {
|
||||
const response = await fetch(this.appData.snippetsURL);
|
||||
if (response.status === 200) {
|
||||
const payload = await response.text();
|
||||
|
||||
this.snippetsMap.set("snippets", payload);
|
||||
this.snippetsMap.set("snippets-cached-version", this.appData.version);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_noSnippetFallback() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_forceOnboardingVisibility(shouldBeVisible) {
|
||||
const onboardingEl = document.getElementById("onboarding-notification-bar");
|
||||
|
||||
if (onboardingEl) {
|
||||
onboardingEl.style.display = shouldBeVisible ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
_showRemoteSnippets() {
|
||||
const snippetsEl = document.getElementById(this.elementId);
|
||||
const payload = this.snippetsMap.get("snippets");
|
||||
|
||||
if (!snippetsEl) {
|
||||
throw new Error(`No element was found with id '${this.elementId}'.`);
|
||||
}
|
||||
|
||||
// This could happen if fetching failed
|
||||
if (!payload) {
|
||||
throw new Error("No remote snippets were found in gSnippetsMap.");
|
||||
}
|
||||
|
||||
if (typeof payload !== "string") {
|
||||
throw new Error("Snippet payload was incorrectly formatted");
|
||||
}
|
||||
|
||||
// Note that injecting snippets can throw if they're invalid XML.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
snippetsEl.innerHTML = payload;
|
||||
|
||||
// Scripts injected by innerHTML are inactive, so we have to relocate them
|
||||
// through DOM manipulation to activate their contents.
|
||||
for (const scriptEl of snippetsEl.getElementsByTagName("script")) {
|
||||
const relocatedScript = document.createElement("script");
|
||||
relocatedScript.text = scriptEl.text;
|
||||
scriptEl.parentNode.replaceChild(relocatedScript, scriptEl);
|
||||
}
|
||||
}
|
||||
|
||||
_onAction(msg) {
|
||||
if (msg.data.type === at.SNIPPET_BLOCKED) {
|
||||
if (!this.snippetsMap.blockList.includes(msg.data.data)) {
|
||||
this.snippetsMap.set("blockList", this.snippetsMap.blockList.concat(msg.data.data));
|
||||
document.getElementById("snippets-container").style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* init - Fetch the snippet payload and show snippets
|
||||
*
|
||||
* @param {obj} options
|
||||
* @param {str} options.appData.snippetsURL The URL from which we fetch snippets
|
||||
* @param {int} options.appData.version The current snippets version
|
||||
* @param {str} options.elementId The id of the element in which to inject snippets
|
||||
* @param {bool} options.connect Should gSnippetsMap connect to indexedDB?
|
||||
*/
|
||||
async init(options) {
|
||||
Object.assign(this, {
|
||||
appData: {},
|
||||
elementId: "snippets",
|
||||
connect: true
|
||||
}, options);
|
||||
|
||||
// Add listener so we know when snippets are blocked on other pages
|
||||
if (global.addMessageListener) {
|
||||
global.addMessageListener("ActivityStream:MainToContent", this._onAction);
|
||||
}
|
||||
|
||||
// TODO: Requires enabling indexedDB on newtab
|
||||
// Restore the snippets map from indexedDB
|
||||
if (this.connect) {
|
||||
try {
|
||||
await this.snippetsMap.connect();
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
// Cache app data values so they can be accessible from gSnippetsMap
|
||||
for (const key of Object.keys(this.appData)) {
|
||||
if (key === "blockList") {
|
||||
this.snippetsMap.set("blockList", this.appData[key]);
|
||||
} else {
|
||||
this.snippetsMap.set(`appData.${key}`, this.appData[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh snippets, if enough time has passed.
|
||||
await this._refreshSnippets();
|
||||
|
||||
// Try showing remote snippets, falling back to defaults if necessary.
|
||||
try {
|
||||
this._showRemoteSnippets();
|
||||
} catch (e) {
|
||||
this._noSnippetFallback(e);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT));
|
||||
|
||||
this._forceOnboardingVisibility(true);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
uninit() {
|
||||
window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT));
|
||||
this._forceOnboardingVisibility(false);
|
||||
if (global.removeMessageListener) {
|
||||
global.removeMessageListener("ActivityStream:MainToContent", this._onAction);
|
||||
}
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addSnippetsSubscriber - Creates a SnippetsProvider that Initializes
|
||||
* when the store has received the appropriate
|
||||
* Snippet data.
|
||||
*
|
||||
* @param {obj} store The redux store
|
||||
* @return {obj} Returns the snippets instance, asrouterContent instance and unsubscribe function
|
||||
*/
|
||||
export function addSnippetsSubscriber(store) {
|
||||
const snippets = new SnippetsProvider(store.dispatch);
|
||||
const asrouterContent = new ASRouterContent();
|
||||
|
||||
let initializing = false;
|
||||
|
||||
store.subscribe(async () => {
|
||||
const state = store.getState();
|
||||
// state.Prefs.values["feeds.snippets"]: Should snippets be shown?
|
||||
// state.Snippets.initialized Is the snippets data initialized?
|
||||
// snippets.initialized: Is SnippetsProvider currently initialised?
|
||||
if (state.Prefs.values["feeds.snippets"] &&
|
||||
// If the message center experiment is enabled, don't show snippets
|
||||
!state.Prefs.values.asrouterExperimentEnabled &&
|
||||
!state.Prefs.values.disableSnippets &&
|
||||
state.Snippets.initialized &&
|
||||
!snippets.initialized &&
|
||||
// Don't call init multiple times
|
||||
!initializing &&
|
||||
location.href !== "about:welcome"
|
||||
) {
|
||||
initializing = true;
|
||||
await snippets.init({appData: state.Snippets});
|
||||
initializing = false;
|
||||
} else if (
|
||||
(state.Prefs.values["feeds.snippets"] === false ||
|
||||
state.Prefs.values.disableSnippets === true) &&
|
||||
snippets.initialized
|
||||
) {
|
||||
snippets.uninit();
|
||||
}
|
||||
|
||||
// Turn on AS Router snippets if the experiment is enabled and the snippets pref is on;
|
||||
// otherwise, turn it off.
|
||||
if (
|
||||
state.Prefs.values.asrouterExperimentEnabled &&
|
||||
state.Prefs.values["feeds.snippets"] &&
|
||||
!asrouterContent.initialized) {
|
||||
asrouterContent.init();
|
||||
} else if (
|
||||
(!state.Prefs.values.asrouterExperimentEnabled || !state.Prefs.values["feeds.snippets"]) &&
|
||||
asrouterContent.initialized
|
||||
) {
|
||||
asrouterContent.uninit();
|
||||
}
|
||||
});
|
||||
|
||||
// These values are returned for testing purposes
|
||||
return {snippets, asrouterContent};
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
@import './normalize';
|
||||
@import './variables';
|
||||
@import './theme';
|
||||
@import './icons';
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body,
|
||||
#root { // sass-lint:disable-line no-ids
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--newtab-background-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif;
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// For screen readers
|
||||
.sr-only {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.inner-border {
|
||||
border: $border-secondary;
|
||||
border-radius: $border-radius;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.show-on-init {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in;
|
||||
|
||||
&.on {
|
||||
animation: fadeIn 0.2s;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
border-top: $border-secondary;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
padding: 15px 25px 0;
|
||||
}
|
||||
|
||||
// Default button (grey)
|
||||
.button,
|
||||
.actions button {
|
||||
background-color: var(--newtab-button-secondary-color);
|
||||
border: $border-primary;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 30px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(.dismiss) {
|
||||
box-shadow: $shadow-primary;
|
||||
transition: box-shadow 150ms;
|
||||
}
|
||||
|
||||
&.dismiss {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Blue button
|
||||
&.primary,
|
||||
&.done {
|
||||
background-color: var(--newtab-button-primary-color);
|
||||
border: solid 1px var(--newtab-button-primary-color);
|
||||
color: $white;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&[type='text'],
|
||||
&[type='search'] {
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure snippets show up above other UI elements
|
||||
#snippets-container { // sass-lint:disable-line no-ids
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Components
|
||||
@import '../components/Base/Base';
|
||||
@import '../components/ErrorBoundary/ErrorBoundary';
|
||||
@import '../components/TopSites/TopSites';
|
||||
@import '../components/Sections/Sections';
|
||||
@import '../components/StartupOverlay/StartupOverlay';
|
||||
@import '../components/Topics/Topics';
|
||||
@import '../components/Search/Search';
|
||||
@import '../components/ContextMenu/ContextMenu';
|
||||
@import '../components/ConfirmDialog/ConfirmDialog';
|
||||
@import '../components/Card/Card';
|
||||
@import '../components/ManualMigration/ManualMigration';
|
||||
@import '../components/CollapsibleSection/CollapsibleSection';
|
||||
@import '../components/ASRouterAdmin/ASRouterAdmin';
|
||||
|
||||
// AS Router
|
||||
@import '../asrouter/components/Button/Button';
|
||||
@import '../asrouter/components/SnippetBase/SnippetBase';
|
||||
@import '../asrouter/components/ModalOverlay/ModalOverlay';
|
||||
@import '../asrouter/templates/SimpleSnippet/SimpleSnippet';
|
||||
@import '../asrouter/templates/OnboardingMessage/OnboardingMessage';
|
|
@ -0,0 +1,180 @@
|
|||
.icon {
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: $icon-size;
|
||||
-moz-context-properties: fill;
|
||||
display: inline-block;
|
||||
fill: var(--newtab-icon-primary-color);
|
||||
height: $icon-size;
|
||||
vertical-align: middle;
|
||||
width: $icon-size;
|
||||
|
||||
&.icon-spacer {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
&.icon-small-spacer {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
&.icon-bookmark-added {
|
||||
background-image: url('chrome://browser/skin/bookmark.svg');
|
||||
}
|
||||
|
||||
&.icon-bookmark-hollow {
|
||||
background-image: url('chrome://browser/skin/bookmark-hollow.svg');
|
||||
}
|
||||
|
||||
&.icon-clear-input {
|
||||
fill: var(--newtab-icon-secondary-color);
|
||||
background-image: url('#{$image-path}glyph-cancel-16.svg');
|
||||
}
|
||||
|
||||
&.icon-delete {
|
||||
background-image: url('#{$image-path}glyph-delete-16.svg');
|
||||
}
|
||||
|
||||
&.icon-search {
|
||||
background-image: url('chrome://browser/skin/search-glass.svg');
|
||||
}
|
||||
|
||||
&.icon-modal-delete {
|
||||
flex-shrink: 0;
|
||||
background-image: url('#{$image-path}glyph-modal-delete-32.svg');
|
||||
background-size: $larger-icon-size;
|
||||
height: $larger-icon-size;
|
||||
width: $larger-icon-size;
|
||||
}
|
||||
|
||||
&.icon-dismiss {
|
||||
background-image: url('#{$image-path}glyph-dismiss-16.svg');
|
||||
}
|
||||
|
||||
&.icon-info {
|
||||
background-image: url('#{$image-path}glyph-info-16.svg');
|
||||
}
|
||||
|
||||
&.icon-import {
|
||||
background-image: url('#{$image-path}glyph-import-16.svg');
|
||||
}
|
||||
|
||||
&.icon-new-window {
|
||||
@include flip-icon;
|
||||
background-image: url('#{$image-path}glyph-newWindow-16.svg');
|
||||
}
|
||||
|
||||
&.icon-new-window-private {
|
||||
background-image: url('chrome://browser/skin/privateBrowsing.svg');
|
||||
}
|
||||
|
||||
&.icon-settings {
|
||||
background-image: url('chrome://browser/skin/settings.svg');
|
||||
}
|
||||
|
||||
&.icon-pin {
|
||||
@include flip-icon;
|
||||
background-image: url('#{$image-path}glyph-pin-16.svg');
|
||||
}
|
||||
|
||||
&.icon-unpin {
|
||||
@include flip-icon;
|
||||
background-image: url('#{$image-path}glyph-unpin-16.svg');
|
||||
}
|
||||
|
||||
&.icon-edit {
|
||||
background-image: url('#{$image-path}glyph-edit-16.svg');
|
||||
}
|
||||
|
||||
&.icon-pocket {
|
||||
background-image: url('#{$image-path}glyph-pocket-16.svg');
|
||||
}
|
||||
|
||||
&.icon-history-item {
|
||||
background-image: url('chrome://browser/skin/history.svg');
|
||||
}
|
||||
|
||||
&.icon-trending {
|
||||
background-image: url('#{$image-path}glyph-trending-16.svg');
|
||||
transform: translateY(2px); // trending bolt is visually top heavy
|
||||
}
|
||||
|
||||
&.icon-now {
|
||||
background-image: url('chrome://browser/skin/history.svg');
|
||||
}
|
||||
|
||||
&.icon-topsites {
|
||||
background-image: url('#{$image-path}glyph-topsites-16.svg');
|
||||
}
|
||||
|
||||
&.icon-pin-small {
|
||||
@include flip-icon;
|
||||
background-image: url('#{$image-path}glyph-pin-12.svg');
|
||||
background-size: $smaller-icon-size;
|
||||
height: $smaller-icon-size;
|
||||
width: $smaller-icon-size;
|
||||
}
|
||||
|
||||
&.icon-check {
|
||||
background-image: url('chrome://browser/skin/check.svg');
|
||||
}
|
||||
|
||||
&.icon-download {
|
||||
background-image: url('chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar');
|
||||
}
|
||||
|
||||
&.icon-copy {
|
||||
background-image: url('chrome://browser/skin/edit-copy.svg');
|
||||
}
|
||||
|
||||
&.icon-open-file {
|
||||
background-image: url('#{$image-path}glyph-open-file-16.svg');
|
||||
}
|
||||
|
||||
&.icon-webextension {
|
||||
background-image: url('#{$image-path}glyph-webextension-16.svg');
|
||||
}
|
||||
|
||||
&.icon-highlights {
|
||||
background-image: url('#{$image-path}glyph-highlights-16.svg');
|
||||
}
|
||||
|
||||
&.icon-arrowhead-down {
|
||||
background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
|
||||
}
|
||||
|
||||
&.icon-arrowhead-down-small {
|
||||
background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
|
||||
background-size: $smaller-icon-size;
|
||||
height: $smaller-icon-size;
|
||||
width: $smaller-icon-size;
|
||||
}
|
||||
|
||||
&.icon-arrowhead-forward-small {
|
||||
background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
|
||||
background-size: $smaller-icon-size;
|
||||
height: $smaller-icon-size;
|
||||
transform: rotate(-90deg);
|
||||
width: $smaller-icon-size;
|
||||
|
||||
&:dir(rtl) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.icon-arrowhead-up {
|
||||
background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&.icon-add {
|
||||
background-image: url('#{$image-path}glyph-add-16.svg');
|
||||
}
|
||||
|
||||
&.icon-minimize {
|
||||
background-image: url('#{$image-path}glyph-minimize-16.svg');
|
||||
}
|
||||
|
||||
&.icon-maximize {
|
||||
background-image: url('#{$image-path}glyph-maximize-16.svg');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
*::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important; // sass-lint:disable-line no-important
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
@function textbox-shadow($color) {
|
||||
@return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3);
|
||||
}
|
||||
|
||||
@mixin textbox-focus($color) {
|
||||
--newtab-textbox-focus-color: $color;
|
||||
--newtab-textbox-focus-boxshadow: textbox-shadow($color);
|
||||
}
|
||||
|
||||
// scss variables related to the theme.
|
||||
$border-primary: 1px solid var(--newtab-border-primary-color);
|
||||
$border-secondary: 1px solid var(--newtab-border-secondary-color);
|
||||
$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color);
|
||||
$input-border: 1px solid var(--newtab-textbox-border);
|
||||
$input-border-active: 1px solid var(--newtab-textbox-focus-color);
|
||||
$input-error-border: 1px solid $red-60;
|
||||
$input-error-boxshadow: textbox-shadow($red-60);
|
||||
$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color);
|
||||
$shadow-secondary: 0 1px 4px 0 $grey-90-20;
|
||||
|
||||
// Default theme
|
||||
body {
|
||||
// General styles
|
||||
--newtab-background-color: $grey-10;
|
||||
--newtab-border-primary-color: $grey-40;
|
||||
--newtab-border-secondary-color: $grey-30;
|
||||
--newtab-button-primary-color: $blue-60;
|
||||
--newtab-button-secondary-color: inherit;
|
||||
--newtab-element-active-color: $grey-30-60;
|
||||
--newtab-element-hover-color: $grey-20;
|
||||
--newtab-icon-primary-color: $grey-90-80;
|
||||
--newtab-icon-secondary-color: $grey-90-60;
|
||||
--newtab-icon-tertiary-color: $grey-30;
|
||||
--newtab-inner-box-shadow-color: $black-10;
|
||||
--newtab-link-primary-color: $blue-60;
|
||||
--newtab-link-secondary-color: $teal-70;
|
||||
--newtab-text-conditional-color: $grey-60;
|
||||
--newtab-text-primary-color: $grey-90;
|
||||
--newtab-text-secondary-color: $grey-50;
|
||||
--newtab-textbox-background-color: $white;
|
||||
--newtab-textbox-border: $grey-90-20;
|
||||
@include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations
|
||||
|
||||
// Context menu
|
||||
--newtab-contextmenu-background-color: $grey-10;
|
||||
--newtab-contextmenu-button-color: $white;
|
||||
|
||||
// Modal + overlay
|
||||
--newtab-modal-color: $white;
|
||||
--newtab-overlay-color: $grey-20-80;
|
||||
|
||||
// Sections
|
||||
--newtab-section-header-text-color: $grey-50;
|
||||
--newtab-section-navigation-text-color: $grey-50;
|
||||
--newtab-section-active-contextmenu-color: $grey-90;
|
||||
|
||||
// Search
|
||||
--newtab-search-border-color: transparent;
|
||||
--newtab-search-dropdown-color: $white;
|
||||
--newtab-search-dropdown-header-color: $grey-10;
|
||||
--newtab-search-icon-color: $grey-90-40;
|
||||
|
||||
// Top Sites
|
||||
--newtab-topsites-background-color: $white;
|
||||
--newtab-topsites-icon-shadow: inset $inner-box-shadow;
|
||||
--newtab-topsites-label-color: inherit;
|
||||
|
||||
// Cards
|
||||
--newtab-card-active-outline-color: $grey-30;
|
||||
--newtab-card-background-color: $white;
|
||||
--newtab-card-hairline-color: $black-10;
|
||||
--newtab-card-shadow: 0 1px 4px 0 $grey-90-10;
|
||||
|
||||
// Snippets
|
||||
--newtab-snippets-background-color: $white;
|
||||
--newtab-snippets-hairline-color: transparent;
|
||||
}
|
||||
|
||||
// Dark theme
|
||||
.dark-theme {
|
||||
// General styles
|
||||
--newtab-background-color: $grey-80;
|
||||
--newtab-border-primary-color: $grey-10-80;
|
||||
--newtab-border-secondary-color: $grey-10-10;
|
||||
--newtab-button-primary-color: $blue-60;
|
||||
--newtab-button-secondary-color: $grey-70;
|
||||
--newtab-element-active-color: $grey-10-20;
|
||||
--newtab-element-hover-color: $grey-10-10;
|
||||
--newtab-icon-primary-color: $grey-10-80;
|
||||
--newtab-icon-secondary-color: $grey-10-40;
|
||||
--newtab-icon-tertiary-color: $grey-10-40;
|
||||
--newtab-inner-box-shadow-color: $grey-10-20;
|
||||
--newtab-link-primary-color: $blue-40;
|
||||
--newtab-link-secondary-color: $pocket-teal;
|
||||
--newtab-text-conditional-color: $grey-10;
|
||||
--newtab-text-primary-color: $grey-10;
|
||||
--newtab-text-secondary-color: $grey-10-80;
|
||||
--newtab-textbox-background-color: $grey-70;
|
||||
--newtab-textbox-border: $grey-10-20;
|
||||
@include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations
|
||||
|
||||
// Context menu
|
||||
--newtab-contextmenu-background-color: $grey-60;
|
||||
--newtab-contextmenu-button-color: $grey-80;
|
||||
|
||||
// Modal + overlay
|
||||
--newtab-modal-color: $grey-80;
|
||||
--newtab-overlay-color: $grey-90-80;
|
||||
|
||||
// Sections
|
||||
--newtab-section-header-text-color: $grey-10-80;
|
||||
--newtab-section-navigation-text-color: $grey-10-80;
|
||||
--newtab-section-active-contextmenu-color: $white;
|
||||
|
||||
// Search
|
||||
--newtab-search-border-color: $grey-10-20;
|
||||
--newtab-search-dropdown-color: $grey-70;
|
||||
--newtab-search-dropdown-header-color: $grey-60;
|
||||
--newtab-search-icon-color: $grey-10-60;
|
||||
|
||||
// Top Sites
|
||||
--newtab-topsites-background-color: $grey-70;
|
||||
--newtab-topsites-icon-shadow: none;
|
||||
--newtab-topsites-label-color: $grey-10-80;
|
||||
|
||||
// Cards
|
||||
--newtab-card-active-outline-color: $grey-60;
|
||||
--newtab-card-background-color: $grey-70;
|
||||
--newtab-card-hairline-color: $grey-10-10;
|
||||
--newtab-card-shadow: 0 1px 8px 0 $grey-90-20;
|
||||
|
||||
// Snippets
|
||||
--newtab-snippets-background-color: $grey-70;
|
||||
--newtab-snippets-hairline-color: $white-10;
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
// Photon colors from http://design.firefox.com/photon/visuals/color.html
|
||||
$blue-40: #45A1FF;
|
||||
$blue-50: #0A84FF;
|
||||
$blue-60: #0060DF;
|
||||
$blue-70: #003EAA;
|
||||
$blue-80: #002275;
|
||||
$grey-10: #F9F9FA;
|
||||
$grey-20: #EDEDF0;
|
||||
$grey-30: #D7D7DB;
|
||||
$grey-40: #B1B1B3;
|
||||
$grey-50: #737373;
|
||||
$grey-60: #4A4A4F;
|
||||
$grey-70: #38383D;
|
||||
$grey-80: #2A2A2E;
|
||||
$grey-90: #0C0C0D;
|
||||
$teal-70: #008EA4;
|
||||
$red-60: #D70022;
|
||||
$yellow-50: #FFE900;
|
||||
|
||||
// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity
|
||||
$grey-10-10: rgba($grey-10, 0.1);
|
||||
$grey-10-20: rgba($grey-10, 0.2);
|
||||
$grey-10-40: rgba($grey-10, 0.4);
|
||||
$grey-10-60: rgba($grey-10, 0.6);
|
||||
$grey-10-80: rgba($grey-10, 0.8);
|
||||
$grey-20-60: rgba($grey-20, 0.6);
|
||||
$grey-20-80: rgba($grey-20, 0.8);
|
||||
$grey-30-60: rgba($grey-30, 0.6);
|
||||
$grey-90-10: rgba($grey-90, 0.1);
|
||||
$grey-90-20: rgba($grey-90, 0.2);
|
||||
$grey-90-30: rgba($grey-90, 0.3);
|
||||
$grey-90-40: rgba($grey-90, 0.4);
|
||||
$grey-90-50: rgba($grey-90, 0.5);
|
||||
$grey-90-60: rgba($grey-90, 0.6);
|
||||
$grey-90-70: rgba($grey-90, 0.7);
|
||||
$grey-90-80: rgba($grey-90, 0.8);
|
||||
$grey-90-90: rgba($grey-90, 0.9);
|
||||
|
||||
$black: #000;
|
||||
$black-5: rgba($black, 0.05);
|
||||
$black-10: rgba($black, 0.1);
|
||||
$black-15: rgba($black, 0.15);
|
||||
$black-20: rgba($black, 0.2);
|
||||
$black-25: rgba($black, 0.25);
|
||||
$black-30: rgba($black, 0.3);
|
||||
|
||||
// Other colors
|
||||
$white: #FFF;
|
||||
$white-10: rgba($white, 0.1);
|
||||
$pocket-teal: #50BCB6;
|
||||
$bookmark-icon-fill: #0A84FF;
|
||||
$download-icon-fill: #12BC00;
|
||||
$history-icon-fill: #B1B1B3;
|
||||
$pocket-icon-fill: #D70022;
|
||||
|
||||
// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html
|
||||
$photon-easing: cubic-bezier(0.07, 0.95, 0, 1);
|
||||
|
||||
$border-radius: 3px;
|
||||
|
||||
// Grid related styles
|
||||
$base-gutter: 32px;
|
||||
$section-horizontal-padding: 25px;
|
||||
$section-vertical-padding: 10px;
|
||||
$section-spacing: 40px - $section-vertical-padding * 2;
|
||||
$grid-unit: 96px; // 1 top site
|
||||
|
||||
$icon-size: 16px;
|
||||
$smaller-icon-size: 12px;
|
||||
$larger-icon-size: 32px;
|
||||
|
||||
$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites
|
||||
$wrapper-max-width-small: $grid-unit * 3 + $base-gutter * 2 + $section-horizontal-padding * 2; // 3 top sites
|
||||
$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites
|
||||
$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
|
||||
$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
|
||||
// For the breakpoints, we need to add space for the scrollbar to avoid weird
|
||||
// layout issues when the scrollbar is visible. 16px is wide enough to cover all
|
||||
// OSes and keeps it simpler than a per-OS value.
|
||||
$scrollbar-width: 16px;
|
||||
$break-point-small: $wrapper-max-width-small + $base-gutter * 2 + $scrollbar-width;
|
||||
$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width;
|
||||
$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width;
|
||||
$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width;
|
||||
|
||||
$section-title-font-size: 13px;
|
||||
|
||||
$card-width: $grid-unit * 2 + $base-gutter;
|
||||
$card-height: 266px;
|
||||
$card-preview-image-height: 122px;
|
||||
$card-title-margin: 2px;
|
||||
$card-text-line-height: 19px;
|
||||
// Larger cards for wider screens:
|
||||
$card-width-large: 309px;
|
||||
$card-height-large: 370px;
|
||||
$card-preview-image-height-large: 155px;
|
||||
// Compact cards for Highlights
|
||||
$card-height-compact: 160px;
|
||||
$card-preview-image-height-compact: 108px;
|
||||
|
||||
$topic-margin-top: 12px;
|
||||
|
||||
$context-menu-button-size: 27px;
|
||||
$context-menu-button-boxshadow: 0 2px $grey-90-10;
|
||||
$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20;
|
||||
$context-menu-font-size: 14px;
|
||||
$context-menu-border-radius: 5px;
|
||||
$context-menu-outer-padding: 5px;
|
||||
$context-menu-item-padding: 3px 12px;
|
||||
|
||||
$error-fallback-font-size: 12px;
|
||||
$error-fallback-line-height: 1.5;
|
||||
|
||||
$image-path: '../data/content/assets/';
|
||||
|
||||
$snippets-container-height: 120px;
|
||||
|
||||
$textbox-shadow-size: 4px;
|
||||
|
||||
@mixin fade-in {
|
||||
box-shadow: inset $inner-box-shadow, $shadow-primary;
|
||||
transition: box-shadow 150ms;
|
||||
}
|
||||
|
||||
@mixin fade-in-card {
|
||||
box-shadow: $shadow-primary;
|
||||
transition: box-shadow 150ms;
|
||||
}
|
||||
|
||||
@mixin context-menu-button {
|
||||
.context-menu-button {
|
||||
background-clip: padding-box;
|
||||
background-color: var(--newtab-contextmenu-button-color);
|
||||
background-image: url('chrome://browser/skin/page-action.svg');
|
||||
background-position: 55%;
|
||||
border: $border-primary;
|
||||
border-radius: 100%;
|
||||
box-shadow: $context-menu-button-boxshadow;
|
||||
cursor: pointer;
|
||||
fill: var(--newtab-icon-primary-color);
|
||||
height: $context-menu-button-size;
|
||||
offset-inline-end: -($context-menu-button-size / 2);
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: -($context-menu-button-size / 2);
|
||||
transform: scale(0.25);
|
||||
transition-duration: 200ms;
|
||||
transition-property: transform, opacity;
|
||||
width: $context-menu-button-size;
|
||||
|
||||
&:-moz-any(:active, :focus) {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin context-menu-button-hover {
|
||||
.context-menu-button {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin context-menu-open-middle {
|
||||
.context-menu {
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
offset-inline-end: auto;
|
||||
offset-inline-start: -$base-gutter;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin context-menu-open-left {
|
||||
.context-menu {
|
||||
margin-inline-end: 5px;
|
||||
margin-inline-start: auto;
|
||||
offset-inline-end: 0;
|
||||
offset-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flip-icon {
|
||||
&:dir(rtl) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
/* This is the linux variant */ // sass-lint:disable-line no-css-comments
|
||||
|
||||
$os-infopanel-arrow-height: 10px;
|
||||
$os-infopanel-arrow-offset-end: 6px;
|
||||
$os-infopanel-arrow-width: 20px;
|
||||
|
||||
@import './activity-stream';
|
|
@ -0,0 +1,11 @@
|
|||
/* This is the mac variant */ // sass-lint:disable-line no-css-comments
|
||||
|
||||
$os-infopanel-arrow-height: 10px;
|
||||
$os-infopanel-arrow-offset-end: 7px;
|
||||
$os-infopanel-arrow-width: 18px;
|
||||
|
||||
.dark-theme {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@import './activity-stream';
|
|
@ -0,0 +1,7 @@
|
|||
/* This is the windows variant */ // sass-lint:disable-line no-css-comments
|
||||
|
||||
$os-infopanel-arrow-height: 10px;
|
||||
$os-infopanel-arrow-offset-end: 6px;
|
||||
$os-infopanel-arrow-width: 20px;
|
||||
|
||||
@import './activity-stream';
|
|
@ -0,0 +1,138 @@
|
|||
# Contributing to Activity Stream
|
||||
|
||||
Activity Stream is an enhancement to the functionality of Firefox's about:newtab page. We welcome new 'streamers' to contribute to the project!
|
||||
|
||||
## Where to ask questions
|
||||
|
||||
- Most of the core dev team can be found on the `#activity-stream` channel on `irc.mozilla.org`.
|
||||
You can also direct message the core team (`dmose`, `emtwo`, `jkerim`, `k88hudson`, `Mardak`, `nanj`, `r1cky`, `ursula`, `andreio`)
|
||||
or our manager (`tspurway`)
|
||||
- Slack channel (staff only): #activitystream
|
||||
- Mailing List: [activity-stream-dev](https://groups.google.com/a/mozilla.com/d/forum/activity-stream-dev)
|
||||
- File issues/questions on Github: https://github.com/mozilla/activity-stream/issues. We typically triage new issues every Monday.
|
||||
|
||||
## Architecture ##
|
||||
|
||||
Activity Stream is a Firefox system add-on. One of the cool things about Activity Stream is that the
|
||||
[content side of the add-on](https://developer.mozilla.org/en-US/Add-ons/SDK/Guides/Content_Scripts)
|
||||
is written using [ReactJS](https://facebook.github.io/react/). This makes it an awesome project for React hackers to contribute to!
|
||||
|
||||
## Finding Bugs, Filing Tickets, Earning Karma ##
|
||||
|
||||
Activity Stream lives on [GitHub](https://github.com/mozilla/activity-stream), but you already knew that! If you've found
|
||||
a bug, or have a feature idea that you you'd like to see in Activity Stream, follow these simple guidelines:
|
||||
- Pick a thoughtful and terse title for the issue (ie. *not* Thing Doesn't Work!)
|
||||
- Make sure to mention your Firefox version, OS and basic system parameters (eg. Firefox 49.0, Windows XP, 512KB RAM)
|
||||
- If you can reproduce the bug, give a step-by-step recipe
|
||||
- Include [stack traces from the console(s)](https://developer.mozilla.org/en-US/docs/Mozilla/Debugging/Debugging_JavaScript) where appropriate
|
||||
- Screenshots welcome!
|
||||
- When in doubt, take a look at some [existing issues](https://github.com/mozilla/activity-stream/issues) and emulate
|
||||
|
||||
## Take a Ticket, Hack some Code ##
|
||||
|
||||
If you are new to the repo, you might want to pay close attention to [`Good first bug`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+first+bug%22),
|
||||
[`Bug`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen%20is%3Aissue%20label%3ABug%20),
|
||||
[`Chore`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3AChore) and
|
||||
[`Polish`](https://github.com/mozilla/activity-stream/issues?q=is%3Aopen+is%3Aissue+label%3APolish) tags, as these are
|
||||
typically a great way to get started. You might see a bug that is not yet assigned to anyone, or start a conversation with
|
||||
an engineer in the ticket itself, expressing your interest in taking the bug. If you take the bug, someone will set
|
||||
the ticket to [`Assigned to Contributor`](https://github.com/mozilla/activity-stream/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Assigned%20to%20contributor%22%20), which is a way we can be pro-active about helping you succeed in fixing the bug.
|
||||
|
||||
When you have some code written, you can open up a [Pull Request](#pull-requests), get your code [reviewed](#code-reviews), and see your code merged into the Activity Stream codebase.
|
||||
|
||||
If you are thinking about contributing on a longer-term basis, check out the section on [milestones](#milestones) and [priorities](#priorities)
|
||||
to get a sense of how we plan and prioritize work.
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
Check out [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) on how to install dependencies, get set up, and run tests.
|
||||
|
||||
## Pull Requests ##
|
||||
|
||||
You have identified the bug, written code and now want to get it into the main repo using a [Pull Request](https://help.github.com/articles/about-pull-requests/).
|
||||
|
||||
All code is added using a pull request against the `master` branch of our repo. Before submitting a PR, please go through this checklist:
|
||||
- all [unit tests](#unit-tests) must pass
|
||||
- if you haven't written unit tests for your patch, eyebrows will be curmudgeonly furrowed (write unit tests!)
|
||||
- if your pull request fixes a particular ticket (it does, right?), please use the `fixes #nnn` github annotation to indicate this
|
||||
- please add a `PR / Needs review` tag to your PR (if you have permission). This starts the code review process. If you cannot add a tag, don't worry, we will add it during triage.
|
||||
- if you can pick a module owner to be your reviewer by including `r? @username` in the comment (if not, don't worry, we will assign a reviewer)
|
||||
- make sure your PR will merge gracefully with `master` at the time you create the PR, and that your commit history is 'clean'
|
||||
|
||||
## Code Reviews ##
|
||||
|
||||
You have created a PR and submitted it to the repo, and now are waiting patiently for you code review feedback. One of the projects
|
||||
module owners will be along and will either:
|
||||
- make suggestions for some improvements
|
||||
- give you an `R+` in the comments section, indicating the review is done and the code can be merged
|
||||
|
||||
Typically, you will iterate on the PR, making changes and pushing your changes to new commits on the PR. When the reviewer is
|
||||
satisfied that your code is good-to-go, you will get the coveted `R+` comment, and your code can be merged. If you have
|
||||
commit permission, you can go ahead and merge the code to `master`, otherwise, it will be done for you.
|
||||
|
||||
Our project prides itself on it's respectful, patient and positive attitude when it comes to reviewing contributor's code, and as such,
|
||||
we expect contributors to be respectful, patient and positive in their communications as well. Let's be friends and learn
|
||||
from each other for a free and awesome web!
|
||||
|
||||
[Mozilla Committing Rules and Responsibilities](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Committing_Rules_and_Responsibilities)
|
||||
|
||||
## Git Commit Guidelines ##
|
||||
|
||||
We loosely follow the [Angular commit guidelines](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#type) of `<type>(<scope>): <subject>` where `type` must be one of:
|
||||
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **docs**: Documentation only changes
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
semi-colons, etc)
|
||||
* **refactor**: A code change that neither fixes a bug or adds a feature
|
||||
* **perf**: A code change that improves performance
|
||||
* **test**: Adding missing tests
|
||||
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
|
||||
generation
|
||||
|
||||
### Scope
|
||||
The scope could be anything specifying place of the commit change. For example `timeline`,
|
||||
`metadata`, `reporting`, `experiments` etc...
|
||||
|
||||
### Subject
|
||||
The subject contains succinct description of the change:
|
||||
|
||||
* use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
* don't capitalize first letter
|
||||
* no dot (.) at the end
|
||||
|
||||
### Body
|
||||
In order to maintain a reference to the context of the commit, add
|
||||
`fixes #<issue_number>` if it closes a related issue or `issue #<issue_number>`
|
||||
if it's a partial fix.
|
||||
|
||||
You can also write a detailed description of the commit:
|
||||
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
It should include the motivation for the change and contrast this with previous behavior.
|
||||
|
||||
###Footer
|
||||
The footer should contain any information about **Breaking Changes** and is also the place to
|
||||
reference GitHub issues that this commit **Closes**.
|
||||
|
||||
## Milestones ##
|
||||
|
||||
All work on Activity Stream is broken into two week iterations, which we map into a GitHub [Milestone](https://github.com/mozilla/activity-stream/milestones). At the beginning of the iteration, we prioritize and estimate tickets
|
||||
into the milestone, attempting to figure out how much progress we can make during the iteration.
|
||||
|
||||
## Priorities ##
|
||||
|
||||
All tickets that have been [triaged](#triage) will have a priority tag of either `P1`, `P2`, `P3`, or `P4` which are highest to lowest
|
||||
priorities of tickets in Activity Stream. We love ticket tags and you might also see `Blocked`, `Critical` or `Chemspill` tags, which
|
||||
indicate our level of anxiety about that particular ticket.
|
||||
|
||||
## Triage ##
|
||||
|
||||
The project team meets weekly (in a closed meeting, for the time being), to discuss project priorities, to triage new tickets, and to
|
||||
redistribute the work amongst team members. Any contributors tickets or PRs are carefully considered, prioritized, and if needed,
|
||||
assigned a reviewer. The project's GitHub [Milestone page](https://github.com/mozilla/activity-stream/milestones) is the best
|
||||
place to look for up-to-date information on project priorities and current workload.
|
||||
|
||||
## License
|
||||
|
||||
MPL 2.0
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче