Bug 1436218 - Export Screenshots 29.0.0 to Firefox (code excluding translations); r=ianb draft
authorJared Hirsch <ohai@6a68.net>
Thu, 08 Feb 2018 15:54:36 -0800
changeset 752793 f0b6e0a3d56e34c056f9324be0523539540ee0f8
parent 752792 b8d3d6835eebf85cdd040c5b2c12163079a48dff
push id98390
push userbmo:jhirsch@mozilla.com
push dateThu, 08 Feb 2018 23:57:17 +0000
reviewersianb
bugs1436218
milestone60.0a1
Bug 1436218 - Export Screenshots 29.0.0 to Firefox (code excluding translations); r=ianb MozReview-Commit-ID: 8ME4zCgdd89
browser/extensions/screenshots/bootstrap.js
browser/extensions/screenshots/install.rdf
browser/extensions/screenshots/moz.build
browser/extensions/screenshots/webextension/background/analytics.js
browser/extensions/screenshots/webextension/background/auth.js
browser/extensions/screenshots/webextension/background/communication.js
browser/extensions/screenshots/webextension/background/main.js
browser/extensions/screenshots/webextension/background/selectorLoader.js
browser/extensions/screenshots/webextension/background/senderror.js
browser/extensions/screenshots/webextension/background/startBackground.js
browser/extensions/screenshots/webextension/background/takeshot.js
browser/extensions/screenshots/webextension/build/inlineSelectionCss.js
browser/extensions/screenshots/webextension/build/shot.js
browser/extensions/screenshots/webextension/build/thumbnailGenerator.js
browser/extensions/screenshots/webextension/manifest.json
browser/extensions/screenshots/webextension/onboarding/slides.js
browser/extensions/screenshots/webextension/selector/shooter.js
browser/extensions/screenshots/webextension/selector/ui.js
browser/extensions/screenshots/webextension/selector/uicontrol.js
browser/extensions/screenshots/webextension/sitehelper.js
--- a/browser/extensions/screenshots/bootstrap.js
+++ b/browser/extensions/screenshots/bootstrap.js
@@ -1,15 +1,14 @@
 /* globals ADDON_DISABLE */
-const OLD_ADDON_PREF_NAME = "extensions.jid1-NeEaf3sAHdKHPA@jetpack.deviceIdInfo";
-const OLD_ADDON_ID = "jid1-NeEaf3sAHdKHPA@jetpack";
 const ADDON_ID = "screenshots@mozilla.org";
 const TELEMETRY_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
 const PREF_BRANCH = "extensions.screenshots.";
 const USER_DISABLE_PREF = "extensions.screenshots.disabled";
+const UPLOAD_DISABLED_PREF = "extensions.screenshots.upload-disabled";
 const HISTORY_ENABLED_PREF = "places.history.enabled";
 
 const { interfaces: Ci, utils: Cu } = Components;
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
 ChromeUtils.defineModuleGetter(this, "AppConstants",
                                "resource://gre/modules/AppConstants.jsm");
@@ -205,43 +204,35 @@ function stop(webExtension, reason) {
   return Promise.resolve(webExtension.shutdown(reason));
 }
 
 function handleMessage(msg, sender, sendReply) {
   if (!msg) {
     return;
   }
 
-  if (msg.funcName === "getTelemetryPref") {
+  if (msg.funcName === "isTelemetryEnabled") {
     let telemetryEnabled = getBoolPref(TELEMETRY_ENABLED_PREF);
     sendReply({type: "success", value: telemetryEnabled});
-  } else if (msg.funcName === "getOldDeviceInfo") {
-    let oldDeviceInfo = prefs.prefHasUserValue(OLD_ADDON_PREF_NAME) && prefs.getCharPref(OLD_ADDON_PREF_NAME);
-    sendReply({type: "success", value: oldDeviceInfo || null});
-  } else if (msg.funcName === "removeOldAddon") {
-    AddonManager.getAddonByID(OLD_ADDON_ID, (addon) => {
-      prefs.clearUserPref(OLD_ADDON_PREF_NAME);
-      if (addon) {
-        addon.uninstall();
-      }
-      sendReply({type: "success", value: !!addon});
-    });
-    return true;
-  } else if (msg.funcName === "getHistoryPref") {
+  } else if (msg.funcName === "isUploadDisabled") {
+    let isESR = AppConstants.MOZ_UPDATE_CHANNEL === 'esr';
+    let uploadDisabled = getBoolPref(UPLOAD_DISABLED_PREF);
+    sendReply({type: "success", value: uploadDisabled || isESR});
+  } else if (msg.funcName === "isHistoryEnabled") {
     let historyEnabled = getBoolPref(HISTORY_ENABLED_PREF);
     sendReply({type: "success", value: historyEnabled});
-  } else if (msg.funcName === "incrementDownloadCount") {
-    Services.telemetry.scalarAdd('screenshots.download', 1);
-    sendReply({type: "success", value: true});
-  } else if (msg.funcName === "incrementUploadCount") {
-    Services.telemetry.scalarAdd('screenshots.upload', 1);
-    sendReply({type: "success", value: true});
-  } else if (msg.funcName === "incrementCopyCount") {
-    Services.telemetry.scalarAdd('screenshots.copy', 1);
-    sendReply({type: "success", value: true});
+  } else if (msg.funcName === "incrementCount") {
+    let allowedScalars = ["download", "upload", "copy"];
+    let scalar = msg.args && msg.args[0] && msg.args[0].scalar;
+    if (!allowedScalars.includes(scalar)) {
+      sendReply({type: "error", name: `incrementCount passed an unrecognized scalar ${scalar}`});
+    } else {
+      Services.telemetry.scalarAdd(`screenshots.${scalar}`, 1);
+      sendReply({type: "success", value: true});
+    }
   }
 }
 
 let photonPageAction;
 
 // If the current Firefox version supports Photon (57 and later), this sets up
 // a Photon page action and removes the UI for the WebExtension browser action.
 // Does nothing otherwise.  Ideally, in the future, WebExtension page actions
--- a/browser/extensions/screenshots/install.rdf
+++ b/browser/extensions/screenshots/install.rdf
@@ -7,14 +7,14 @@
     <em:targetApplication>
       <Description>
         <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> <!--Firefox-->
         <em:minVersion>57.0a1</em:minVersion>
         <em:maxVersion>*</em:maxVersion>
       </Description>
     </em:targetApplication>
     <em:type>2</em:type>
-    <em:version>25.0.0</em:version>
+    <em:version>29.0.0</em:version>
     <em:bootstrap>true</em:bootstrap>
     <em:homepageURL>https://screenshots.firefox.com/</em:homepageURL>
     <em:multiprocessCompatible>true</em:multiprocessCompatible>
   </Description>
 </RDF>
--- a/browser/extensions/screenshots/moz.build
+++ b/browser/extensions/screenshots/moz.build
@@ -374,17 +374,18 @@ FINAL_TARGET_FILES.features['screenshots
 ]
 
 FINAL_TARGET_FILES.features['screenshots@mozilla.org']["webextension"]["build"] += [
   'webextension/build/buildSettings.js',
   'webextension/build/inlineSelectionCss.js',
   'webextension/build/onboardingCss.js',
   'webextension/build/onboardingHtml.js',
   'webextension/build/raven.js',
-  'webextension/build/shot.js'
+  'webextension/build/shot.js',
+  'webextension/build/thumbnailGenerator.js'
 ]
 
 FINAL_TARGET_FILES.features['screenshots@mozilla.org']["webextension"]["icons"] += [
   'webextension/icons/back-highlight.svg',
   'webextension/icons/back.svg',
   'webextension/icons/cancel.svg',
   'webextension/icons/cloud.svg',
   'webextension/icons/copy.png',
--- a/browser/extensions/screenshots/webextension/background/analytics.js
+++ b/browser/extensions/screenshots/webextension/background/analytics.js
@@ -1,124 +1,160 @@
 /* globals main, auth, catcher, deviceInfo, communication, log */
 
 "use strict";
 
 this.analytics = (function() {
   let exports = {};
 
   let telemetryPrefKnown = false;
-  let telemetryPref;
+  let telemetryEnabled;
+
+  const EVENT_BATCH_DURATION = 1000; // ms for setTimeout
+  let pendingEvents = [];
+  let pendingTimings = [];
+  let eventsTimeoutHandle, timingsTimeoutHandle;
+  const fetchOptions = {
+    method: "POST",
+    mode: "cors",
+    headers: { "content-type": "application/json" },
+    credentials: "include"
+  };
+
+  function flushEvents() {
+    if (pendingEvents.length === 0) {
+      return;
+    }
+
+    let eventsUrl = `${main.getBackend()}/event`;
+    let deviceId = auth.getDeviceId();
+    let sendTime = Date.now();
+
+    pendingEvents.forEach(event => {
+      event.queueTime = sendTime - event.eventTime
+      log.info(`sendEvent ${event.event}/${event.action}/${event.label || 'none'} ${JSON.stringify(event.options)}`);
+    });
+
+    let body = JSON.stringify({deviceId, events: pendingEvents});
+    let fetchRequest = fetch(eventsUrl, Object.assign({body}, fetchOptions));
+    fetchWatcher(fetchRequest);
+    pendingEvents = [];
+  }
+
+  function flushTimings() {
+    if (pendingTimings.length === 0) {
+      return;
+    }
+
+    let timingsUrl = `${main.getBackend()}/timing`;
+    let deviceId = auth.getDeviceId();
+    let body = JSON.stringify({deviceId, timings: pendingTimings});
+    let fetchRequest = fetch(timingsUrl, Object.assign({body}, fetchOptions));
+    fetchWatcher(fetchRequest);
+    pendingTimings.forEach(t => {
+      log.info(`sendTiming ${t.timingCategory}/${t.timingLabel}/${t.timingVar}: ${t.timingValue}`);
+    });
+    pendingTimings = [];
+  }
 
   function sendTiming(timingLabel, timingVar, timingValue) {
     // sendTiming is only called in response to sendEvent, so no need to check
     // the telemetry pref again here.
     let timingCategory = "addon";
-    return new Promise((resolve, reject) => {
-      let url = main.getBackend() + "/timing";
-      let req = new XMLHttpRequest();
-      req.open("POST", url);
-      req.setRequestHeader("content-type", "application/json");
-      req.onload = catcher.watchFunction(() => {
-        if (req.status >= 300) {
-          let exc = new Error("Bad response from POST /timing");
-          exc.status = req.status;
-          exc.statusText = req.statusText;
-          reject(exc);
-        } else {
-          resolve();
-        }
-      });
-      log.info(`sendTiming ${timingCategory}/${timingLabel}/${timingVar}: ${timingValue}`);
-      req.send(JSON.stringify({
-        deviceId: auth.getDeviceId(),
-        timingCategory,
-        timingLabel,
-        timingVar,
-        timingValue
-      }));
+    pendingTimings.push({
+      timingCategory,
+      timingLabel,
+      timingVar,
+      timingValue
     });
+    if (!timingsTimeoutHandle) {
+      timingsTimeoutHandle = setTimeout(() => {
+        timingsTimeoutHandle = null;
+        flushTimings();
+      }, EVENT_BATCH_DURATION);
+    }
   }
 
   exports.sendEvent = function(action, label, options) {
     let eventCategory = "addon";
     if (!telemetryPrefKnown) {
       log.warn("sendEvent called before we were able to refresh");
       return Promise.resolve();
     }
-    if (!telemetryPref) {
+    if (!telemetryEnabled) {
       log.info(`Cancelled sendEvent ${eventCategory}/${action}/${label || 'none'} ${JSON.stringify(options)}`);
       return Promise.resolve();
     }
     measureTiming(action, label);
     // Internal-only events are used for measuring time between events,
     // but aren't submitted to GA.
     if (action === 'internal') {
       return Promise.resolve();
     }
     if (typeof label == "object" && (!options)) {
       options = label;
       label = undefined;
     }
     options = options || {};
+
+    // Don't send events if in private browsing.
+    if (options.incognito) {
+      return Promise.resolve();
+    }
+
+    // Don't include in event data.
+    delete options.incognito;
+
     let di = deviceInfo();
-    return new Promise((resolve, reject) => {
-      let url = main.getBackend() + "/event";
-      let req = new XMLHttpRequest();
-      req.open("POST", url);
-      req.setRequestHeader("content-type", "application/json");
-      req.onload = catcher.watchFunction(() => {
-        if (req.status >= 300) {
-          let exc = new Error("Bad response from POST /event");
-          exc.status = req.status;
-          exc.statusText = req.statusText;
-          reject(exc);
-        } else {
-          resolve();
-        }
-      });
-      options.applicationName = di.appName;
-      options.applicationVersion = di.addonVersion;
-      let abTests = auth.getAbTests();
-      for (let [gaField, value] of Object.entries(abTests)) {
-        options[gaField] = value;
-      }
-      log.info(`sendEvent ${eventCategory}/${action}/${label || 'none'} ${JSON.stringify(options)}`);
-      req.send(JSON.stringify({
-        deviceId: auth.getDeviceId(),
-        event: eventCategory,
-        action,
-        label,
-        options
-      }));
+    options.applicationName = di.appName;
+    options.applicationVersion = di.addonVersion;
+    let abTests = auth.getAbTests();
+    for (let [gaField, value] of Object.entries(abTests)) {
+      options[gaField] = value;
+    }
+    pendingEvents.push({
+      eventTime: Date.now(),
+      event: eventCategory,
+      action,
+      label,
+      options
     });
+    if (!eventsTimeoutHandle) {
+      eventsTimeoutHandle = setTimeout(() => {
+        eventsTimeoutHandle = null;
+        flushEvents();
+      }, EVENT_BATCH_DURATION);
+    }
+    // This function used to return a Promise that was not used at any of the
+    // call sites; doing this simply maintains that interface.
+    return Promise.resolve();
   };
 
   exports.refreshTelemetryPref = function() {
-    return communication.sendToBootstrap("getTelemetryPref").then((result) => {
+    return communication.sendToBootstrap("isTelemetryEnabled").then((result) => {
       telemetryPrefKnown = true;
       if (result === communication.NO_BOOTSTRAP) {
-        telemetryPref = true;
+        telemetryEnabled = true;
       } else {
-        telemetryPref = result;
+        telemetryEnabled = result;
       }
     }, (error) => {
       // If there's an error reading the pref, we should assume that we shouldn't send data
       telemetryPrefKnown = true;
-      telemetryPref = false;
+      telemetryEnabled = false;
       throw error;
     });
   };
 
-  exports.getTelemetryPrefSync = function() {
+  exports.isTelemetryEnabled = function() {
     catcher.watchPromise(exports.refreshTelemetryPref());
-    return !!telemetryPref;
+    return telemetryEnabled;
   };
 
-  let timingData = {};
+  let timingData = new Map();
 
   // Configuration for filtering the sendEvent stream on start/end events.
   // When start or end events occur, the time is recorded.
   // When end events occur, the elapsed time is calculated and submitted
   // via `sendEvent`, where action = "perf-response-time", label = name of rule,
   // and cd1 value is the elapsed time in milliseconds.
   // If a cancel event happens between the start and end events, the start time
   // is deleted.
@@ -192,28 +228,40 @@ this.analytics = (function() {
   // Match a filter (action and optional label) against an action and label.
   function match(filter, action, label) {
     return filter.label ?
       filter.action === action && filter.label === label :
       filter.action === action;
   }
 
   function anyMatches(filters, action, label) {
-    return !!filters.find(filter => match(filter, action, label));
+    return filters.some(filter => match(filter, action, label));
   }
 
   function measureTiming(action, label) {
     rules.forEach(r => {
       if (anyMatches(r.cancel, action, label)) {
         delete timingData[r.name];
       } else if (match(r.start, action, label)) {
         timingData[r.name] = Date.now();
       } else if (timingData[r.name] && match(r.end, action, label)) {
         let endTime = Date.now();
         let elapsed = endTime - timingData[r.name];
-        catcher.watchPromise(sendTiming("perf-response-time", r.name, elapsed), true);
+        sendTiming("perf-response-time", r.name, elapsed);
         delete timingData[r.name];
       }
     });
   }
 
+  function fetchWatcher(request) {
+    catcher.watchPromise(
+      request.then(response => {
+        if (!response.ok) {
+          throw new Error(`Bad response from ${request.url}: ${response.status} ${response.statusText}`);
+        }
+        return response;
+      }),
+      true
+    );
+  }
+
   return exports;
 })();
--- a/browser/extensions/screenshots/webextension/background/auth.js
+++ b/browser/extensions/screenshots/webextension/background/auth.js
@@ -171,38 +171,16 @@ this.auth = (function() {
   exports.getAbTests = function() {
     return abTests;
   };
 
   exports.isRegistered = function() {
     return registrationInfo.registered;
   };
 
-  exports.setDeviceInfoFromOldAddon = function(newDeviceInfo) {
-    return registrationInfoFetched.then(() => {
-      if (!(newDeviceInfo.deviceId && newDeviceInfo.secret)) {
-        throw new Error("Bad deviceInfo");
-      }
-      if (registrationInfo.deviceId === newDeviceInfo.deviceId &&
-        registrationInfo.secret === newDeviceInfo.secret) {
-        // Probably we already imported the information
-        return Promise.resolve(false);
-      }
-      registrationInfo = {
-        deviceId: newDeviceInfo.deviceId,
-        secret: newDeviceInfo.secret,
-        registered: true
-      };
-      initialized = false;
-      return browser.storage.local.set({registrationInfo}).then(() => {
-        return true;
-      });
-    });
-  };
-
   communication.register("getAuthInfo", (sender, ownershipCheck) => {
     return registrationInfoFetched.then(() => {
       return exports.authHeaders();
     }).then((authHeaders) => {
       let info = registrationInfo;
       if (info.registered) {
         return login({ownershipCheck}).then((result) => {
           return {
--- a/browser/extensions/screenshots/webextension/background/communication.js
+++ b/browser/extensions/screenshots/webextension/background/communication.js
@@ -58,17 +58,16 @@ this.communication = (function() {
       if (isBootstrapMissingError(error)) {
         return exports.NO_BOOTSTRAP;
       }
       throw error;
     });
   };
 
   function isBootstrapMissingError(error) {
-    // Note: some of this logic is copied into startBackground.js's getOldDeviceInfo call
     if (!error) {
       return false;
     }
     return ('errorCode' in error && error.errorCode === "NO_RECEIVING_END") ||
       (!error.errorCode && error.message === "Could not establish connection. Receiving end does not exist.");
   }
 
 
--- a/browser/extensions/screenshots/webextension/background/main.js
+++ b/browser/extensions/screenshots/webextension/background/main.js
@@ -75,52 +75,52 @@ this.main = (function() {
       });
   }
 
   function startSelectionWithOnboarding(tab) {
     return analytics.refreshTelemetryPref().then(() => {
       return selectorLoader.testIfLoaded(tab.id);
     }).then((isLoaded) => {
       if (!isLoaded) {
-        sendEvent("start-shot", "site-request");
+        sendEvent("start-shot", "site-request", {incognito: tab.incognito});
         setIconActive(true, tab.id);
         selectorLoader.toggle(tab.id, false);
       }
     });
   }
 
   function shouldOpenMyShots(url) {
     return /^about:(?:newtab|blank|home)/i.test(url) || /^resource:\/\/activity-streams\//i.test(url);
   }
 
   // This is called by startBackground.js, directly in response to clicks on the Photon page action
   exports.onClicked = catcher.watchFunction((tab) => {
     if (shouldOpenMyShots(tab.url)) {
       if (!hasSeenOnboarding) {
         catcher.watchPromise(analytics.refreshTelemetryPref().then(() => {
-          sendEvent("goto-onboarding", "selection-button");
+          sendEvent("goto-onboarding", "selection-button", {incognito: tab.incognito});
           return forceOnboarding();
         }));
         return;
       }
       catcher.watchPromise(analytics.refreshTelemetryPref().then(() => {
-        sendEvent("goto-myshots", "about-newtab");
+        sendEvent("goto-myshots", "about-newtab", {incognito: tab.incognito});
       }));
       catcher.watchPromise(
         auth.authHeaders()
         .then(() => browser.tabs.update({url: backend + "/shots"})));
     } else {
       catcher.watchPromise(
         toggleSelector(tab)
           .then(active => {
             const event = active ? "start-shot" : "cancel-shot";
-            sendEvent(event, "toolbar-button");
+            sendEvent(event, "toolbar-button", {incognito: tab.incognito});
           }, (error) => {
             if ((!hasSeenOnboarding) && error.popupMessage == "UNSHOOTABLE_PAGE") {
-              sendEvent("goto-onboarding", "selection-button");
+              sendEvent("goto-onboarding", "selection-button", {incognito: tab.incognito});
               return forceOnboarding();
             }
             throw error;
           }));
     }
   });
 
   function forceOnboarding() {
@@ -135,17 +135,17 @@ this.main = (function() {
     if (!urlEnabled(tab.url)) {
       senderror.showError({
         popupMessage: "UNSHOOTABLE_PAGE"
       });
       return;
     }
     catcher.watchPromise(
       toggleSelector(tab)
-        .then(() => sendEvent("start-shot", "context-menu")));
+        .then(() => sendEvent("start-shot", "context-menu", {incognito: tab.incognito})));
   });
 
   function urlEnabled(url) {
     if (shouldOpenMyShots(url)) {
       return true;
     }
     if (isShotOrMyShotPage(url) || /^(?:about|data|moz-extension):/i.test(url) || isBlacklistedUrl(url)) {
       return false;
@@ -204,17 +204,17 @@ this.main = (function() {
       });
     }
   });
 
   communication.register("copyShotToClipboard", (sender, blob) => {
     return blobConverters.blobToArray(blob).then(buffer => {
       return browser.clipboard.setImageData(
         buffer, blob.type.split("/", 2)[1]).then(() => {
-          catcher.watchPromise(communication.sendToBootstrap('incrementCopyCount'));
+          catcher.watchPromise(communication.sendToBootstrap("incrementCount", {scalar: "copy"}));
           return browser.notifications.create({
             type: "basic",
             iconUrl: "../icons/copy.png",
             title: browser.i18n.getMessage("notificationImageCopiedTitle"),
             message: browser.i18n.getMessage("notificationImageCopiedDetails", pasteSymbol)
           });
         });
     })
@@ -231,101 +231,55 @@ this.main = (function() {
         return;
       }
       if (change.state && change.state.current != "in_progress") {
         URL.revokeObjectURL(url);
         browser.downloads.onChanged.removeListener(onChangedCallback);
       }
     });
     browser.downloads.onChanged.addListener(onChangedCallback)
-    catcher.watchPromise(communication.sendToBootstrap("incrementDownloadCount"));
+    catcher.watchPromise(communication.sendToBootstrap("incrementCount", {scalar: "download"}));
     return browser.windows.getLastFocused().then(windowInfo => {
-      return windowInfo.incognito;
-    }).then((incognito) => {
       return browser.downloads.download({
         url,
-        incognito,
+        incognito: windowInfo.incognito,
         filename: info.filename
       }).then((id) => {
         downloadId = id;
       });
     });
   });
 
   communication.register("closeSelector", (sender) => {
     setIconActive(false, sender.tab.id);
   });
 
-  catcher.watchPromise(communication.sendToBootstrap("getOldDeviceInfo").then((deviceInfo) => {
-    if (deviceInfo === communication.NO_BOOTSTRAP || !deviceInfo) {
-      return;
-    }
-    deviceInfo = JSON.parse(deviceInfo);
-    if (deviceInfo && typeof deviceInfo == "object") {
-      return auth.setDeviceInfoFromOldAddon(deviceInfo).then((updated) => {
-        if (updated === communication.NO_BOOTSTRAP) {
-          throw new Error("bootstrap.js disappeared unexpectedly");
-        }
-        if (updated) {
-          return communication.sendToBootstrap("removeOldAddon");
-        }
-      });
-    }
-  }));
-
   communication.register("hasSeenOnboarding", () => {
     hasSeenOnboarding = true;
     catcher.watchPromise(browser.storage.local.set({hasSeenOnboarding}));
     setIconActive(false, null);
     startBackground.photonPageActionPort.postMessage({
       type: "setProperties",
       title: browser.i18n.getMessage("contextMenuLabel")
     });
   });
 
-  communication.register("abortFrameset", () => {
-    sendEvent("abort-start-shot", "frame-page");
-    // Note, we only show the error but don't report it, as we know that we can't
-    // take shots of these pages:
-    senderror.showError({
-      popupMessage: "UNSHOOTABLE_PAGE"
-    });
-  });
-
-  communication.register("abortNoDocumentBody", (sender, tagName) => {
-    tagName = String(tagName || "").replace(/[^a-z0-9]/ig, "");
-    sendEvent("abort-start-shot", `document-is-${tagName}`);
+  communication.register("abortStartShot", () => {
     // Note, we only show the error but don't report it, as we know that we can't
     // take shots of these pages:
     senderror.showError({
       popupMessage: "UNSHOOTABLE_PAGE"
     });
   });
 
-  // Note: this signal is only needed until bug 1357589 is fixed.
-  communication.register("openTermsPage", () => {
-    return catcher.watchPromise(browser.tabs.create({url: "https://www.mozilla.org/about/legal/terms/services/"}));
-  });
-
-  // Note: this signal is also only needed until bug 1357589 is fixed.
-  communication.register("openPrivacyPage", () => {
-    return catcher.watchPromise(browser.tabs.create({url: "https://www.mozilla.org/privacy/firefox-cloud/"}));
-  });
-
   // A Screenshots page wants us to start/force onboarding
   communication.register("requestOnboarding", (sender) => {
     return startSelectionWithOnboarding(sender.tab);
   });
 
-  communication.register("isHistoryEnabled", () => {
-    return catcher.watchPromise(communication.sendToBootstrap("getHistoryPref").then(historyEnabled => {
-      return historyEnabled;
-    }));
-  });
-
   communication.register("getPlatformOs", () => {
     return catcher.watchPromise(browser.runtime.getPlatformInfo().then(platformInfo => {
       return platformInfo.os;
     }));
   });
 
   return exports;
 })();
--- a/browser/extensions/screenshots/webextension/background/selectorLoader.js
+++ b/browser/extensions/screenshots/webextension/background/selectorLoader.js
@@ -83,25 +83,27 @@ this.selectorLoader = (function() {
       loadingTabs.delete(tabId);
       throw error;
     });
   };
 
   // TODO: since bootstrap communication is now required, would this function
   // make more sense inside background/main?
   function downloadOnlyCheck(tabId) {
-    return communication.sendToBootstrap("getHistoryPref").then((historyEnabled) => {
-      return browser.tabs.get(tabId).then(tab => {
-        let downloadOnly = !historyEnabled || tab.incognito;
-        return browser.tabs.executeScript(tabId, {
-          // Note: `window` here refers to a global accessible to content
-          // scripts, but not the scripts in the underlying page. For more
-          // details, see https://mdn.io/WebExtensions/Content_scripts#Content_script_environment
-          code: `window.downloadOnly = ${downloadOnly}`,
-          runAt: "document_start"
+    return communication.sendToBootstrap("isHistoryEnabled").then((historyEnabled) => {
+      return communication.sendToBootstrap("isUploadDisabled").then((uploadDisabled) => {
+        return browser.tabs.get(tabId).then(tab => {
+          let downloadOnly = !historyEnabled || uploadDisabled || tab.incognito;
+          return browser.tabs.executeScript(tabId, {
+            // Note: `window` here refers to a global accessible to content
+            // scripts, but not the scripts in the underlying page. For more
+            // details, see https://mdn.io/WebExtensions/Content_scripts#Content_script_environment
+            code: `window.downloadOnly = ${downloadOnly}`,
+            runAt: "document_start"
+          });
         });
       });
     });
   }
 
   function executeModules(tabId, scripts) {
     let lastPromise = Promise.resolve(null);
     scripts.forEach((file) => {
--- a/browser/extensions/screenshots/webextension/background/senderror.js
+++ b/browser/extensions/screenshots/webextension/background/senderror.js
@@ -83,17 +83,17 @@ this.senderror = (function() {
         // FIXME: need iconUrl for an image, see #2239
         title,
         message
       });
     }
   };
 
   exports.reportError = function(e) {
-    if (!analytics.getTelemetryPrefSync()) {
+    if (!analytics.isTelemetryEnabled()) {
       log.error("Telemetry disabled. Not sending critical error:", e);
       return;
     }
     let dsn = auth.getSentryPublicDSN();
     if (!dsn) {
       log.warn("Screenshots error:", e);
       return;
     }
--- a/browser/extensions/screenshots/webextension/background/startBackground.js
+++ b/browser/extensions/screenshots/webextension/background/startBackground.js
@@ -17,25 +17,23 @@ this.startBackground = (function() {
     "catcher.js",
     "blobConverters.js",
     "background/selectorLoader.js",
     "background/communication.js",
     "background/auth.js",
     "background/senderror.js",
     "build/raven.js",
     "build/shot.js",
+    "build/thumbnailGenerator.js",
     "background/analytics.js",
     "background/deviceInfo.js",
     "background/takeshot.js",
     "background/main.js"
   ];
 
-  // Maximum milliseconds to wait before checking for migration possibility
-  const CHECK_MIGRATION_DELAY = 2000;
-
   browser.contextMenus.create({
     id: "create-screenshot",
     title: browser.i18n.getMessage("contextMenuLabel"),
     contexts: ["page"],
     documentUrlPatterns: ["<all_urls>"]
   });
 
   browser.contextMenus.onClicked.addListener((info, tab) => {
@@ -53,40 +51,16 @@ this.startBackground = (function() {
       console.error("Error loading Screenshots:", error);
     });
     return true;
   });
 
   let photonPageActionPort = null;
   initPhotonPageAction();
 
-  // We delay this check (by CHECK_MIGRATION_DELAY) just to avoid piling too
-  // many things onto browser/add-on startup
-  requestIdleCallback(() => {
-    browser.runtime.sendMessage({funcName: "getOldDeviceInfo"}).then((result) => {
-      if (result && result.type == "success" && result.value) {
-        // There is a possible migration to run, so we'll load the entire background
-        // page and continue the process
-        return loadIfNecessary();
-      }
-      if (!result) {
-        throw new Error("Got no result from getOldDeviceInfo");
-      }
-      if (result.type == "error") {
-        throw new Error(`Error from getOldDeviceInfo: ${result.name}`);
-      }
-    }).catch((error) => {
-      if (error && error.message == "Could not establish connection. Receiving end does not exist") {
-        // Just a missing bootstrap.js, ignore
-      } else {
-        console.error("Screenshots error checking for Page Shot migration:", error);
-      }
-    });
-  }, {timeout: CHECK_MIGRATION_DELAY});
-
   let loadedPromise;
 
   function loadIfNecessary() {
     if (loadedPromise) {
       return loadedPromise;
     }
     loadedPromise = Promise.resolve();
     backgroundScripts.forEach((script) => {
--- a/browser/extensions/screenshots/webextension/background/takeshot.js
+++ b/browser/extensions/screenshots/webextension/background/takeshot.js
@@ -1,23 +1,24 @@
-/* globals communication, shot, main, auth, catcher, analytics, buildSettings, blobConverters */
+/* globals communication, shot, main, auth, catcher, analytics, buildSettings, blobConverters, thumbnailGenerator */
 
 "use strict";
 
 this.takeshot = (function() {
   let exports = {};
   const Shot = shot.AbstractShot;
   const { sendEvent } = analytics;
 
   communication.register("takeShot", catcher.watchFunction((sender, options) => {
     let { captureType, captureText, scroll, selectedPos, shotId, shot, imageBlob } = options;
     shot = new Shot(main.getBackend(), shotId, shot);
     shot.favicon = sender.tab.favIconUrl;
     let capturePromise = Promise.resolve();
     let openedTab;
+    let thumbnailBlob;
     if (!shot.clipNames().length) {
       // canvas.drawWindow isn't available, so we fall back to captureVisibleTab
       capturePromise = screenshotPage(selectedPos, scroll).then((dataUrl) => {
         shot.addClip({
           createdDate: Date.now(),
           image: {
             url: "data:",
             captureType,
@@ -28,18 +29,19 @@ this.takeshot = (function() {
               y: selectedPos.bottom - selectedPos.top
             }
           }
         });
       });
     }
     let convertBlobPromise = Promise.resolve();
     if (buildSettings.uploadBinary && !imageBlob) {
-      imageBlob = blobConverters.dataUrlToBlob(shot.getClip(shot.clipNames()[0]).image.url);
-      shot.getClip(shot.clipNames()[0]).image.url = "";
+      let clipImage = shot.getClip(shot.clipNames()[0]).image;
+      imageBlob = blobConverters.dataUrlToBlob(clipImage.url);
+      clipImage.url = "";
     } else if (!buildSettings.uploadBinary && imageBlob) {
       convertBlobPromise = blobConverters.blobToDataUrl(imageBlob).then((dataUrl) => {
         shot.getClip(shot.clipNames()[0]).image.url = dataUrl;
       });
       imageBlob = null;
     }
     let shotAbTests = {};
     let abTests = auth.getAbTests();
@@ -49,36 +51,48 @@ this.takeshot = (function() {
       }
     }
     if (Object.keys(shotAbTests).length) {
       shot.abTests = shotAbTests;
     }
     return catcher.watchPromise(capturePromise.then(() => {
       return convertBlobPromise;
     }).then(() => {
+      if (buildSettings.uploadBinary) {
+        let blobToUrlPromise = blobConverters.blobToDataUrl(imageBlob);
+        return thumbnailGenerator.createThumbnailBlobFromPromise(shot, blobToUrlPromise);
+      }
+      return thumbnailGenerator.createThumbnailUrl(shot);
+    }).then((thumbnailImage) => {
+      if (buildSettings.uploadBinary) {
+        thumbnailBlob = thumbnailImage;
+      } else {
+        shot.thumbnail = thumbnailImage;
+      }
+    }).then(() => {
       return browser.tabs.create({url: shot.creatingUrl})
     }).then((tab) => {
       openedTab = tab;
       sendEvent('internal', 'open-shot-tab');
-      return uploadShot(shot, imageBlob);
+      return uploadShot(shot, imageBlob, thumbnailBlob);
     }).then(() => {
-      return browser.tabs.update(openedTab.id, {url: shot.viewUrl}).then(
+      return browser.tabs.update(openedTab.id, {url: shot.viewUrl, loadReplace: true}).then(
         null,
         (error) => {
           // FIXME: If https://bugzilla.mozilla.org/show_bug.cgi?id=1365718 is resolved,
           // use the errorCode added as an additional check:
           if ((/invalid tab id/i).test(error)) {
             // This happens if the tab was closed before the upload completed
             return browser.tabs.create({url: shot.viewUrl});
           }
           throw error;
         }
       );
     }).then(() => {
-      catcher.watchPromise(communication.sendToBootstrap('incrementUploadCount'));
+      catcher.watchPromise(communication.sendToBootstrap("incrementCount", {scalar: "upload"}));
       return shot.viewUrl;
     }).catch((error) => {
       browser.tabs.remove(openedTab.id);
       throw error;
     }));
   }));
 
   communication.register("screenshotPage", (sender, selectedPos, scroll) => {
@@ -126,57 +140,75 @@ this.takeshot = (function() {
   function concatBuffers(buffer1, buffer2) {
     var tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
     tmp.set(new Uint8Array(buffer1), 0);
     tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
     return tmp.buffer;
   }
 
   /** Creates a multipart TypedArray, given {name: value} fields
-      and {name: blob} files
+      and a files array in the format of
+      [{fieldName: "NAME", filename: "NAME.png", blob: fileBlob}, {...}, ...]
 
       Returns {body, "content-type"}
       */
-  function createMultipart(fields, fileField, fileFilename, blob) {
+  function createMultipart(fields, files) {
     let boundary = "---------------------------ScreenshotBoundary" + Date.now();
-    return blobConverters.blobToArray(blob).then((blobAsBuffer) => {
-      let body = [];
-      for (let name in fields) {
-        body.push("--" + boundary);
-        body.push(`Content-Disposition: form-data; name="${name}"`);
-        body.push("");
-        body.push(fields[name]);
+    let body = [];
+    for (let name in fields) {
+      body.push("--" + boundary);
+      body.push(`Content-Disposition: form-data; name="${name}"`);
+      body.push("");
+      body.push(fields[name]);
+    }
+    body.push("");
+    body = body.join("\r\n");
+    let enc = new TextEncoder("utf-8");
+    body = enc.encode(body).buffer;
+
+    let blobToArrayPromises = files.map(f => {
+      return blobConverters.blobToArray(f.blob);
+    });
+
+    return Promise.all(blobToArrayPromises).then(buffers => {
+      for (let i = 0; i < buffers.length; i++) {
+        let filePart = [];
+        filePart.push("--" + boundary);
+        filePart.push(`Content-Disposition: form-data; name="${files[i].fieldName}"; filename="${files[i].filename}"`);
+        filePart.push(`Content-Type: ${files[i].blob.type}`);
+        filePart.push("");
+        filePart.push("");
+        filePart = filePart.join("\r\n");
+        filePart = concatBuffers(enc.encode(filePart).buffer, buffers[i]);
+        body = concatBuffers(body, filePart);
+        body = concatBuffers(body, enc.encode("\r\n").buffer);
       }
-      body.push("--" + boundary);
-      body.push(`Content-Disposition: form-data; name="${fileField}"; filename="${fileFilename}"`);
-      body.push(`Content-Type: ${blob.type}`);
-      body.push("");
-      body.push("");
-      body = body.join("\r\n");
-      let enc = new TextEncoder("utf-8");
-      body = enc.encode(body);
-      body = concatBuffers(body.buffer, blobAsBuffer);
+
       let tail = `\r\n--${boundary}--`;
       tail = enc.encode(tail);
       body = concatBuffers(body, tail.buffer);
       return {
         "content-type": `multipart/form-data; boundary=${boundary}`,
         body
       };
     });
   }
 
-  function uploadShot(shot, blob) {
+  function uploadShot(shot, blob, thumbnail) {
     let headers;
     return auth.authHeaders().then((_headers) => {
       headers = _headers;
       if (blob) {
+        let files = [ {fieldName: "blob", filename: "screenshot.png", blob} ];
+        if (thumbnail) {
+          files.push({fieldName: "thumbnail", filename: "thumbnail.png", blob: thumbnail});
+        }
         return createMultipart(
           {shot: JSON.stringify(shot.asJson())},
-          "blob", "screenshot.png", blob
+          files
         );
       }
       return {
         "content-type": "application/json",
         body: JSON.stringify(shot.asJson())
       };
 
     }).then((submission) => {
--- a/browser/extensions/screenshots/webextension/build/inlineSelectionCss.js
+++ b/browser/extensions/screenshots/webextension/build/inlineSelectionCss.js
@@ -71,16 +71,18 @@ window.inlineSelectionCss = `
     .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-save:active, .download.highlight-button-download:active, .download.highlight-button-copy:active, .download.preview-button-save:active {
       background-color: #dedede; }
   .button.share, .share.highlight-button-cancel, .share.highlight-button-save, .share.highlight-button-download, .share.highlight-button-copy, .share.preview-button-save {
     background-image: url("../img/icon-share.svg"); }
     .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-save:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover, .share.preview-button-save:hover {
       background-color: #ededf0; }
     .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-save, .share.active.highlight-button-download, .share.active.highlight-button-copy, .share.active.preview-button-save, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-save:active, .share.highlight-button-download:active, .share.highlight-button-copy:active, .share.preview-button-save:active {
       background-color: #dedede; }
+  .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-save, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy, .share.newicon.preview-button-save {
+    background-image: url("../img/icon-share-alternate.svg"); }
   .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-save, .trash.highlight-button-download, .trash.highlight-button-copy, .trash.preview-button-save {
     background-image: url("../img/icon-trash.svg"); }
     .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-save:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover, .trash.preview-button-save:hover {
       background-color: #ededf0; }
     .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-save:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active, .trash.preview-button-save:active {
       background-color: #dedede; }
   .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-save, .edit.highlight-button-download, .edit.highlight-button-copy, .edit.preview-button-save {
     background-image: url("../img/icon-edit.svg"); }
@@ -475,59 +477,46 @@ window.inlineSelectionCss = `
     right: 5px; }
   .bottom-selection .highlight-buttons {
     bottom: 5px; }
   .left-selection .highlight-buttons {
     right: auto;
     left: 5px; }
 
 .highlight-button-cancel {
-  background-image: url("MOZ_EXTENSION/icons/cancel.svg");
-  background-position: center center;
-  background-repeat: no-repeat;
-  background-size: 18px 18px;
   border: 1px solid #dedede;
   margin: 5px;
   width: 40px; }
 
 .highlight-button-save {
-  background-image: url("MOZ_EXTENSION/icons/cloud.svg");
-  background-repeat: no-repeat;
-  background-size: 20px 18px;
   font-size: 18px;
   margin: 5px;
   min-width: 80px; }
-  html[dir="ltr"] .highlight-button-save {
-    background-position: 8px center; }
-  html[dir="rtl"] .highlight-button-save {
-    background-position: right 10px center; }
-  html[dir="ltr"] .highlight-button-save {
-    padding-left: 34px; }
-  html[dir="rtl"] .highlight-button-save {
-    padding-right: 40px; }
+  html[dir="ltr"] .highlight-button-save img {
+    padding-right: 8px; }
+  html[dir="rtl"] .highlight-button-save img {
+    padding-left: 8px; }
 
 .highlight-button-download {
-  background-image: url("MOZ_EXTENSION/icons/download.svg");
-  background-position: center center;
-  background-repeat: no-repeat;
-  background-size: 18px 18px;
   border: 1px solid #dedede;
   display: block;
   margin: 5px;
   width: 40px; }
   .highlight-button-download.download-only-button {
-    width: auto;
-    background-position: 7px;
-    padding-left: 32px; }
+    font-size: 18px;
+    width: auto; }
+    .highlight-button-download.download-only-button img {
+      height: 16px;
+      width: 16px; }
+      html[dir="ltr"] .highlight-button-download.download-only-button img {
+        padding-right: 8px; }
+      html[dir="rtl"] .highlight-button-download.download-only-button img {
+        padding-left: 8px; }
 
 .highlight-button-copy {
-  background-image: url("MOZ_EXTENSION/icons/copy.svg");
-  background-position: center center;
-  background-repeat: no-repeat;
-  background-size: 18px 18px;
   border: 1px solid #dedede;
   display: block;
   margin: 5px;
   width: 40px; }
 
 .pixel-dimensions {
   position: absolute;
   pointer-events: none;
@@ -537,46 +526,48 @@ window.inlineSelectionCss = `
   color: #000;
   text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
 
 .preview-buttons {
   display: flex;
   align-items: center;
   justify-content: center;
   position: absolute;
-  right: 0;
   top: -2px; }
+  html[dir="rtl"] .preview-buttons {
+    left: 0; }
+  html[dir="ltr"] .preview-buttons {
+    right: 0; }
 
 .preview-image {
   position: relative;
   height: 80%;
   max-width: 100%;
   margin: auto 2em;
   text-align: center;
   animation-delay: 50ms;
   animation: bounce-in 300ms forwards ease-in-out; }
 
-.preview-image img {
+.preview-image > img {
   display: block;
   width: auto;
   height: auto;
   max-width: 100%;
   max-height: 90%;
   margin: 50px auto;
   border: 1px solid rgba(255, 255, 255, 0.8); }
 
 .preview-button-save {
-  background-image: url("MOZ_EXTENSION/icons/cloud.svg");
-  background-position: 8px center;
-  background-repeat: no-repeat;
-  background-size: 20px 18px;
   font-size: 18px;
   margin: 5px;
-  min-width: 80px;
-  padding-left: 34px; }
+  min-width: 80px; }
+  html[dir="ltr"] .preview-button-save img {
+    padding-right: 8px; }
+  html[dir="rtl"] .preview-button-save img {
+    padding-left: 8px; }
 
 .fixed-container {
   align-items: center;
   display: flex;
   flex-direction: column;
   height: 100vh;
   justify-content: center;
   left: 0;
@@ -633,16 +624,30 @@ window.inlineSelectionCss = `
   font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
   font-size: 24px;
   line-height: 32px;
   text-align: center;
   padding-top: 20px;
   width: 400px;
   user-select: none; }
 
+.cancel-shot {
+  background-color: transparent;
+  cursor: pointer;
+  outline: none;
+  border-radius: 3px;
+  border: 1px #9b9b9b solid;
+  color: #fff;
+  cursor: pointer;
+  font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+  font-size: 16px;
+  margin-top: 40px;
+  padding: 10px 25px;
+  pointer-events: all; }
+
 .myshots-all-buttons-container {
   display: flex;
   flex-direction: row-reverse;
   background: #f5f5f5;
   border-radius: 2px;
   box-sizing: border-box;
   height: 80px;
   padding: 8px;
--- a/browser/extensions/screenshots/webextension/build/shot.js
+++ b/browser/extensions/screenshots/webextension/build/shot.js
@@ -232,29 +232,35 @@ class AbstractShot {
     this.images = [];
     if (attrs.images) {
       this.images = attrs.images.map(
         (json) => new this.Image(json));
     }
     this.openGraph = attrs.openGraph || null;
     this.twitterCard = attrs.twitterCard || null;
     this.documentSize = attrs.documentSize || null;
-    this.fullScreenThumbnail = attrs.fullScreenThumbnail || null;
+    this.thumbnail = attrs.thumbnail || null;
     this.abTests = attrs.abTests || null;
     this._clips = {};
     if (attrs.clips) {
       for (let clipId in attrs.clips) {
         let clip = attrs.clips[clipId];
         this._clips[clipId] = new this.Clip(this, clipId, clip);
       }
     }
 
+    let isProd = typeof process !== "undefined" && process.env.NODE_ENV === "production";
+
     for (let attr in attrs) {
       if (attr !== "clips" && attr !== "id" && !this.REGULAR_ATTRS.includes(attr) && !this.DEPRECATED_ATTRS.includes(attr)) {
-        throw new Error("Unexpected attribute: " + attr);
+        if (isProd) {
+          console.warn("Unexpected attribute: " + attr);
+        } else {
+          throw new Error("Unexpected attribute: " + attr);
+        }
       } else if (attr === "id") {
         console.warn("passing id in attrs in AbstractShot constructor");
         console.trace();
         assert(attrs.id === this.id);
       }
     }
   }
 
@@ -564,26 +570,26 @@ class AbstractShot {
       assert(typeof val.height == "number");
       assert(typeof val.width == "number");
       this._documentSize = val;
     } else {
       this._documentSize = null;
     }
   }
 
-  get fullScreenThumbnail() {
-    return this._fullScreenThumbnail;
+  get thumbnail() {
+    return this._thumbnail;
   }
-  set fullScreenThumbnail(val) {
+  set thumbnail(val) {
     assert(typeof val == "string" || !val);
     if (val) {
       assert(isUrl(val));
-      this._fullScreenThumbnail = val;
+      this._thumbnail = val;
     } else {
-      this._fullScreenThumbnail = null;
+      this._thumbnail = null;
     }
   }
 
   get abTests() {
     return this._abTests;
   }
   set abTests(val) {
     if (val === null || val === undefined) {
@@ -598,28 +604,29 @@ class AbstractShot {
     this._abTests = val;
   }
 
 }
 
 AbstractShot.prototype.REGULAR_ATTRS = (`
 origin fullUrl docTitle userTitle createdDate favicon images
 siteName openGraph twitterCard documentSize
-fullScreenThumbnail abTests
+thumbnail abTests
 `).split(/\s+/g);
 
 // Attributes that will be accepted in the constructor, but ignored/dropped
 AbstractShot.prototype.DEPRECATED_ATTRS = (`
 microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs
 readable hashtags comments showPage isPublic resources deviceId url
+fullScreenThumbnail
 `).split(/\s+/g);
 
 AbstractShot.prototype.RECALL_ATTRS = (`
 url docTitle userTitle createdDate favicon
-openGraph twitterCard images fullScreenThumbnail
+openGraph twitterCard images thumbnail
 `).split(/\s+/g);
 
 AbstractShot.prototype._OPENGRAPH_PROPERTIES = (`
 title type url image audio description determiner locale site_name video
 image:secure_url image:type image:width image:height
 video:secure_url video:type video:width image:height
 audio:secure_url audio:type
 article:published_time article:modified_time article:expiration_time article:author article:section article:tag
new file mode 100644
--- /dev/null
+++ b/browser/extensions/screenshots/webextension/build/thumbnailGenerator.js
@@ -0,0 +1,150 @@
+this.thumbnailGenerator = (function () {let exports={}; // This is used in addon/webextension/background/takeshot.js,
+// server/src/pages/shot/controller.js, and
+// server/scr/pages/shotindex/view.js. It is used in a browser
+// environment.
+
+// Resize down 1/2 at a time produces better image quality.
+// Not quite as good as using a third-party filter (which will be
+// slower), but good enough.
+const maxResizeScaleFactor = 0.5
+
+// The shot will be scaled or cropped down to 210px on x, and cropped or
+// scaled down to a maximum of 280px on y.
+// x: 210
+// y: <= 280
+const maxThumbnailWidth = 210;
+const maxThumbnailHeight = 280;
+
+/**
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @returns {width, height, scaledX, scaledY}
+ */
+function getThumbnailDimensions(imageWidth, imageHeight) {
+  const displayAspectRatio = 3 / 4;
+  let imageAspectRatio = imageWidth / imageHeight;
+  let thumbnailImageWidth, thumbnailImageHeight;
+  let scaledX, scaledY;
+
+  if (imageAspectRatio > displayAspectRatio) {
+    // "Landscape" mode
+    // Scale on y, crop on x
+    let yScaleFactor = (imageHeight > maxThumbnailHeight) ? (maxThumbnailHeight / imageHeight) : 1.0;
+    thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor);
+    scaledX = Math.round(imageWidth * yScaleFactor);
+    thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth);
+  } else {
+    // "Portrait" mode
+    // Scale on x, crop on y
+    let xScaleFactor = (imageWidth > maxThumbnailWidth) ? (maxThumbnailWidth / imageWidth) : 1.0;
+    thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor);
+    scaledY = Math.round(imageHeight * xScaleFactor);
+    // The CSS could widen the image, in which case we crop more off of y.
+    thumbnailImageHeight = Math.min(scaledY, maxThumbnailHeight,
+                                    maxThumbnailHeight / (maxThumbnailWidth / imageWidth));
+  }
+
+  return {
+    width: thumbnailImageWidth,
+    height: thumbnailImageHeight,
+    scaledX,
+    scaledY
+  }
+}
+
+/**
+ * @param {dataUrl} String Data URL of the original image.
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @param {String} urlOrBlob 'blob' for a blob, otherwise data url.
+ * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null.
+ */
+function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) {
+  // There's cost associated with generating, transmitting, and storing
+  // thumbnails, so we'll opt out if the image size is below a certain threshold
+  const thumbnailThresholdFactor = 1.20;
+  const thumbnailWidthThreshold = maxThumbnailWidth * thumbnailThresholdFactor;
+  const thumbnailHeightThreshold = maxThumbnailHeight * thumbnailThresholdFactor;
+
+  if (imageWidth <= thumbnailWidthThreshold &&
+      imageHeight <= thumbnailHeightThreshold) {
+    // Do not create a thumbnail.
+    return Promise.resolve(null);
+  }
+
+  let thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight);
+
+  return new Promise((resolve, reject) => {
+    let thumbnailImage = new Image();
+    let srcWidth = imageWidth;
+    let srcHeight = imageHeight;
+    let destWidth, destHeight;
+
+    thumbnailImage.onload = function() {
+      destWidth = Math.round(srcWidth * maxResizeScaleFactor);
+      destHeight = Math.round(srcHeight * maxResizeScaleFactor);
+      if (destWidth <= thumbnailDimensions.scaledX || destHeight <= thumbnailDimensions.scaledY) {
+        srcWidth = Math.round(srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX));
+        srcHeight = Math.round(srcHeight * (thumbnailDimensions.height / thumbnailDimensions.scaledY));
+        destWidth = thumbnailDimensions.width;
+        destHeight = thumbnailDimensions.height;
+      }
+
+      const thumbnailCanvas = document.createElement('canvas');
+      thumbnailCanvas.width = destWidth;
+      thumbnailCanvas.height = destHeight;
+      const ctx = thumbnailCanvas.getContext("2d");
+      ctx.imageSmoothingEnabled = false;
+
+      ctx.drawImage(
+        thumbnailImage,
+        0, 0, srcWidth, srcHeight,
+        0, 0, destWidth, destHeight);
+
+      if (thumbnailCanvas.width <= thumbnailDimensions.width ||
+        thumbnailCanvas.height <= thumbnailDimensions.height) {
+        if (urlOrBlob === "blob") {
+          thumbnailCanvas.toBlob((blob) => {
+            resolve(blob);
+          });
+        } else {
+          resolve(thumbnailCanvas.toDataURL("image/png"))
+        }
+        return;
+      }
+
+      srcWidth = destWidth;
+      srcHeight = destHeight;
+      thumbnailImage.src = thumbnailCanvas.toDataURL();
+    }
+    thumbnailImage.src = dataUrl;
+  });
+}
+
+function createThumbnailUrl(shot) {
+  const image = shot.getClip(shot.clipNames()[0]).image;
+  if (!image.url) {
+    return Promise.resolve(null);
+  }
+  return createThumbnail(
+    image.url, image.dimensions.x, image.dimensions.y, "dataurl");
+}
+
+function createThumbnailBlobFromPromise(shot, blobToUrlPromise) {
+  return blobToUrlPromise.then(dataUrl => {
+    const image = shot.getClip(shot.clipNames()[0]).image;
+    return createThumbnail(
+      dataUrl, image.dimensions.x, image.dimensions.y, "blob");
+  });
+}
+
+if (typeof exports != "undefined") {
+  exports.getThumbnailDimensions = getThumbnailDimensions;
+  exports.createThumbnailUrl = createThumbnailUrl;
+  exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise;
+}
+
+return exports;
+})();
+null;
+
--- a/browser/extensions/screenshots/webextension/manifest.json
+++ b/browser/extensions/screenshots/webextension/manifest.json
@@ -1,12 +1,12 @@
 {
   "manifest_version": 2,
   "name": "Firefox Screenshots",
-  "version": "25.0.0",
+  "version": "29.0.0",
   "description": "__MSG_addonDescription__",
   "author": "__MSG_addonAuthorsList__",
   "homepage_url": "https://github.com/mozilla-services/screenshots",
   "applications": {
     "gecko": {
       "id": "screenshots@mozilla.org",
       "strict_min_version": "57.0a1"
     }
--- a/browser/extensions/screenshots/webextension/onboarding/slides.js
+++ b/browser/extensions/screenshots/webextension/onboarding/slides.js
@@ -139,26 +139,16 @@ this.slides = (function() {
     doc.querySelector("#skip").addEventListener("click", watchFunction(assertIsTrusted((event) => {
       shooter.sendEvent("cancel-slides", "skip");
       callbacks.onEnd();
     })));
     doc.querySelector("#done").addEventListener("click", watchFunction(assertIsTrusted((event) => {
       shooter.sendEvent("finish-slides", "done");
       callbacks.onEnd();
     })));
-    // Note: e10s breaks the terms and privacy anchor tags. Work around this by
-    // manually opening the correct URLs on click until bug 1357589 is fixed.
-    doc.querySelector("#terms").addEventListener("click", watchFunction(assertIsTrusted((event) => {
-      event.preventDefault();
-      callBackground("openTermsPage");
-    })));
-    doc.querySelector("#privacy").addEventListener("click", watchFunction(assertIsTrusted((event) => {
-      event.preventDefault();
-      callBackground("openPrivacyPage");
-    })));
     doc.querySelector("#slide-overlay").addEventListener("click", watchFunction(assertIsTrusted((event) => {
       if (event.target == doc.querySelector("#slide-overlay")) {
         shooter.sendEvent("cancel-slides", "background-click");
         callbacks.onEnd();
       }
     })));
     setSlide(1);
   }
--- a/browser/extensions/screenshots/webextension/selector/shooter.js
+++ b/browser/extensions/screenshots/webextension/selector/shooter.js
@@ -55,21 +55,17 @@ this.shooter = (function() { // eslint-d
     } else {
       canvas.width = width * window.devicePixelRatio;
       canvas.height = height * window.devicePixelRatio;
     }
     if (expand) {
       ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
     }
     ui.iframe.hide();
-    try {
-      ctx.drawWindow(window, selectedPos.left, selectedPos.top, width, height, "#fff");
-    } finally {
-      ui.iframe.unhide();
-    }
+    ctx.drawWindow(window, selectedPos.left, selectedPos.top, width, height, "#fff");
     let limit = buildSettings.pngToJpegCutoff;
     let dataUrl = canvas.toDataURL();
     if (limit && dataUrl.length > limit) {
       let jpegDataUrl = canvas.toDataURL("image/jpeg");
       if (jpegDataUrl.length < dataUrl.length) {
         // Only use the JPEG if it is actually smaller
         dataUrl = jpegDataUrl;
       }
@@ -147,31 +143,33 @@ this.shooter = (function() { // eslint-d
       return clipboard.copy(url).then((copied) => {
         return callBackground("openShot", { url, copied });
       });
     }, (error) => {
       if ('popupMessage' in error && (error.popupMessage == "REQUEST_ERROR" || error.popupMessage == 'CONNECTION_ERROR')) {
         // The error has been signaled to the user, but unlike other errors (or
         // success) we should not abort the selection
         deactivateAfterFinish = false;
+        // We need to unhide the UI since screenshotPage() hides it.
+        ui.iframe.unhide();
         return;
       }
       if (error.name != "BackgroundError") {
         // BackgroundError errors are reported in the Background page
         throw error;
       }
     }).then(() => {
       if (deactivateAfterFinish) {
         uicontrol.deactivate();
       }
     }));
   };
 
-  exports.downloadShot = function(selectedPos) {
-    let dataUrl = screenshotPage(selectedPos);
+  exports.downloadShot = function(selectedPos, previewDataUrl) {
+    let dataUrl = previewDataUrl || screenshotPage(selectedPos);
     let promise = Promise.resolve(dataUrl);
     if (!dataUrl) {
       promise = callBackground(
         "screenshotPage",
         selectedPos.asJson(),
         {
           scrollX: window.scrollX,
           scrollY: window.scrollY,
@@ -191,25 +189,51 @@ this.shooter = (function() { // eslint-d
           location: selectedPos
         }
       });
       ui.triggerDownload(dataUrl, shotObject.filename);
       uicontrol.deactivate();
     }));
   };
 
-  exports.copyShot = function(selectedPos) {
-    let dataUrl = screenshotPage(selectedPos);
+  let copyInProgress = null;
+  exports.copyShot = function(selectedPos, previewDataUrl) {
+    // This is pretty slow. We'll ignore additional user triggered copy events
+    // while it is in progress.
+    if (copyInProgress) {
+      return;
+    }
+    // A max of five seconds in case some error occurs.
+    copyInProgress = setTimeout(() => {
+      copyInProgress = null;
+    }, 5000);
+
+    let unsetCopyInProgress = () => {
+      if (copyInProgress) {
+        clearTimeout(copyInProgress);
+        copyInProgress = null;
+      }
+    }
+    let dataUrl = previewDataUrl || screenshotPage(selectedPos);
     let blob = blobConverters.dataUrlToBlob(dataUrl);
     catcher.watchPromise(callBackground("copyShotToClipboard", blob).then(() => {
       uicontrol.deactivate();
-    }));
+      unsetCopyInProgress();
+    }, unsetCopyInProgress));
   };
 
   exports.sendEvent = function(...args) {
+    let maybeOptions = args[args.length - 1];
+
+    if (typeof maybeOptions === "object") {
+      maybeOptions.incognito = browser.extension.inIncognitoContext;
+    } else {
+      args.push({incognito: browser.extension.inIncognitoContext});
+    }
+
     callBackground("sendEvent", ...args);
   };
 
   catcher.watchFunction(() => {
     shotObject = new AbstractShot(
       backend,
       randomString(RANDOM_STRING_LENGTH) + "/" + domainFromUrl(location),
       {
--- a/browser/extensions/screenshots/webextension/selector/ui.js
+++ b/browser/extensions/screenshots/webextension/selector/ui.js
@@ -1,46 +1,25 @@
-/* globals log, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, buildSettings */
+/* globals log, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, buildSettings blobConverters */
 
 "use strict";
 
 this.ui = (function() { // eslint-disable-line no-unused-vars
   let exports = {};
   const SAVE_BUTTON_HEIGHT = 50;
 
   const { watchFunction } = catcher;
 
-  // The <body> tag itself can have margins and offsets, which need to be used when
-  // setting the position of the boxEl.
-  function getBodyRect() {
-    if (getBodyRect.cached) {
-      return getBodyRect.cached;
-    }
-    let rect = document.body.getBoundingClientRect();
-    let cached = {
-      top: rect.top + window.scrollY,
-      bottom: rect.bottom + window.scrollY,
-      left: rect.left + window.scrollX,
-      right: rect.right + window.scrollX
-    };
-    // FIXME: I can't decide when this is necessary
-    // *not* necessary on http://patriciogonzalezvivo.com/2015/thebookofshaders/
-    // (actually causes mis-selection there)
-    // *is* necessary on http://atirip.com/2015/03/17/sorry-sad-state-of-matrix-transforms-in-browsers/
-    cached = {top: 0, bottom: 0, left: 0, right: 0};
-    getBodyRect.cached = cached;
-    return cached;
-  }
-
   exports.isHeader = function(el) {
     while (el) {
       if (el.classList &&
           (el.classList.contains("myshots-button") ||
            el.classList.contains("visible") ||
-           el.classList.contains("full-page"))) {
+           el.classList.contains("full-page") ||
+           el.classList.contains("cancel-shot"))) {
         return true;
       }
       el = el.parentNode;
     }
     return false;
   }
 
   let substitutedCss = inlineSelectionCss.replace(/MOZ_EXTENSION([^"]+)/g, (match, filename) => {
@@ -63,21 +42,32 @@ this.ui = (function() { // eslint-disabl
       clearTimeout(this.sizeTracking.windowDelayer);
     }
     this.sizeTracking.windowDelayer = setTimeout(watchFunction(() => {
       this.updateElementSize(true);
     }), 50);
   }
 
   function localizeText(doc) {
-    let els = doc.querySelectorAll("[data-l10n-id]");
+    let els = doc.querySelectorAll("[data-l10n-id], [data-l10n-title]");
     for (let el of els) {
       let id = el.getAttribute("data-l10n-id");
-      let text = browser.i18n.getMessage(id);
-      el.textContent = text;
+      if (id) {
+        let text = browser.i18n.getMessage(id);
+        el.textContent = text;
+      }
+      let title = el.getAttribute("data-l10n-title");
+      if (title) {
+        let titleText = browser.i18n.getMessage(title);
+        let sanitized = titleText && titleText.replace("&", "&amp;")
+                                              .replace('"', "&quot;")
+                                              .replace("<", "&lt;")
+                                              .replace(">", "&gt;");
+        el.setAttribute("title", sanitized);
+      }
     }
   }
 
   function highContrastCheck(win) {
     let result, doc, el;
     doc = win.document;
     el = doc.createElement("div");
     el.style.backgroundImage = "url('#')";
@@ -100,16 +90,18 @@ this.ui = (function() { // eslint-disabl
   function renderDownloadNotice(initAtBottom = false) {
     let notice = makeEl("table", "notice");
     notice.innerHTML = `
       <div class="notice-tooltip">
         <p data-l10n-id="downloadOnlyDetails"></p>
         <ul>
           <li data-l10n-id="downloadOnlyDetailsPrivate"></li>
           <li data-l10n-id="downloadOnlyDetailsNeverRemember"></li>
+          <li data-l10n-id="downloadOnlyDetailsESR"></li>
+          <li data-l10n-id="downloadOnlyDetailsNoUploadPref"></li>
         </ul>
       </div>
       <tbody>
         <tr class="notice-wrapper">
           <td class="notice-content" data-l10n-id="downloadOnlyNotice"></td>
           <td class="notice-help"></td>
         </tr>
       <tbody>`;
@@ -215,23 +207,25 @@ this.ui = (function() { // eslint-disabl
         document.documentElement.clientWidth,
         document.body.clientWidth,
         document.documentElement.scrollWidth,
         document.body.scrollWidth);
       if (width !== this.sizeTracking.lastWidth) {
         this.sizeTracking.lastWidth = width;
         this.element.style.width = width + "px";
         // Since this frame has an absolute position relative to the parent
-        // document, if the parent document has a max-width that is narrower
-        // than the viewport, then the x of the parent document is not at 0 of
-        // the viewport. That makes the frame shifted to the right. This left
-        // margin negates that.
-        let boundingRect = document.body.getBoundingClientRect();
-        if (boundingRect.x) {
-          this.element.style.marginLeft = `-${boundingRect.x}px`;
+        // document, if the parent document's body has a relative position and
+        // left and/or top not at 0, then the left and/or top of the parent
+        // document's body is not at (0, 0) of the viewport. That makes the
+        // frame shifted relative to the viewport. These margins negates that.
+        if (window.getComputedStyle(document.body).position === "relative") {
+          let docBoundingRect = document.documentElement.getBoundingClientRect();
+          let bodyBoundingRect = document.body.getBoundingClientRect();
+          this.element.style.marginLeft = `-${bodyBoundingRect.right - docBoundingRect.right}px`;
+          this.element.style.marginTop = `-${bodyBoundingRect.bottom - docBoundingRect.bottom}px`;
         }
       }
       if (force && visible) {
         this.element.style.display = "";
       }
     },
 
     initSizeWatch() {
@@ -297,23 +291,24 @@ this.ui = (function() { // eslint-disabl
                  <div class="preview-overlay">
                    <div class="fixed-container">
                      <div class="face-container">
                        <div class="eye left"><div class="eyeball"></div></div>
                        <div class="eye right"><div class="eyeball"></div></div>
                        <div class="face"></div>
                      </div>
                      <div class="preview-instructions" data-l10n-id="screenshotInstructions"></div>
+                     <button class="cancel-shot">${browser.i18n.getMessage("cancelScreenshot")}</button>
                      <div class="myshots-all-buttons-container">
                        ${isDownloadOnly() ? '' : `
-                         <button class="myshots-button myshots-link" tabindex="1" data-l10n-id="myShotsLink"></button>
+                         <button class="myshots-button" tabindex="1" data-l10n-id="myShotsLink"></button>
                          <div class="spacer"></div>
                        `}
-                       <button class="myshots-button visible" tabindex="2" data-l10n-id="saveScreenshotVisibleArea"></button>
-                       <button class="myshots-button full-page" tabindex="3" data-l10n-id="saveScreenshotFullPage"></button>
+                       <button class="visible" tabindex="2" data-l10n-id="saveScreenshotVisibleArea"></button>
+                       <button class="full-page" tabindex="3" data-l10n-id="saveScreenshotFullPage"></button>
                      </div>
                    </div>
                  </div>
                </body>`;
             installHandlerOnDocument(this.document);
             if (this.addClassName) {
               this.document.body.className = this.addClassName;
             }
@@ -324,16 +319,18 @@ this.ui = (function() { // eslint-disabl
             if (!(isDownloadOnly())) {
               overlay.querySelector(".myshots-button").addEventListener(
                 "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onOpenMyShots)));
             }
             overlay.querySelector(".visible").addEventListener(
               "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickVisible)));
             overlay.querySelector(".full-page").addEventListener(
               "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickFullPage)));
+            overlay.querySelector(".cancel-shot").addEventListener(
+              "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickCancel)));
             resolve();
           }), {once: true});
           document.body.appendChild(this.element);
         } else {
           resolve();
         }
       });
     },
@@ -377,17 +374,21 @@ this.ui = (function() { // eslint-disabl
       util.removeNode(this.element);
       this.element = null;
       this.document = null;
     }
   };
 
   function getAttributeText(l10nID) {
     let text = browser.i18n.getMessage(l10nID);
-    return text && text.replace('"', "&quot;");
+    return text &&
+      text.replace("&", "&amp;")
+        .replace('"', "&quot;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;");
   }
 
   let iframePreview = exports.iframePreview = {
     element: null,
     document: null,
     display(installHandlerOnDocument, standardOverlayCallbacks) {
       return new Promise((resolve, reject) => {
         if (!this.element) {
@@ -405,28 +406,33 @@ this.ui = (function() { // eslint-disabl
                 <style>${substitutedCss}</style>
                 <title></title>
               </head>
               <body>
                 <div class="preview-overlay">
                   <div class="preview-image">
                     <div class="preview-buttons">
                       <button class="highlight-button-cancel"
-                        title="${getAttributeText("cancelScreenshot")}"></button>
+                        data-l10n-title="cancelScreenshot"><img
+                        src="${browser.extension.getURL("icons/cancel.svg")}" /></button>
                       <button class="highlight-button-copy"
-                        title="${getAttributeText("copyScreenshot")}"></button>
+                        data-l10n-title="copyScreenshot"><img
+                        src="${browser.extension.getURL("icons/copy.svg")}" /></button>
                       ${isDownloadOnly() ?
                         `<button class="highlight-button-download download-only-button"
-                                 title="${getAttributeText("downloadScreenshot")}"
-                                 data-l10n-id="downloadScreenshot"></button>` :
+                          data-l10n-title="downloadScreenshot"><img
+                          src="${browser.extension.getURL("icons/download.svg")}"
+                          />${browser.i18n.getMessage("downloadScreenshot")}</button>` :
                         `<button class="highlight-button-download"
-                                 title="${getAttributeText("downloadScreenshot")}"></button>
+                          data-l10n-title="downloadScreenshot"><img
+                          src="${browser.extension.getURL("icons/download.svg")}" /></button>
                          <button class="preview-button-save"
-                                 title="${getAttributeText("saveScreenshotSelectedArea")}"
-                                 data-l10n-id="saveScreenshotSelectedArea"></button>`
+                          data-l10n-title="saveScreenshotSelectedArea"><img
+                          src="${browser.extension.getURL("icons/cloud.svg")}"
+                          />${browser.i18n.getMessage("saveScreenshotSelectedArea")}</button>`
                       }
                     </div>
                   </div>
                 </div>
               </body>`;
             installHandlerOnDocument(this.document);
             this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir");
             this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale");
@@ -577,17 +583,16 @@ this.ui = (function() { // eslint-disabl
           callbacks.copy(e);
           e.preventDefault();
           e.stopPropagation();
         }));
         this.copy.style.display = "";
       } else {
         this.copy.style.display = "none";
       }
-      let bodyRect = getBodyRect();
 
       let winBottom = window.innerHeight;
       let pageYOffset = window.pageYOffset;
 
       if ((pos.right - pos.left) < 78 || (pos.bottom - pos.top) < 78) {
         this.el.classList.add("small-selection");
       } else {
         this.el.classList.remove("small-selection");
@@ -601,35 +606,35 @@ this.ui = (function() { // eslint-disabl
         this.el.classList.remove("bottom-selection");
       }
 
       if (pos.right < 200) {
         this.el.classList.add("left-selection");
       } else {
         this.el.classList.remove("left-selection");
       }
-      this.el.style.top = (pos.top - bodyRect.top) + "px";
-      this.el.style.left = (pos.left - bodyRect.left) + "px";
-      this.el.style.height = (pos.bottom - pos.top - bodyRect.top) + "px";
-      this.el.style.width = (pos.right - pos.left - bodyRect.left) + "px";
+      this.el.style.top = `${pos.top}px`;
+      this.el.style.left = `${pos.left}px`;
+      this.el.style.height = `${pos.bottom - pos.top}px`;
+      this.el.style.width = `${pos.right - pos.left}px`;
       this.bgTop.style.top = "0px";
-      this.bgTop.style.height = (pos.top - bodyRect.top) + "px";
+      this.bgTop.style.height = `${pos.top}px`;
       this.bgTop.style.left = "0px";
       this.bgTop.style.width = "100%";
-      this.bgBottom.style.top = (pos.bottom - bodyRect.top) + "px";
+      this.bgBottom.style.top = `${pos.bottom}px`;
       this.bgBottom.style.height = "100vh";
       this.bgBottom.style.left = "0px";
       this.bgBottom.style.width = "100%";
-      this.bgLeft.style.top = (pos.top - bodyRect.top) + "px";
-      this.bgLeft.style.height = pos.bottom - pos.top + "px";
+      this.bgLeft.style.top = `${pos.top}px`;
+      this.bgLeft.style.height = `${pos.bottom - pos.top}px`;
       this.bgLeft.style.left = "0px";
-      this.bgLeft.style.width = (pos.left - bodyRect.left) + "px";
-      this.bgRight.style.top = (pos.top - bodyRect.top) + "px";
-      this.bgRight.style.height = pos.bottom - pos.top + "px";
-      this.bgRight.style.left = (pos.right - bodyRect.left) + "px";
+      this.bgLeft.style.width = `${pos.left}px`;
+      this.bgRight.style.top = `${pos.top}px`;
+      this.bgRight.style.height = `${pos.bottom - pos.top}px`;
+      this.bgRight.style.left = `${pos.right}px`;
       this.bgRight.style.width = "100%";
       // the download notice is injected into an iframe that matches the document size
       // in order to reposition it on scroll we need to bind an updated positioning
       // function to some window events.
       this.repositionDownloadNotice = () => {
         if (this.downloadNotice) {
           const currentYOffset = window.pageYOffset;
           const currentWinBottom = window.innerHeight;
@@ -639,37 +644,16 @@ this.ui = (function() { // eslint-disabl
 
       if (this.downloadNotice) {
         this.downloadNotice.style.top = (pageYOffset + winBottom - 60) + "px";
         // event callbacks are delayed 100ms each to keep from overloading things
         this.windowChangeStop = this.delayExecution(100, this.repositionDownloadNotice);
         window.addEventListener('scroll', watchFunction(assertIsTrusted(this.windowChangeStop)));
         window.addEventListener('resize', watchFunction(assertIsTrusted(this.windowChangeStop)));
       }
-
-      if (!(this.isElementInViewport(this.buttons))) {
-        this.cancel.style.position = this.download.style.position = "fixed";
-        this.cancel.style.left = (pos.left - bodyRect.left - 50) + "px";
-        this.download.style.left = ((pos.left - bodyRect.left - 100)) + "px";
-        this.cancel.style.top = this.download.style.top = (pos.top - bodyRect.top) + "px";
-        if (this.save) {
-          this.save.style.position = "fixed";
-          this.save.style.left = ((pos.left - bodyRect.left) - 190) + "px";
-          this.save.style.top = (pos.top - bodyRect.top) + "px";
-        }
-      } else {
-        this.cancel.style.position = this.download.style.position = "initial";
-        this.cancel.style.top = this.download.style.top = 0;
-        this.cancel.style.left = this.download.style.left = 0;
-        if (this.save) {
-          this.save.style.position = "initial";
-          this.save.style.top = 0;
-          this.save.style.left = 0;
-        }
-      }
     },
 
     // used to eventually move the download-only warning
     // when a user ends scrolling or ends resizing a window
     delayExecution(delay, cb) {
       let timer;
       return function() {
         if (typeof timer !== 'undefined') {
@@ -695,41 +679,55 @@ this.ui = (function() { // eslint-disabl
     _createEl() {
       let boxEl = this.el;
       if (boxEl) {
         return;
       }
       boxEl = makeEl("div", "highlight");
       let buttons = makeEl("div", "highlight-buttons");
       let cancel = makeEl("button", "highlight-button-cancel");
+      let cancelImg = makeEl("img");
+      cancelImg.src = browser.extension.getURL("icons/cancel.svg");
       cancel.title = browser.i18n.getMessage("cancelScreenshot");
+      cancel.appendChild(cancelImg);
       buttons.appendChild(cancel);
 
       let copy = makeEl("button", "highlight-button-copy");
       copy.title = browser.i18n.getMessage("copyScreenshot");
+      let copyImg = makeEl("img");
+      copyImg.src = browser.extension.getURL("icons/copy.svg");
+      copy.appendChild(copyImg);
       buttons.appendChild(copy);
 
       let download, save;
 
       if (isDownloadOnly()) {
         download = makeEl("button", "highlight-button-download download-only-button");
+        let downloadImg = makeEl("img");
+        downloadImg.src = browser.extension.getURL("icons/download.svg");
+        download.appendChild(downloadImg);
+        download.append(browser.i18n.getMessage("downloadScreenshot"));
         download.title = browser.i18n.getMessage("downloadScreenshot");
-        download.textContent = browser.i18n.getMessage("downloadScreenshot");
       } else {
         download = makeEl("button", "highlight-button-download");
         download.title = browser.i18n.getMessage("downloadScreenshot");
+        let downloadImg = makeEl("img");
+        downloadImg.src = browser.extension.getURL("icons/download.svg");
+        download.appendChild(downloadImg);
         save = makeEl("button", "highlight-button-save");
-        save.textContent = browser.i18n.getMessage("saveScreenshotSelectedArea");
+        let saveImg = makeEl("img");
+        saveImg.src = browser.extension.getURL("icons/cloud.svg");
+        save.appendChild(saveImg);
+        save.append(browser.i18n.getMessage("saveScreenshotSelectedArea"));
         save.title = browser.i18n.getMessage("saveScreenshotSelectedArea");
       }
       buttons.appendChild(download);
       if (save) {
         buttons.appendChild(save);
       }
-      this.buttons = buttons;
       this.cancel = cancel;
       this.download = download;
       this.copy = copy;
       this.save = save;
       boxEl.appendChild(buttons);
       for (let name of movements) {
         let elTarget = makeEl("div", "mover-target direction-" + name);
         let elMover = makeEl("div", "mover");
@@ -788,21 +786,16 @@ this.ui = (function() { // eslint-disabl
         if (target.nodeType === document.ELEMENT_NODE && target.classList.contains("highlight-buttons")) {
           return true;
         }
         target = target.parentNode;
       }
       return false;
     },
 
-    isElementInViewport(el) {
-      let rect = el.getBoundingClientRect();
-      return (rect.right <= window.innerWidth);
-    },
-
     clearSaveDisabled() {
       if (!this.save) {
         // Happens if we try to remove the disabled status after the worker
         // has been shut down
         return;
       }
       this.save.removeAttribute("disabled");
     },
@@ -867,17 +860,18 @@ this.ui = (function() { // eslint-disabl
       util.removeNode(this.el);
       this.el = this.xEl = this.yEl = null;
     }
   };
 
   exports.Preview = {
     display(dataUrl, showCropWarning) {
       let img = makeEl("IMG");
-      img.src = dataUrl;
+      let imgBlob = blobConverters.dataUrlToBlob(dataUrl);
+      img.src = URL.createObjectURL(imgBlob);
       iframe.document().querySelector(".preview-image").appendChild(img);
       if (showCropWarning && !(isDownloadOnly())) {
         let imageCroppedEl = makeEl("table", "notice");
         imageCroppedEl.style.bottom = "10px";
         imageCroppedEl.innerHTML = `<tbody>
           <tr class="notice-wrapper">
             <td class="notice-content"></td>
           </tr>
--- a/browser/extensions/screenshots/webextension/selector/uicontrol.js
+++ b/browser/extensions/screenshots/webextension/selector/uicontrol.js
@@ -119,16 +119,25 @@ this.uicontrol = (function() {
     H3: true,
     H4: true,
     H5: true,
     H6: true
   };
 
   let captureType;
 
+  function removeDimensionLimitsOnFullPageShot() {
+    if (captureType === "fullPageTruncated") {
+      captureType = "fullPage";
+      selectedPos = new Selection(
+        0, 0,
+        getDocumentWidth(), getDocumentHeight());
+    }
+  }
+
   let standardDisplayCallbacks = {
     cancel: () => {
       sendEvent("cancel-shot", "overlay-cancel-button");
       exports.deactivate();
     }, save: () => {
       sendEvent("save-shot", "overlay-save-button");
       shooter.takeShot("selection", selectedPos);
     }, download: () => {
@@ -140,16 +149,22 @@ this.uicontrol = (function() {
     }
   };
 
   let standardOverlayCallbacks = {
     cancel: () => {
       sendEvent("cancel-shot", "cancel-preview-button");
       exports.deactivate();
     },
+    onClickCancel: e => {
+      sendEvent("cancel-shot", "cancel-selection-button");
+      e.preventDefault();
+      e.stopPropagation();
+      exports.deactivate();
+    },
     onOpenMyShots: () => {
       sendEvent("goto-myshots", "selection-button");
       callBackground("openMyShots")
         .then(() => exports.deactivate())
         .catch(() => {
           // Handled in communication.js
         });
     },
@@ -180,30 +195,27 @@ this.uicontrol = (function() {
       setState("previewing");
     },
     onSavePreview: () => {
       sendEvent(`save-${captureType.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`, "save-preview-button");
       shooter.takeShot(captureType, selectedPos, dataUrl);
     },
     onDownloadPreview: () => {
       sendEvent(`download-${captureType.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`, "download-preview-button");
-
       // Downloaded shots don't have dimension limits
-      if (captureType === "fullPageTruncated") {
-        captureType = "fullPage";
-        selectedPos = new Selection(
-          0, 0,
-          getDocumentWidth(), getDocumentHeight());
-      }
-
-      shooter.downloadShot(selectedPos);
+      let previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl;
+      removeDimensionLimitsOnFullPageShot();
+      shooter.downloadShot(selectedPos, previewDataUrl);
     },
     onCopyPreview: () => {
       sendEvent(`copy-${captureType.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`, "copy-preview-button");
-      shooter.copyShot(selectedPos);
+      // Copied shots don't have dimension limits
+      let previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl;
+      removeDimensionLimitsOnFullPageShot();
+      shooter.copyShot(selectedPos, previewDataUrl);
     }
   };
 
   /** Holds all the objects that handle events for each state: */
   let stateHandlers = {};
 
   function getState() {
     return getState.state;
@@ -363,17 +375,17 @@ this.uicontrol = (function() {
     elementFromPoint() {
       return ui.iframe.getElementFromPoint(
         this.x - window.pageXOffset,
         this.y - window.pageYOffset
       );
     }
 
     distanceTo(x, y) {
-      return Math.sqrt(Math.pow(this.x - x, 2), Math.pow(this.y - y));
+      return Math.sqrt(Math.pow(this.x - x, 2) + Math.pow(this.y - y, 2));
     }
   }
 
   /** *********************************************
    * all stateHandlers
    */
 
   let dataUrl;
@@ -411,28 +423,34 @@ this.uicontrol = (function() {
     cachedEl: null,
 
     start() {
       selectedPos = mousedownPos = null;
       this.cachedEl = null;
       watchPromise(ui.iframe.display(installHandlersOnDocument, standardOverlayCallbacks).then(() => {
         ui.iframe.usePreSelection();
         ui.Box.remove();
-        const handler = watchFunction(assertIsTrusted(keyupHandler));
-        document.addEventListener("keyup", handler);
-        registeredDocumentHandlers.push({name: "keyup", doc: document, handler, useCapture: false});
+        const upHandler = watchFunction(assertIsTrusted(keyupHandler));
+        document.addEventListener("keyup", upHandler);
+        registeredDocumentHandlers.push({name: "keyup", doc: document, upHandler, useCapture: false});
+        const downHandler = watchFunction(assertIsTrusted(keydownHandler));
+        document.addEventListener("keydown", downHandler);
+        registeredDocumentHandlers.push({name: "keydown", doc: document, downHandler, useCapture: false});
       }));
     },
 
     mousemove(event) {
       ui.PixelDimensions.display(event.pageX, event.pageY, event.pageX, event.pageY);
       if (event.target.classList &&
           (!event.target.classList.contains("preview-overlay"))) {
         // User is hovering over a toolbar button or control
         autoDetectRect = null;
+        if (this.cachedEl) {
+          this.cachedEl = null;
+        }
         ui.HoverBox.hide();
         return;
       }
       let el;
       if (event.target.classList && event.target.classList.contains("preview-overlay")) {
         // The hover is on the overlay, so we need to figure out the real element
         el = ui.iframe.getElementFromPoint(
           event.pageX + window.scrollX - window.pageXOffset,
@@ -865,22 +883,25 @@ this.uicontrol = (function() {
    * Selection communication
    */
 
    // If the slides module is loaded then we're supposed to onboard
   let shouldOnboard = typeof slides !== "undefined";
 
   exports.activate = function() {
     if (!document.body) {
-      callBackground("abortNoDocumentBody", document.documentElement.tagName);
+      callBackground("abortStartShot");
+      let tagName = String(document.documentElement.tagName || "").replace(/[^a-z0-9]/ig, "");
+      sendEvent("abort-start-shot", `document-is-${tagName}`);
       selectorLoader.unloadModules();
       return;
     }
     if (isFrameset()) {
-      callBackground("abortFrameset");
+      callBackground("abortStartShot");
+      sendEvent("abort-start-shot", "frame-page");
       selectorLoader.unloadModules();
       return;
     }
     addHandlers();
     if (shouldOnboard) {
       setState("onboarding");
     } else {
       setState("crosshairs");
@@ -935,16 +956,17 @@ this.uicontrol = (function() {
         if (handler[eventName]) {
           return handler[eventName](event);
         }
         return undefined;
       }).bind(null, eventName)));
       primedDocumentHandlers.set(eventName, fn);
     });
     primedDocumentHandlers.set("keyup", watchFunction(assertIsTrusted(keyupHandler)));
+    primedDocumentHandlers.set("keydown", watchFunction(assertIsTrusted(keydownHandler)));
     window.addEventListener('beforeunload', beforeunloadHandler);
   }
 
   let mousedownSetOnDocument = false;
 
   function installHandlersOnDocument(docObj) {
     for (let [eventName, handler] of primedDocumentHandlers) {
       let watchHandler = watchFunction(handler);
@@ -960,37 +982,45 @@ this.uicontrol = (function() {
     }
   }
 
   function beforeunloadHandler() {
     sendEvent("cancel-shot", "tab-load");
     exports.deactivate();
   }
 
-  function keyupHandler(event) {
-    if (event.shiftKey || event.altKey) {
-      // unused modifier keys
-      return;
-    }
+  function keydownHandler(event) {
+    // In MacOS, the keyup event for 'c' is not fired when performing cmd+c.
     if (event.code === "KeyC" && (event.ctrlKey || event.metaKey)) {
       callBackground("getPlatformOs").then(os => {
         if ((event.ctrlKey && os !== "mac") ||
             (event.metaKey && os === "mac")) {
           sendEvent("copy-shot", "keyboard-copy");
           shooter.copyShot(selectedPos);
         }
       }).catch(() => {
         // handled by catcher.watchPromise
       });
     }
+  }
+
+  function keyupHandler(event) {
+    if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
+      // unused modifier keys
+      return;
+    }
     if ((event.key || event.code) === "Escape") {
       sendEvent("cancel-shot", "keyboard-escape");
       exports.deactivate();
     }
-    if ((event.key || event.code) === "Enter" && getState.state === "selected") {
+    // Enter to trigger Save or Download by default. But if the user tabbed to
+    // select another button, then we do not want this.
+    if ((event.key || event.code) === "Enter"
+        && getState.state === "selected"
+        && ui.iframe.document().activeElement.tagName === "BODY") {
       if (ui.isDownloadOnly()) {
         sendEvent("download-shot", "keyboard-enter");
         shooter.downloadShot(selectedPos);
       } else {
         sendEvent("save-shot", "keyboard-enter");
         shooter.takeShot("selection", selectedPos);
       }
     }
--- a/browser/extensions/screenshots/webextension/sitehelper.js
+++ b/browser/extensions/screenshots/webextension/sitehelper.js
@@ -53,17 +53,17 @@ this.sitehelper = (function() {
   document.addEventListener("delete-everything", catcher.watchFunction((event) => {
     // FIXME: reset some data in the add-on
   }, false));
 
   document.addEventListener("request-login", catcher.watchFunction((event) => {
     let shotId = event.detail;
     catcher.watchPromise(callBackground("getAuthInfo", shotId || null).then((info) => {
       sendBackupCookieRequest(info.authHeaders);
-      sendCustomEvent("login-successful", {deviceId: info.deviceId, isOwner: info.isOwner});
+      sendCustomEvent("login-successful", {deviceId: info.deviceId, isOwner: info.isOwner, backupCookieRequest: true});
     }));
   }));
 
   document.addEventListener("request-onboarding", catcher.watchFunction((event) => {
     callBackground("requestOnboarding");
   }));
 
   // Depending on the script loading order, the site might get the addon-present event,