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:
Ciure Andrei 2018-06-08 00:56:15 +03:00
Родитель 9aae925042 05605d68c4
Коммит 83c5f0bd74
367 изменённых файлов: 46522 добавлений и 2129 удалений

Просмотреть файл

@ -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

Просмотреть файл

@ -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 Mozillas [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

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше