From 30a2601d35cc0d6aae1e0a681b69a0e6a995c751 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 6 Jan 2020 20:49:01 +0100 Subject: [PATCH 1/9] Recording - automatically add sites to containers as you browse --- src/css/content.css | 96 +++++- src/css/popup.css | 75 ++++- src/img/container-record-disabled.svg | 5 + src/img/container-record-enabled.svg | 5 + src/js/.eslintrc.js | 4 +- src/js/background/assignManager.js | 31 +- src/js/background/backgroundLogic.js | 45 ++- src/js/background/index.html | 1 + src/js/background/messageHandler.js | 158 +++++++++- src/js/background/recordManager.js | 177 +++++++++++ src/js/content-script.js | 413 ++++++++++++++++++++++++-- src/js/popup-bootstrap.js | 58 ++++ src/js/popup.js | 262 ++++++++++++++-- src/popup.html | 25 +- src/recording.html | 11 + test/features/recording.test.js | 87 ++++++ test/helper.js | 12 + 17 files changed, 1369 insertions(+), 96 deletions(-) create mode 100644 src/img/container-record-disabled.svg create mode 100644 src/img/container-record-enabled.svg create mode 100644 src/js/background/recordManager.js create mode 100644 src/js/popup-bootstrap.js create mode 100644 src/recording.html create mode 100644 test/features/recording.test.js diff --git a/src/css/content.css b/src/css/content.css index 56818873..6433e426 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -1,4 +1,72 @@ -.container-notification { +#container-notifications, +#container-notifications * { + all: unset; +} + +#container-notifications { + display: block; + inline-size: 100vw; + inset-block-start: 0; /* stylelint-disable-line property-no-unknown */ + inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */ + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; + offset-block-start: 0; + offset-inline-start: 0; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; + position: fixed; + z-index: 999999999999; +} + +#container-notifications > iframe { + border: 1px solid; + inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */ + inset-inline-end: 4px; /* stylelint-disable-line property-no-unknown */ + offset-block-start: 4px; + offset-inline-end: 4px; + position: absolute; + z-index: 2; +} + +#container-notifications > div.recording { + z-index: 1; +} + +#container-notifications > div { + display: block; + max-block-size: 0; + overflow: hidden; + position: relative; + transition: all 1s cubic-bezier(0.07, 0.95, 0, 1); +} + +#container-notifications > div.show { + max-block-size: 500px; + transition: all 1s ease-in; +} + +#container-notifications > div:hover, +#container-notifications > div:focus, +#container-notifications > div:visited { + color: #003f07; + text-decoration: none; +} + +#container-notifications > div > div.real { + inset-block-end: 0; /* stylelint-disable-line property-no-unknown */ + offset-block-end: 0; + position: absolute; +} + +#container-notifications > div > div.dummy { + visibility: hidden; +} + +#container-notifications > div > div > div { align-items: center; background: #efefef; color: #003f07; @@ -6,22 +74,34 @@ font: 12px sans-serif; inline-size: 100vw; justify-content: start; - offset-block-start: 0; - offset-inline-start: 0; + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; padding-block-end: 8px; padding-block-start: 8px; padding-inline-end: 8px; padding-inline-start: 8px; - position: fixed; text-align: start; - transform: translateY(-100%); - transition: transform 0.3s cubic-bezier(0.07, 0.95, 0, 1) 0.3s; - z-index: 999999999999; } -.container-notification img { +#container-notifications > div > div > div > .title { + font-weight: bold; + padding-left: 0.5rem; + padding-right: 1rem; +} + +#container-notifications > div > div > div > .logo { block-size: 16px; display: inline-block; inline-size: 16px; margin-inline-end: 3px; } + +#container-notifications > div.recording > div > div { + background: #fcc; +} + +#container-notifications > div.recording > div > div > .title { + color: red; +} diff --git a/src/css/popup.css b/src/css/popup.css index d5f32958..fc4d4fa4 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -18,6 +18,7 @@ html { } body { + display: flex; font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif; inline-size: calc(var(--overflow-size) + 299px); max-inline-size: calc(var(--overflow-size) + 299px); @@ -246,6 +247,7 @@ table { /* Panels keep everything together */ .panel { display: flex; + flex: 1; flex-direction: column; justify-content: space-between; min-block-size: 400px; @@ -451,7 +453,9 @@ manage things like container crud */ } .container-panel-controls { - display: flex; + display: grid; + grid-auto-flow: column; + grid-column-gap: var(--inline-item-space-size); justify-content: flex-end; margin-block-end: var(--block-line-space-size); margin-block-start: var(--block-line-space-size); @@ -459,24 +463,51 @@ manage things like container crud */ margin-inline-start: var(--inline-item-space-size); } -#container-panel #sort-containers-link { +#container-panel .container-panel-controls > * { align-items: center; block-size: var(--block-url-label-size); border: 1px solid #d8d8d8; border-radius: var(--small-radius); color: var(--title-text-color); display: flex; + flex-direction: column; + flex-wrap: wrap; font-size: var(--small-text-size); inline-size: var(--inline-button-size); justify-content: center; text-decoration: none; } -#container-panel #sort-containers-link:hover, -#container-panel #sort-containers-link:focus { +#container-panel .container-panel-controls > a:hover, +#container-panel .container-panel-controls > a:focus, +#container-panel .container-panel-controls > .disabled { background: #f2f2f2; } +#container-panel .container-panel-controls > #record-link { + inline-size: var(--block-url-label-size); +} + +.container-panel-controls > #record-link > .icon { + margin-block-end: 4px; + margin-block-start: 4px; + margin-inline-end: 4px; + margin-inline-start: 4px; +} + +#record-link > .icon { + filter: invert(0.2); +} + +#record-link.disabled > .icon { + filter: invert(0.6); +} + +#record-link.active > .icon, +.container-record-banner img { + filter: invert(0.5) sepia(1) saturate(127) hue-rotate(360deg); +} + span ~ .panel-header-text { padding-block-end: 0; padding-block-start: 0; @@ -674,7 +705,8 @@ span ~ .panel-header-text { inline-size: calc(var(--column-panel-inline-size) - 58px); } -#container-info-hideorshow { +#container-info-hideorshow, +#container-record-banner { margin-block-start: 4px; } @@ -704,7 +736,8 @@ span ~ .panel-header-text { } .container-info-has-tabs, -.container-info-tab-row { +.container-info-tab-row, +.container-record-banner { align-items: center; display: flex; flex: 0 0 28px; @@ -718,13 +751,25 @@ span ~ .panel-header-text { padding-inline-start: 16px; } +.container-record-banner { + background: #fcc; + color: red; +} + .container-info-has-tabs img, -.container-info-tab-row img { +.container-info-tab-row img, +.container-record-banner img { block-size: 16px; flex: 0 0 16px; margin-inline-end: 4px; } +.container-record-banner img { + block-size: 24px; + flex: 0 0 24px; + margin-inline-end: 6px; +} + .container-info-tab-row img[src=""] { margin-inline-end: 0; } @@ -749,7 +794,9 @@ span ~ .panel-header-text { background-color: #ebebeb; } -.edit-containers-exit-text { +.edit-containers-exit-text, +.container-record-exit-text, +.container-record-banner-text { align-items: center; background: var(--primary-action-color); block-size: 100%; @@ -760,11 +807,13 @@ span ~ .panel-header-text { padding-inline-start: 30%; } -.edit-containers-panel-footer { +.edit-containers-panel-footer, +.container-record-panel-footer { background: var(--primary-action-color); } -.exit-edit-mode-link img { +.exit-edit-mode-link img, +.exit-record-mode-link img { block-size: 16px; display: inline; filter: grayscale(100%) brightness(5); @@ -797,11 +846,13 @@ span ~ .panel-header-text { overflow: hidden; /* Bugfix: issue 948 */ } -#edit-sites-assigned { +#edit-sites-assigned, +#record-sites-assigned { flex: 1000; /* Bugfix: issue 948 */ } -#edit-sites-assigned h3 { +#edit-sites-assigned h3, +#record-sites-assigned h3 { font-size: 14px; font-weight: normal; padding-block-end: 6px; diff --git a/src/img/container-record-disabled.svg b/src/img/container-record-disabled.svg new file mode 100644 index 00000000..31fa3c47 --- /dev/null +++ b/src/img/container-record-disabled.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/img/container-record-enabled.svg b/src/img/container-record-enabled.svg new file mode 100644 index 00000000..23f873b6 --- /dev/null +++ b/src/img/container-record-enabled.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f9..bbef9ebd 100644 --- a/src/js/.eslintrc.js +++ b/src/js/.eslintrc.js @@ -3,10 +3,12 @@ module.exports = { "../../.eslintrc.js" ], "globals": { + "recordManager": "readonly", "assignManager": true, "badge": true, "backgroundLogic": true, "identityState": true, - "messageHandler": true + "messageHandler": true, + "browserAPIInjector": "readonly" } }; diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index b48db759..0d18656c 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -116,6 +116,13 @@ const assignManager = { this.storageArea.setExempted(pageUrl, m.tabId); return true; }, + + _determineSetAssignmentDueToRecording(tabId, url, siteSettings) { + if (siteSettings) { return false; } // Assignment already set + if (!recordManager.isRecordingTabId(tabId)) { return false; } + if (!url.startsWith("http")) { return false; } // Exclude moz-extension:// requests + return true; + }, // Before a request is handled by the browser we decide if we should route through a different container async onBeforeRequest(options) { @@ -141,6 +148,12 @@ const assignManager = { return {}; } const userContextId = this.getUserContextIdFromCookieStore(tab); + + // Recording + if (this._determineSetAssignmentDueToRecording(tab.id, options.url, siteSettings)) { + await this._setOrRemoveAssignment(tab.id, options.url, userContextId, false); + } + if (!siteSettings || userContextId === siteSettings.userContextId || this.storageArea.isExempted(options.url, tab.id)) { @@ -374,7 +387,7 @@ const assignManager = { // Context menu has stored context IDs as strings, so we need to coerce // the value to a string for accurate checking userContextId = String(userContextId); - + if (!remove) { const tabs = await browser.tabs.query({}); const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl); @@ -394,18 +407,20 @@ const assignManager = { userContextId, neverAsk: false }, exemptedTabIds); - actionName = "added"; + actionName = "Successfully set to always open in this container"; } else { await this.storageArea.remove(pageUrl); - actionName = "removed"; + actionName = "Successfully removed from this container"; } - browser.tabs.sendMessage(tabId, { - text: `Successfully ${actionName} site to always open in this container` + const hostname = new window.URL(pageUrl).hostname; + messageHandler.sendTabMessage(tabId, { + title: hostname, + text: actionName }); const tab = await browser.tabs.get(tabId); this.calculateContextMenu(tab); }, - + async _getAssignment(tab) { const cookieStore = this.getUserContextIdFromCookieStore(tab); // Ensure we have a cookieStore to assign to @@ -415,11 +430,11 @@ const assignManager = { } return false; }, - + _getByContainer(userContextId) { return this.storageArea.getByContainer(userContextId); }, - + removeContextMenu() { // There is a focus issue in this menu where if you change window with a context menu click // you get the wrong menu display because of async diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 5d71fecf..b562338e 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -7,7 +7,7 @@ const backgroundLogic = { "about:blank" ]), unhideQueue: [], - + async getExtensionInfo() { const manifestPath = browser.extension.getURL("manifest.json"); const response = await fetch(manifestPath); @@ -93,6 +93,29 @@ const backgroundLogic = { } }); }, + + asPromise(value) { + if (value === undefined) { return value; } + if (value instanceof Promise) { return value; } + return Promise.resolve(value); + }, + + asTabId(tabId) { + if (tabId === undefined || tabId === null) { + return browser.tabs.TAB_ID_NONE; + } + return tabId; + }, + + async getTabOrNull(tabId) { + tabId = this.asTabId(tabId); + if (tabId !== browser.tabs.TAB_ID_NONE) { + try { + return await browser.tabs.get(tabId); + } catch(e) { /* Assume tabId is invalid */ } + } + return null; + }, async getTabs(options) { const requiredArguments = ["cookieStoreId", "windowId"]; @@ -329,5 +352,23 @@ const backgroundLogic = { cookieStoreId(userContextId) { return `firefox-container-${userContextId}`; + }, + + async invokeBrowserMethod(name, args) { + let target = browser; + let indexOfDot; + while ((indexOfDot = name.indexOf(".")) !== -1) { + const targetName = name.substring(0, indexOfDot); + target = target[targetName]; + name = name.substring(indexOfDot + 1); + } + const method = target[name]; + let returnValue; + if (typeof method === "function" || (args && args.length > 0)) { + returnValue = method(...args); + } else { + returnValue = method; + } + return returnValue; } -}; \ No newline at end of file +}; diff --git a/src/js/background/index.html b/src/js/background/index.html index e167f0b6..0dc95a0e 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -14,6 +14,7 @@ ] --> + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 9578e6e2..d619141b 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -3,7 +3,7 @@ const messageHandler = { // We use this to catch redirected tabs that have just opened // If this were in platform we would change how the tab opens based on "new tab" link navigations such as ctrl+click LAST_CREATED_TAB_TIMER: 2000, - + init() { // Handles messages from webextension code browser.runtime.onMessage.addListener((m) => { @@ -37,6 +37,12 @@ const messageHandler = { return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value); }); break; + case "getRecording": + response = backgroundLogic.asPromise(recordManager.getTabId()); + break; + case "setOrRemoveRecording": + response = recordManager.setTabId(m.tabId); + break; case "sortTabs": backgroundLogic.sortTabs(); break; @@ -70,10 +76,17 @@ const messageHandler = { case "exemptContainerAssignment": response = assignManager._exemptTab(m); break; + case "invokeBrowserMethod": + response = backgroundLogic.asPromise(backgroundLogic.invokeBrowserMethod(m.name, m.args)); + break; } + return response; }); - + + // Monitor browserAction popup + this.browserAction.init(); + // Handles external messages from webextensions const externalExtensionAllowed = {}; browser.runtime.onMessageExternal.addListener(async (message, sender) => { @@ -213,6 +226,147 @@ const messageHandler = { }).catch((e) => { throw e; }); + }, + + /** + Sends a message to a tab, with following benefits: + 1. Waits until sending AND animating is fully complete + 2. Keeps retrying until succeeds (or too many attempts) + 3. Resends message if tab reloaded while sending/animating + 4. Stops without error if tab closed while sending/animating + */ + SendTabMessage: class { + constructor(tabId, message) { + this.tabId = tabId; + this.message = message; + } + + async send() { + const message = { to:"tab", content:this.message }; + const MAX_ATTEMPTS = 5; + let attempts = 0; + let succeeded = false; + do { + try { + if (this.tabLoading) { await this.tabLoading.promise; } + if (this.tabRemoved) { break; } + await browser.tabs.sendMessage(this.tabId, message); + succeeded = true; + } catch (e) { + if (this.tabRemoved) { break; } + if (attempts >= MAX_ATTEMPTS) { throw e; } + + attempts++; + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + } while (!succeeded); + } + + handleTabChangedStatus(status) { + if (status === "loading") { + if (!this.tabLoading) { + this.tabLoading = {}; + this.tabLoading.promise = new Promise((resolve) => { + this.tabLoading.resolve = resolve; + }); + } + } else { + if (this.tabLoading) { + this.tabLoading.resolve(); + this.tabLoading = null; + } + } + } + + handleTabRemoved() { + this.tabRemoved = true; + this.removeTabListeners(); + this.handleTabChangedStatus("complete"); + } + + addTabListeners() { + this.onTabsUpdated = (eventTabId, info) => { + if (this.tabId === eventTabId) { + this.handleTabChangedStatus(info.status); + } + }; + + this.onTabsRemoved = (eventTabId) => { + if (this.tabId === eventTabId) { + this.handleTabRemoved(); + } + }; + + browser.tabs.onUpdated.addListener(this.onTabsUpdated, { tabId: this.tabId, properties:["status"] }); + browser.tabs.onRemoved.addListener(this.onTabsRemoved); + } + + removeTabListeners() { + browser.tabs.onUpdated.removeListener(this.onTabsUpdated); + browser.tabs.onRemoved.removeListener(this.onTabsRemoved); + } + }, + + async sendTabMessage(tabId, message) { + const tab = await backgroundLogic.getTabOrNull(tabId); + if (!tab || tab.id === browser.tabs.TAB_ID_NONE) { throw new Error(`Cannot send message to tab ${tabId}`); } + + const sendMessage = new this.SendTabMessage(tabId, message); + sendMessage.addTabListeners(); + try { + await sendMessage.send(); + } catch (e) { + console.log(`Send Message Failed: ${e} ${tab.url}`); + throw e; + } finally { + sendMessage.removeTabListeners(); + } + }, + + // Holds current browserAction popup state, dispatches events + browserAction: { + init() { + browser.runtime.onConnect.addListener((port) => { + if (port.name === "browserActionPopup") { + this.onLoad(port); + } + }); + + browser.windows.onFocusChanged.addListener((windowId) => { + this.currentWindowId = windowId; + }); + }, + onLoad(port) { + // Note a new connection can arrive before existing connection is disconnected. + // Happens when you click on the browserAction button on two different windows + if (this.popup) { this.onUnload(); } + + const popup = this.popup = { windowId: this.currentWindowId }; + + port.onDisconnect.addListener(() => { + if (this.popup === popup) { + this.onUnload(); + this.popup = null; + } + }); + port.onMessage.addListener((msg) => { + if ("update" in msg) { + this.onUpdate(popup, msg.update); + } + }); + + window.dispatchEvent(new Event("BrowserActionPopupLoad")); + }, + onUnload() { + window.dispatchEvent(new Event("BrowserActionPopupUnload")); + }, + onUpdate(popup, update) { + if (update.width === 0) { delete update.width; } + if (update.height === 0) { delete update.height; } + Object.assign(popup, update); + } } }; diff --git a/src/js/background/recordManager.js b/src/js/background/recordManager.js new file mode 100644 index 00000000..7702ba18 --- /dev/null +++ b/src/js/background/recordManager.js @@ -0,0 +1,177 @@ +const recordManager = { + recording: null, + listening: null, + + Recording: class { + constructor(tab) { + if (tab) { + this.windowId = tab.windowId; + this.tabId = tab.id; + this.isTabActive = tab.active; + } else { + this.windowId = browser.windows.WINDOW_ID_NONE; + this.tabId = browser.tabs.TAB_ID_NONE; + this.isTabActive = false; + } + } + + get valid() { + return this.tabId !== browser.tabs.TAB_ID_NONE; + } + + async sendTabMessage() { + return messageHandler.sendTabMessage(this.tabId, this.tabMessage); + } + + async stop() { + if (!this.valid) { return; } + + recordManager.listening.enabled = false; + + // Update GUI + this.tabMessage = { recording: false, popup: false }; + const tab = await backgroundLogic.getTabOrNull(this.tabId); + // Don't try to send "stop recording" message to tab if already closed or showing an invalid page + if (tab && tab.url) { + return this.sendTabMessage(); + } + } + + async start() { + if (!this.valid) { return; } + + recordManager.listening.enabled = true; + + // Update GUI + const baPopup = messageHandler.browserAction.popup; + const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId); + this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} }; + const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") }); + const messagingTab = this.sendTabMessage(); + + return Promise.all([showingPage, messagingTab]); + } + + // Re-show recording state on page load + onTabsUpdated(tabId, changeInfo) { + if (this.tabId === tabId && changeInfo.status === "complete") { + this.sendTabMessage(); + } + } + + // Show/hide tabPopup on this tab show/hide + onTabsActivated(activeInfo) { + if (this.tabId === activeInfo.tabId) { + this.sendTabMessage(); + } + } + + // Keep track of tab's windowId + onTabsAttached(tabId, attachInfo) { + if (this.tabId === tabId) { + this.windowId = attachInfo.newWindowId; + } + } + + // Stop recording on close + onTabsRemoved(tabId) { + if (this.tabId === tabId) { + recordManager.setTabId(browser.tabs.TAB_ID_NONE); + } + } + + // Show/hide tabPopup on hide/show browserActionPopup + onToggleBrowserActionPopup(baPopupVisible, baPopup) { + if (this.windowId === baPopup.windowId && this.isTabActive) { + this.tabMessage.popup = !baPopupVisible; + this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height }; + this.sendTabMessage(); + } + } + }, + + Listening: class { + constructor() { + this._enabled = false; + } + + get enabled() { return this._enabled; } + + set enabled(enabled) { + if (this._enabled === !!enabled) { return; } + this._enabled = !!enabled; + + if (enabled) { + browser.tabs.onUpdated.addListener(this.onTabsUpdated, { properties: ["status"] }); + browser.tabs.onActivated.addListener(this.onTabsActivated); + browser.tabs.onAttached.addListener(this.onTabsAttached); + browser.tabs.onRemoved.addListener(this.onTabsRemoved); + window.addEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad); + window.addEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload); + } else { + browser.tabs.onUpdated.removeListener(this.onTabsUpdated); + browser.tabs.onActivated.removeListener(this.onTabsActivated); + browser.tabs.onAttached.removeListener(this.onTabsAttached); + browser.tabs.onRemoved.removeListener(this.onTabsRemoved); + window.removeEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad); + window.removeEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload); + } + } + + onTabsUpdated(...args) { recordManager.recording.onTabsUpdated(...args); } + onTabsActivated(...args) { recordManager.recording.onTabsActivated(...args); } + onTabsAttached(...args) { recordManager.recording.onTabsAttached(...args); } + onTabsRemoved(...args) { recordManager.recording.onTabsRemoved(...args); } + onBrowserActionPopupLoad() { recordManager.recording.onToggleBrowserActionPopup(true, messageHandler.browserAction.popup); } + onBrowserActionPopupUnload() { recordManager.recording.onToggleBrowserActionPopup(false, messageHandler.browserAction.popup); } + }, + + init() { + this.recording = new recordManager.Recording(); + this.listening = new recordManager.Listening(); + }, + + isRecordingTabId(tabId) { + if (!this.recording.valid) { return false; } + if (this.recording.tabId !== tabId) { return false; } + return true; + }, + + getTabId() { + return this.recording.tabId; + }, + + async setTabId(tabId) { + // Ensure tab is recordable + tabId = backgroundLogic.asTabId(tabId); + const tab = await backgroundLogic.getTabOrNull(tabId); + const wantRecordableTab = tabId !== browser.tabs.TAB_ID_NONE; + const isRecordableTab = tab && "cookieStoreId" in tab; + + // Invalid tab - stop recording & throw error + if (wantRecordableTab && !isRecordableTab) { + this.setTabId(browser.tabs.TAB_ID_NONE); // Don't wait for stop + throw new Error(`Recording not possible for tab with id ${tabId}`); + } + + // Already recording + if (this.recording.tabId === tabId) { return; } + + const oldRecording = this.recording; + const newRecording = this.recording = new recordManager.Recording(tab); + + // Don't wait for stop + oldRecording.stop(); + try { + // But DO wait for start + await newRecording.start(); + + // If error while starting, immediately stop, but don't wait + } catch (e) { + this.setTabId(browser.tabs.TAB_ID_NONE); + throw e; + } + } +}; + +recordManager.init(); \ No newline at end of file diff --git a/src/js/content-script.js b/src/js/content-script.js index 1bf6a6e4..9e0e58b1 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -1,46 +1,395 @@ -async function delayAnimation(delay = 350) { - return new Promise((resolve) => { - setTimeout(resolve, delay); - }); +function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); } +function resolves(value) { return (resolve) => { resolve(value); }; } +// function rejects(reason) { return (resolve, reject) => { reject(asError(reason)); }; } + +// Easily build promises that: +// 1. combine reusable behaviours (e.g. onTimeout, onEvent) +// 2. have a cleanup phase (e.g. to remove listeners) +// 3. can be interrupted (e.g. on unload) +class PromiseBuilder { + constructor() { + this._promise = Promise.race([ + // Interrupter + new Promise((resolve, reject) => { this.interrupt = reject; }), + // Main + new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = (reason, options) => { + (options && options.interrupt ? this.interrupt : reject)(asError(reason)); + }; + // Cleanup + }).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } }) + ]); + } + + async _tryHandler(handler, name, ...args) { + try { + await handler(...args); + } catch (e) { + console.error(`Failed: ${name}: ${e.message}`); + this.reject(e); + } + } + + promise(handler) { + if (handler) { this._tryHandler(handler, "promise", this); } + return this._promise; + } + + onCompletion(completion) { + if (!this.completions) { this.completions = []; } + this.completions.push(completion); + return this; + } + + onTimeout(delay, timeoutHandler) { + const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); }; + let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay); + this.onCompletion(() => { clearTimeout(timeoutId); }); + return this; + } + + onFutureEvent(target, eventName, eventHandler) { + const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); }; + target.addEventListener(eventName, listener, {once: true}); + this.onCompletion(() => { target.removeEventListener(eventName, listener); }); + return this; + } + + onEvent(target, eventName, eventHandler) { + if (target === window) { + eventName = eventName.toLowerCase(); + if (eventName === "domcontentloaded" || eventName === "load") { + switch (document.readyState) { + case "loading": break; + case "interactive": + if (eventName === "load") { break; } + // Fall through + case "complete": + // Event already fired - run immediately + this._tryHandler(eventHandler, eventName, this.resolve, this.reject); + return this; + } + } + } + this.onFutureEvent(target, eventName, eventHandler); + return this; + } } -async function doAnimation(element, property, value) { - return new Promise((resolve) => { - const handler = () => { - resolve(); - element.removeEventListener("transitionend", handler); +class Animation { + static delay(delay = 350) { + return new Promise((resolve) => { setTimeout(resolve, delay); }); + } + + static async toggle(element, show, timeoutDelay = 3000) { + const shown = element.classList.contains("show"); + if (shown === !!show) { return; } + + const animate = () => { + if (show) { + if (!element.classList.contains("show")) { + element.classList.add("show"); + } + } else { + element.classList.remove("show"); + } }; - element.addEventListener("transitionend", handler); - window.requestAnimationFrame(() => { - element.style[property] = value; + + return new PromiseBuilder() + .onTimeout(timeoutDelay, resolves()) + .onEvent(element, "transitionend", resolves()) + .promise((promise) => { + + // Delay until element has been rendered + requestAnimationFrame(() => { + setTimeout(() => { + animate(); + }, 10); + }); + + // Ensure animation always reaches final state + promise.onCompletion(animate); + }); + } +} + +class UIRequest { + constructor (component, action, options, response) { + this.component = component; + this.action = action; + this.options = options; + this.response = response || new UIResponse(); + } +} + +class UIResponse { + constructor (value) { + let promise; + if (value instanceof Promise) { promise = value; } + if (value !== undefined) { promise = Promise.resolve(value); } + this.modifyingDOM = this.animating = promise; + } +} + +let requests; + +class UIRequestManager { + static request(component, action, options) { + // Try for quick return + if (component.unique) { + const previous = requests && requests[component.name]; + + // Quick return if request already enqueued + if (previous && previous.action === action) { + // Previous request is also an add, but we've got an extra update to do as well + if (action === "add" && component.onUpdate && options) { + return new UIResponse(previous.response.animating.then((elem) => { + const updating = component.onUpdate(elem, options); + return updating ? updating.then(elem) : elem; + })); + // No update needed, so can just reuse previous request + } else { + return previous.response; + } + } + + // Quick return if no request pending and element already added/removed + if (!previous) { + const element = this._get(component); + if (element) { + if (action === "add") { return new UIResponse(element); } + } else { + if (action === "remove") { return new UIResponse(null); } + } + } + } + + // New request + const response = new UIResponse(); + const request = new UIRequest(component, action, options, response); + + // Enqueue + let previous; + if (component.unique) { + if (!requests) { requests = {}; } + previous = requests[component.name]; + requests[component.name] = request; + } + + // Execute + response.modifyingDOM = new Promise((resolve,reject) => { + const modifiedDOM = {resolve,reject}; + response.animating = new Promise((resolve,reject) => { + const animated = {resolve,reject}; + this._execute(request, previous, modifiedDOM, animated); + }); + }); + + return response; + } + + static _get(component) { + const unique = component.unique; + if (!unique) { return null; } + if (unique.id) { + return document.getElementById(unique.id); + } else { + if ("querySelector" in component.parent) { + return component.parent.querySelector(unique.selector); + } else { + const parent = this._get(component.parent); + if (parent) { + return parent.querySelector(unique.selector); + } else { + return null; + } + } + } + } + + static async _execute(request, previous, modifiedDOM, animated) { + try { + if (previous) { + try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ } + } + + const component = request.component; + const options = request.options; + + // Get parent + let parentElement; + if ("querySelector" in component.parent) { + parentElement = component.parent; + } else { + if (request.action === "add") { + parentElement = await this.request(component.parent, "add", options).modifyingDOM; + } else { + parentElement = this._get(component.parent); + } + } + + let element; + + // Add + if (request.action === "add") { + element = await component.create(options); + if (component.onUpdate) { await component.onUpdate(element, options); } + + if (component.prepend) { + parentElement.prepend(element); + } else { + parentElement.appendChild(element); + } + + modifiedDOM.resolve(element); + + if (component.onAdd) { await component.onAdd(element, options); } + + // Remove + } else { + if (parentElement) { + element = this._get(component); + if (element) { + if (component.onRemove) { await component.onRemove(element, options); } + element.remove(); + } + modifiedDOM.resolve(element); + } + } + + animated.resolve(element); + + } catch (e) { + modifiedDOM.reject(e); + animated.reject(e); + } finally { + if (requests[request.component.name] === request) { requests[request.component.name] = null; } + } + } +} + +class UI { + static async toggle(component, show, options) { + const action = show ? "add" : "remove"; + const response = UIRequestManager.request(component, action, options); + return response.animating; + } +} + +class Container { + static get parent() { return document.body; } + static get unique() { return { id: "container-notifications" }; } + static create() { + const elem = document.createElement("div"); + elem.id = this.unique.id; + return elem; + } +} + +class Popup { + static get parent() { return Container; } + static get unique() { return { selector: "iframe" }; } + static get prepend() { return true; } + static create(options) { + const elem = document.createElement("iframe"); + elem.setAttribute("sandbox", "allow-scripts allow-same-origin"); + elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId; + return elem; + } + static onUpdate(elem, options) { + if (!options) { return; } + if (options.width) { + const width = options.width; + const height = options.height || 400; + elem.style.width = `${width}px`; + elem.style.height = `${height}px`; + } + } +} + +class Recording { + static get parent() { return Container; } + static get unique() { return { selector: ".recording" }; } + static get prepend() { return true; } + static async create() { + const elem = await Message.create({ + title: "Recording", + text: "Sites will be automatically added to this container as you browse in this tab" }); - }); + elem.classList.add("recording"); + return elem; + } + static onAdd(elem) { return Animation.toggle(elem, true); } + static onRemove(elem) { return Animation.toggle(elem, false); } } -async function addMessage(message) { - const divElement = document.createElement("div"); - divElement.classList.add("container-notification"); - // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available - divElement.innerText = message.text; +class Message { + static get parent() { return Container; } + static async create(options) { + // Message + const msgElem = document.createElement("div"); + + // Text + // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available + msgElem.innerText = options.text; + + // Title + if (options.title) { + const titleElem = document.createElement("span"); + titleElem.classList.add("title"); + titleElem.innerText = options.title; + msgElem.prepend(titleElem); + } - const imageElement = document.createElement("img"); - const imagePath = browser.extension.getURL("/img/container-site-d-24.png"); - const response = await fetch(imagePath); - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - imageElement.src = objectUrl; - divElement.prepend(imageElement); + // Icon + const imageElem = document.createElement("div"); + const imagePath = browser.extension.getURL("/img/container-site-d-24.png"); + imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`; + imageElem.classList.add("logo"); + msgElem.prepend(imageElem); - document.body.appendChild(divElement); + // Real/dummy wrappers (required for stacking & sliding animations) + const dummyElem = document.createElement("div"); + dummyElem.appendChild(msgElem); + const realElem = document.importNode(dummyElem, true); // Clone + dummyElem.classList.add("dummy"); // For sizing + realElem.classList.add("real"); // For display + + // Outer container + const elem = document.createElement("div"); + elem.appendChild(dummyElem); + elem.appendChild(realElem); + + return elem; + } + static async onAdd(elem) { + await Animation.toggle(elem, true); + await Animation.delay(3000); + await Animation.toggle(elem, false); + elem.remove(); + } +} - await delayAnimation(100); - await doAnimation(divElement, "transform", "translateY(0)"); - await delayAnimation(3000); - await doAnimation(divElement, "transform", "translateY(-100%)"); +class Messages { + static async handle(message) { + let animatePopup, animateRecording, animateMessage; + if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); } + if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); } + if ("text" in message) { animateMessage = UI.toggle(Message, true, message); } + await Promise.all([animatePopup, animateRecording, animateMessage]); + } - divElement.remove(); + static async add(message) { + return new PromiseBuilder() + .onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); }) + .onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); }) + .promise(); + } } browser.runtime.onMessage.addListener((message) => { - addMessage(message); + if (message.to === "tab") { + return Messages.add(message.content); + } }); diff --git a/src/js/popup-bootstrap.js b/src/js/popup-bootstrap.js new file mode 100644 index 00000000..db421cb9 --- /dev/null +++ b/src/js/popup-bootstrap.js @@ -0,0 +1,58 @@ +/** + Some of the Web Extension API (e.g. tabs, contextualIdentities) is unavailable + if popup is hosted in an iframe on a web page. So must forward those calls + to (privileged) background script, so that popup can be run in an iframe. + */ +const browserAPIInjector = { // eslint-disable-line no-unused-vars + async injectAPI() { + await this.injectMethods([ + "tabs.get", + "tabs.query", + "contextualIdentities.query", + "contextualIdentities.get" + ]); + await this.injectConstants([ + "tabs.TAB_ID_NONE", + "windows.WINDOW_ID_CURRENT" + ]); + await this.injectUnimplemented([ + "tabs.onUpdated.addListener", + "tabs.onUpdated.removeListener" + ]); + }, + + injectMethods(keys) { return this.inject(keys, "method"); }, + injectConstants(keys) { return this.inject(keys, "constant"); }, + injectUnimplemented(keys) { return this.inject(keys, "unimplemented"); }, + + async inject(keys, type) { + return Promise.all(keys.map(async (key) => { + const [object, property] = this.getComponents(key); + if (!(property in object)) { + if (type === "constant") { + object[property] = await this.invokeBrowserMethod(key); + } else if (type === "unimplemented") { + object[property] = () => {}; + } else { + object[property] = async (...args) => { return this.invokeBrowserMethod(key, args); }; + } + } + })); + }, + + getComponents(key) { + let object = browser; + let indexOfDot; + while ((indexOfDot = key.indexOf(".")) !== -1) { + const property = key.substring(0, indexOfDot); + if (!(property in object)) { object[property] = {}; } + object = object[property]; + key = key.substring(indexOfDot + 1); + } + return [object, key]; + }, + + async invokeBrowserMethod(name, args) { + return browser.runtime.sendMessage({ method:"invokeBrowserMethod", name, args }); + } +}; \ No newline at end of file diff --git a/src/js/popup.js b/src/js/popup.js index 64dca459..2122bced 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -4,6 +4,8 @@ const CONTAINER_HIDE_SRC = "/img/container-hide.svg"; const CONTAINER_UNHIDE_SRC = "/img/container-unhide.svg"; +const CONTAINER_RECORD_ENABLED_SRC = "/img/container-record-enabled.svg"; +const CONTAINER_RECORD_DISABLED_SRC = "/img/container-record-disabled.svg"; const DEFAULT_COLOR = "blue"; const DEFAULT_ICON = "circle"; @@ -22,6 +24,7 @@ const P_CONTAINERS_EDIT = "containersEdit"; const P_CONTAINER_INFO = "containerInfo"; const P_CONTAINER_EDIT = "containerEdit"; const P_CONTAINER_DELETE = "containerDelete"; +const P_CONTAINER_RECORD = "containerRecord"; const P_CONTAINERS_ACHIEVEMENT = "containersAchievement"; /** @@ -67,6 +70,25 @@ async function getExtensionInfo() { return extensionInfo; } +// Determine where this popup is hosted - browserAction / iframe in a tab +const Env = { + init() { + this.hasFullBrowserAPI = !!browser.tabs; + + const params = new URLSearchParams(window.location.search); + const tabId = params.get("tabId"); + if (tabId !== null) { + this.tabId = parseInt(tabId, 10); + this.isBrowserActionPopup = false; + } else { + this.tabId = null; + this.isBrowserActionPopup = this.hasFullBrowserAPI; + } + } +}; +Env.init(); + + // This object controls all the panels, identities and many other things. const Logic = { _identities: [], @@ -77,52 +99,62 @@ const Logic = { _onboardingVariation: null, async init() { - // Remove browserAction "upgraded" badge when opening panel - this.clearBrowserActionBadge(); - + // Running in an iframe on a webpage - inject missing API methods + if (!Env.hasFullBrowserAPI) { + await this.injectAPI(); + } + + // API methods are ready, can continue with init + const initializingPanels = this.initializePanels(); + // Retrieve the list of identities. const identitiesPromise = this.refreshIdentities(); - try { await identitiesPromise; } catch (e) { throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message); } - + + // Remove browserAction "upgraded" badge when opening panel + const clearingBadge = this.clearBrowserActionBadge(); + // Routing to the correct panel. // If localStorage is disabled, we don't show the onboarding. const onboardingData = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]); let onboarded = onboardingData[ONBOARDING_STORAGE_KEY]; + let settingOnboardingStage; if (!onboarded) { onboarded = 0; - this.setOnboardingStage(onboarded); + settingOnboardingStage = this.setOnboardingStage(onboarded); } + let showingPanel; switch (onboarded) { case 5: - this.showAchievementOrContainersListPanel(); + showingPanel = this.showAchievementOrContainersListOrRecordPanel(); break; case 4: - this.showPanel(P_ONBOARDING_5); + showingPanel = this.showPanel(P_ONBOARDING_5); break; case 3: - this.showPanel(P_ONBOARDING_4); + showingPanel = this.showPanel(P_ONBOARDING_4); break; case 2: - this.showPanel(P_ONBOARDING_3); + showingPanel = this.showPanel(P_ONBOARDING_3); break; case 1: - this.showPanel(P_ONBOARDING_2); + showingPanel = this.showPanel(P_ONBOARDING_2); break; case 0: default: - this.showPanel(P_ONBOARDING_1); + showingPanel = this.showPanel(P_ONBOARDING_1); break; } - + + return Promise.all([initializingPanels, clearingBadge, settingOnboardingStage, showingPanel]); }, - async showAchievementOrContainersListPanel() { + async showAchievementOrContainersListOrRecordPanel() { // Do we need to show an achievement panel? let showAchievements = false; const achievementsStorage = await browser.storage.local.get({ achievements: [] }); @@ -134,9 +166,25 @@ const Logic = { if (showAchievements) { this.showPanel(P_CONTAINERS_ACHIEVEMENT); } else { - this.showPanel(P_CONTAINERS_LIST); + const currentTab = await Logic.currentTab(); + const isRecordingTab = await Logic.isRecordingTab(currentTab); + if (isRecordingTab) { + this.showPanel(P_CONTAINER_RECORD); + } else { + this.showPanel(P_CONTAINERS_LIST); + } } }, + + // Used when popup is running within iframe on a webpage, so lacks privileged API + async injectAPI() { + const script = document.createElement("script"); + script.src = "/js/popup-bootstrap.js"; + document.body.appendChild(script); + await new Promise((resolve) => { script.addEventListener("load", resolve); }); + // Above script has added browserAPIInjector + await browserAPIInjector.injectAPI(); + }, // In case the user wants to click multiple actions, // they have to click the "Done" button to stop the panel @@ -160,6 +208,8 @@ const Logic = { }, async clearBrowserActionBadge() { + if (!Env.isBrowserActionPopup) { return; } + const extensionInfo = await getExtensionInfo(); const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] }); browser.browserAction.setBadgeBackgroundColor({ color: null }); @@ -207,13 +257,17 @@ const Logic = { }, async currentTab() { - const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT }); - if (activeTabs.length > 0) { - return activeTabs[0]; + if (Env.tabId) { + return await browser.tabs.get(Env.tabId); + } else { + const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT }); + if (activeTabs.length > 0) { + return activeTabs[0]; + } + return false; } - return false; }, - + async numTabs() { const activeTabs = await browser.tabs.query({ windowId: browser.windows.WINDOW_ID_CURRENT }); return activeTabs.length; @@ -309,7 +363,14 @@ const Logic = { registerPanel(panelName, panelObject) { this._panels[panelName] = panelObject; - panelObject.initialize(); + }, + + initializePanels() { + return Promise.all(Object.values(this._panels).map(async (panel) => { return panel.initialize(); })); + }, + + getPanel(panelName) { + return this._panels[panelName]; }, identities() { @@ -322,6 +383,10 @@ const Logic = { } return this._currentIdentity; }, + + setCurrentIdentity(identity) { + this._currentIdentity = identity; + }, currentUserContextId() { const identity = Logic.currentIdentity(); @@ -368,6 +433,24 @@ const Logic = { }); }, + async isRecordingTab(tab) { + if (!tab || tab.id === browser.tabs.TAB_ID_NONE) { return false; } + try { + const recordingTabId = await browser.runtime.sendMessage({ + method: "getRecording" + }); + return recordingTabId === tab.id; + } catch (e) { console.error("Failed to determine if recording: " + e.message); return false; } + }, + + async setRecordingTab(tab) { + const tabId = tab ? tab.id : browser.tabs.TAB_ID_NONE; + return browser.runtime.sendMessage({ + method: "setOrRemoveRecording", + tabId + }); + }, + generateIdentityName() { const defaultName = "Container #"; const ids = []; @@ -393,7 +476,7 @@ const Logic = { getCurrentPanelElement() { const panelItem = this._panels[this._currentPanel]; return document.querySelector(this.getPanelSelector(panelItem)); - }, + } }; // P_ONBOARDING_1: First page for Onboarding. @@ -538,7 +621,7 @@ Logic.registerPanel(P_CONTAINERS_LIST, { window.close(); } }); - + document.addEventListener("keydown", (e) => { const selectables = [...document.querySelectorAll("[tabindex='0'], [tabindex='-1']")]; const element = document.activeElement; @@ -630,21 +713,78 @@ Logic.registerPanel(P_CONTAINERS_LIST, { } assignmentCheckboxElement.disabled = disabled; }, - + + isRecordingEnabled() { + const recordLinkElement = document.getElementById("record-link"); + if (recordLinkElement.classList.contains("disabled")) { return false; } + return true; + }, + + isRecordingActive() { + const recordLinkElement = document.getElementById("record-link"); + if (recordLinkElement.classList.contains("active")) { return true; } + return false; + }, + + setRecordingActiveAndEnabled(isActive, isEnabled) { + const recordLinkElement = document.getElementById("record-link"); + const recordIconElement = recordLinkElement.querySelector(".icon"); + + if (!isEnabled) { + recordIconElement.src = CONTAINER_RECORD_DISABLED_SRC; + recordLinkElement.classList.remove("active"); + recordLinkElement.classList.add("disabled"); + } else { + recordIconElement.src = CONTAINER_RECORD_ENABLED_SRC; + recordLinkElement.classList.remove("disabled"); + if (isActive) { + recordLinkElement.classList.add("active"); + } else { + recordLinkElement.classList.remove("active"); + } + } + }, + async prepareCurrentTabHeader() { const currentTab = await Logic.currentTab(); const currentTabElement = document.getElementById("current-tab"); const assignmentCheckboxElement = document.getElementById("container-page-assigned"); + const recordLinkElement = document.getElementById("record-link"); const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); assignmentCheckboxElement.addEventListener("change", () => { Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked); }); + Logic.addEnterHandler(recordLinkElement, async () => { + const currentTab = await Logic.currentTab(); + if (!currentTab) { return; } + if (!this.isRecordingEnabled()) { return; } + + const newRecordingTab = this.isRecordingActive() ? null : currentTab; + let showingPanel; + try { + // Show new recording started/stopped status + this.setRecordingActiveAndEnabled(!!newRecordingTab, true); + // Show recording panel + if (newRecordingTab) { showingPanel = Logic.showPanel(P_CONTAINER_RECORD); } + // Start/stop recording + await Logic.setRecordingTab(newRecordingTab); + } catch (e) { + // Failed - revert recording started/stopped status + this.setRecordingActiveAndEnabled(!newRecordingTab, true); + try { await showingPanel; } catch (e) { /* Ignore show error, as we're immediately going to change panel */ } + Logic.showPanel(P_CONTAINERS_LIST); + throw new Error("Failed to " + (newRecordingTab ? "start" : "stop") + " recording: " + e.message); + } + }); currentTabElement.hidden = !currentTab; this.setupAssignmentCheckbox(false, currentTabUserContextId); + this.setRecordingActiveAndEnabled(false, false); if (currentTab) { const identity = await Logic.identity(currentTab.cookieStoreId); const siteSettings = await Logic.getAssignment(currentTab); this.setupAssignmentCheckbox(siteSettings, currentTabUserContextId); + const isCurrentTabRecording = await Logic.isRecordingTab(currentTab); + this.setRecordingActiveAndEnabled(isCurrentTabRecording, (currentTabUserContextId !== false)); const currentPage = document.getElementById("current-page"); currentPage.innerHTML = escaped`${currentTab.title}`; const favIconElement = Utils.createFavIconElement(currentTab.favIconUrl || ""); @@ -1011,10 +1151,10 @@ Logic.registerPanel(P_CONTAINER_EDIT, { } }, - showAssignedContainers(assignments) { - const assignmentPanel = document.getElementById("edit-sites-assigned"); - const assignmentKeys = Object.keys(assignments); - assignmentPanel.hidden = !(assignmentKeys.length > 0); + showAssignedContainers(assignments, options = {}) { + const assignmentPanel = document.getElementById(options.elementId || "edit-sites-assigned"); + const assignmentKeys = assignments ? Object.keys(assignments) : []; + assignmentPanel.hidden = !(assignmentKeys.length > 0) && !options.sticky; if (assignments) { const tableElement = assignmentPanel.querySelector(".assigned-sites-list"); /* Remove previous assignment list, @@ -1047,7 +1187,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, { const currentTab = await Logic.currentTab(); Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true); delete assignments[siteKey]; - that.showAssignedContainers(assignments); + that.showAssignedContainers(assignments, options); }); trElement.classList.add("container-info-tab-row", "clickable"); tableElement.appendChild(trElement); @@ -1091,7 +1231,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, { const userContextId = Logic.currentUserContextId(); const assignments = await Logic.getAssignmentObjectByContainer(userContextId); - this.showAssignedContainers(assignments); + this.showAssignedContainers(assignments, { elementId: "edit-sites-assigned" }); document.querySelector("#edit-container-panel .panel-footer").hidden = !!userContextId; document.querySelector("#edit-container-panel-name-input").value = identity.name || ""; @@ -1164,6 +1304,45 @@ Logic.registerPanel(P_CONTAINER_DELETE, { }, }); +// P_CONTAINER_RECORD: Add assignments to a container by browsing +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_CONTAINER_RECORD, { + panelSelector: "#container-record-panel", + + // This method is called when the object is registered. + initialize() { + Logic.addEnterHandler(document.querySelector("#exit-record-mode-link"), () => { + Logic.setRecordingTab(null); + Logic.showPanel(P_CONTAINERS_LIST); + }); + }, + + // This method is called when the panel is shown. + async prepare() { + const currentTab = await Logic.currentTab(); + const identity = await Logic.identity(currentTab.cookieStoreId); + // We only show this panel if the current tab is recording. + // So the current identity is determined by the current tab. + Logic.setCurrentIdentity(identity); + + // Populating the panel: name and icon + document.getElementById("container-record-name").textContent = identity.name; + + const icon = document.getElementById("container-record-icon"); + icon.setAttribute("data-identity-icon", identity.icon); + icon.setAttribute("data-identity-color", identity.color); + + // Assignments + const userContextId = Logic.currentUserContextId(); + const assignments = await Logic.getAssignmentObjectByContainer(userContextId); + const editPanel = Logic.getPanel(P_CONTAINER_EDIT); + editPanel.showAssignedContainers(assignments, { elementId: "record-sites-assigned", sticky: true }); + + return Promise.resolve(null); + }, +}); + // P_CONTAINERS_ACHIEVEMENT: Page for achievement. // ---------------------------------------------------------------------------- @@ -1187,7 +1366,30 @@ Logic.registerPanel(P_CONTAINERS_ACHIEVEMENT, { Logic.init(); +/** + Notify backgroundPage about show/hide/resize of this popup by opening a port. + When this popup unloads, the port is automatically disconnected. + Note: only notify if this is the 'real' browserAction popup (i.e. not a 'fake' popup in an iframe) + */ +class PopupEvents { + constructor() { + this.port = browser.runtime.connect({ name: "browserActionPopup" }); + this.onResize(); + } + onResize() { + this.port.postMessage({ + update: { + width: window.innerWidth, + height: window.innerHeight + } + }); + } +} +const popupEvents = Env.isBrowserActionPopup ? new PopupEvents() : null; + window.addEventListener("resize", function () { + if (popupEvents) { popupEvents.onResize(); } + //for overflow menu const difference = window.innerWidth - document.body.offsetWidth; if (difference > 2) { diff --git a/src/popup.html b/src/popup.html index cf9878f0..6a46ca7d 100644 --- a/src/popup.html +++ b/src/popup.html @@ -108,6 +108,9 @@

Current Tab

+ + + Sort Tabs
@@ -162,7 +165,7 @@

Edit Containers

+
@@ -212,6 +215,26 @@

Remove This Container

OK + +
+
+ +

+
+
+ Container Record icon + RECORDING +
+ + +
diff --git a/src/recording.html b/src/recording.html new file mode 100644 index 00000000..334e7320 --- /dev/null +++ b/src/recording.html @@ -0,0 +1,11 @@ + + + + Multi-Account Containers Recording + + + + + + + \ No newline at end of file diff --git a/test/features/recording.test.js b/test/features/recording.test.js new file mode 100644 index 00000000..94bf30b0 --- /dev/null +++ b/test/features/recording.test.js @@ -0,0 +1,87 @@ +describe("Recording Feature", () => { + const url1 = "http://example.com"; + const url2 = "http://example2.com"; + let recordingTab; + beforeEach(async () => { + recordingTab = await helper.browser.initializeWithTab({ + cookieStoreId: "firefox-container-1", + url: url1 + }); + }); + + describe("click the 'Record' button in the popup", () => { + beforeEach(async () => { + await helper.popup.clickElementById("record-link"); + }); + + describe("browse to a website", () => { + beforeEach(async () => { + await helper.browser.browseToURL(recordingTab.id, url1); + }); + + describe("browse to another website", () => { + beforeEach(async () => { + await helper.browser.browseToURL(recordingTab.id, url2); + }); + + describe("click the 'Exit Record Mode' button in the popup", () => { + beforeEach(async () => { + await helper.popup.clickElementById("exit-record-mode-link"); + }); + + describe("in a new tab open the first website", () => { + beforeEach(async () => { + await helper.browser.openNewTab({ + cookieStoreId: "firefox-default", + url: url1 + }, { + options: { + webRequestError: true // because request is canceled due to reopening + } + }); + }); + + it("should open the confirm page", async () => { + // should have created a new tab with the confirm page + background.browser.tabs.create.should.have.been.calledWithMatch({ + url: "moz-extension://fake/confirm-page.html?" + + `url=${encodeURIComponent(url1)}` + + `&cookieStoreId=${recordingTab.cookieStoreId}`, + cookieStoreId: undefined, + openerTabId: null, + index: 2, + active: true + }); + }); + + describe("in another new tab, open the second website", () => { + beforeEach(async () => { + await helper.browser.openNewTab({ + cookieStoreId: "firefox-default", + url: url2 + }, { + options: { + webRequestError: true // because request is canceled due to reopening + } + }); + }); + + it("should open the confirm page", async () => { + // should have created a new tab with the confirm page + background.browser.tabs.create.should.have.been.calledWithMatch({ + url: "moz-extension://fake/confirm-page.html?" + + `url=${encodeURIComponent(url2)}` + + `&cookieStoreId=${recordingTab.cookieStoreId}`, + cookieStoreId: undefined, + openerTabId: null, + index: 3, + active: true + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/helper.js b/test/helper.js index 2704bacb..2bfd82e1 100644 --- a/test/helper.js +++ b/test/helper.js @@ -19,6 +19,9 @@ module.exports = { "achievements": [] }); window.browser.storage.local.set.resetHistory(); + window.browser.runtime.connect.returns({ + postMessage: sinon.stub() + }); } } } @@ -29,6 +32,15 @@ module.exports = { async openNewTab(tab, options = {}) { return background.browser.tabs._create(tab, options); + }, + + async browseToURL(tabId, url) { + const [promise] = background.browser.webRequest.onBeforeRequest.addListener.yield({ + frameId: 0, + tabId: tabId, + url: url + }); + return promise; } }, From 46fe530f62def6ac557ec74b08cf0b904b341867 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Fri, 24 Jan 2020 09:52:26 +0100 Subject: [PATCH 2/9] Recording - minor fix to popup.js ('0' is a valid tabId), plus whitespace fixes --- src/js/background/assignManager.js | 6 ++--- src/js/background/backgroundLogic.js | 8 +++--- src/js/background/messageHandler.js | 37 +++++++++++++++------------- src/js/popup.js | 25 ++++++++++--------- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index 0d18656c..fcf48945 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -420,7 +420,7 @@ const assignManager = { const tab = await browser.tabs.get(tabId); this.calculateContextMenu(tab); }, - + async _getAssignment(tab) { const cookieStore = this.getUserContextIdFromCookieStore(tab); // Ensure we have a cookieStore to assign to @@ -430,11 +430,11 @@ const assignManager = { } return false; }, - + _getByContainer(userContextId) { return this.storageArea.getByContainer(userContextId); }, - + removeContextMenu() { // There is a focus issue in this menu where if you change window with a context menu click // you get the wrong menu display because of async diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index b562338e..aaf0526a 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -7,7 +7,7 @@ const backgroundLogic = { "about:blank" ]), unhideQueue: [], - + async getExtensionInfo() { const manifestPath = browser.extension.getURL("manifest.json"); const response = await fetch(manifestPath); @@ -93,7 +93,7 @@ const backgroundLogic = { } }); }, - + asPromise(value) { if (value === undefined) { return value; } if (value instanceof Promise) { return value; } @@ -115,7 +115,7 @@ const backgroundLogic = { } catch(e) { /* Assume tabId is invalid */ } } return null; - }, + }, async getTabs(options) { const requiredArguments = ["cookieStoreId", "windowId"]; @@ -371,4 +371,4 @@ const backgroundLogic = { } return returnValue; } -}; +}; \ No newline at end of file diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index d619141b..c9251949 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -3,7 +3,7 @@ const messageHandler = { // We use this to catch redirected tabs that have just opened // If this were in platform we would change how the tab opens based on "new tab" link navigations such as ctrl+click LAST_CREATED_TAB_TIMER: 2000, - + init() { // Handles messages from webextension code browser.runtime.onMessage.addListener((m) => { @@ -80,7 +80,6 @@ const messageHandler = { response = backgroundLogic.asPromise(backgroundLogic.invokeBrowserMethod(m.name, m.args)); break; } - return response; }); @@ -264,32 +263,36 @@ const messageHandler = { } while (!succeeded); } - handleTabChangedStatus(status) { - if (status === "loading") { - if (!this.tabLoading) { - this.tabLoading = {}; - this.tabLoading.promise = new Promise((resolve) => { - this.tabLoading.resolve = resolve; - }); - } - } else { - if (this.tabLoading) { - this.tabLoading.resolve(); - this.tabLoading = null; - } + handleTabStatusLoading() { + if (!this.tabLoading) { + this.tabLoading = {}; + this.tabLoading.promise = new Promise((resolve) => { + this.tabLoading.resolve = resolve; + }); + } + } + + handleTabStatusComplete() { + if (this.tabLoading) { + this.tabLoading.resolve(); + this.tabLoading = null; } } handleTabRemoved() { this.tabRemoved = true; this.removeTabListeners(); - this.handleTabChangedStatus("complete"); + this.handleTabStatusComplete(); } addTabListeners() { this.onTabsUpdated = (eventTabId, info) => { if (this.tabId === eventTabId) { - this.handleTabChangedStatus(info.status); + if (info.status === "loading") { + this.handleTabStatusLoading(); + } else { + this.handleTabStatusComplete(); + } } }; diff --git a/src/js/popup.js b/src/js/popup.js index 2122bced..360e668a 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -81,7 +81,7 @@ const Env = { this.tabId = parseInt(tabId, 10); this.isBrowserActionPopup = false; } else { - this.tabId = null; + this.tabId = -1; this.isBrowserActionPopup = this.hasFullBrowserAPI; } } @@ -106,15 +106,16 @@ const Logic = { // API methods are ready, can continue with init const initializingPanels = this.initializePanels(); - + // Retrieve the list of identities. const identitiesPromise = this.refreshIdentities(); + try { await identitiesPromise; } catch (e) { throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message); } - + // Remove browserAction "upgraded" badge when opening panel const clearingBadge = this.clearBrowserActionBadge(); @@ -175,7 +176,7 @@ const Logic = { } } }, - + // Used when popup is running within iframe on a webpage, so lacks privileged API async injectAPI() { const script = document.createElement("script"); @@ -257,7 +258,7 @@ const Logic = { }, async currentTab() { - if (Env.tabId) { + if (Env.tabId >= 0) { return await browser.tabs.get(Env.tabId); } else { const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT }); @@ -267,7 +268,7 @@ const Logic = { return false; } }, - + async numTabs() { const activeTabs = await browser.tabs.query({ windowId: browser.windows.WINDOW_ID_CURRENT }); return activeTabs.length; @@ -732,19 +733,19 @@ Logic.registerPanel(P_CONTAINERS_LIST, { if (!isEnabled) { recordIconElement.src = CONTAINER_RECORD_DISABLED_SRC; - recordLinkElement.classList.remove("active"); + recordLinkElement.classList.remove("active"); recordLinkElement.classList.add("disabled"); } else { recordIconElement.src = CONTAINER_RECORD_ENABLED_SRC; recordLinkElement.classList.remove("disabled"); if (isActive) { - recordLinkElement.classList.add("active"); + recordLinkElement.classList.add("active"); } else { - recordLinkElement.classList.remove("active"); + recordLinkElement.classList.remove("active"); } } }, - + async prepareCurrentTabHeader() { const currentTab = await Logic.currentTab(); const currentTabElement = document.getElementById("current-tab"); @@ -774,7 +775,7 @@ Logic.registerPanel(P_CONTAINERS_LIST, { try { await showingPanel; } catch (e) { /* Ignore show error, as we're immediately going to change panel */ } Logic.showPanel(P_CONTAINERS_LIST); throw new Error("Failed to " + (newRecordingTab ? "start" : "stop") + " recording: " + e.message); - } + } }); currentTabElement.hidden = !currentTab; this.setupAssignmentCheckbox(false, currentTabUserContextId); @@ -1339,7 +1340,7 @@ Logic.registerPanel(P_CONTAINER_RECORD, { const editPanel = Logic.getPanel(P_CONTAINER_EDIT); editPanel.showAssignedContainers(assignments, { elementId: "record-sites-assigned", sticky: true }); - return Promise.resolve(null); + return Promise.resolve(null); }, }); From f15d1984a922fd7ba212bfd641b76942b6ef8298 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 13:57:23 +0100 Subject: [PATCH 3/9] recording - need newer webextensions-jsdom version, otherwise tests fail --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 753f292a..39ebde4f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "stylelint-config-standard": "^16.0.0", "stylelint-order": "^0.3.0", "web-ext": "^2.9.3", - "webextensions-jsdom": "^1.1.0" + "webextensions-jsdom": "^1.1.2" }, "homepage": "https://github.com/mozilla/multi-account-containers#readme", "license": "MPL-2.0", From 914b1a1c19bc02ef3caded6e0f324d605e31395a Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 16:54:42 +0100 Subject: [PATCH 4/9] Recording - fix adding duplicate event listeners when re-showing main container list panel in popup. --- src/js/popup.js | 121 +++++++++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/src/js/popup.js b/src/js/popup.js index 360e668a..e1b9c5bb 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -103,7 +103,7 @@ const Logic = { if (!Env.hasFullBrowserAPI) { await this.injectAPI(); } - + // API methods are ready, can continue with init const initializingPanels = this.initializePanels(); @@ -118,7 +118,7 @@ const Logic = { // Remove browserAction "upgraded" badge when opening panel const clearingBadge = this.clearBrowserActionBadge(); - + // Routing to the correct panel. // If localStorage is disabled, we don't show the onboarding. const onboardingData = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]); @@ -151,7 +151,7 @@ const Logic = { showingPanel = this.showPanel(P_ONBOARDING_1); break; } - + return Promise.all([initializingPanels, clearingBadge, settingOnboardingStage, showingPanel]); }, @@ -210,7 +210,7 @@ const Logic = { async clearBrowserActionBadge() { if (!Env.isBrowserActionPopup) { return; } - + const extensionInfo = await getExtensionInfo(); const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] }); browser.browserAction.setBadgeBackgroundColor({ color: null }); @@ -365,11 +365,11 @@ const Logic = { registerPanel(panelName, panelObject) { this._panels[panelName] = panelObject; }, - + initializePanels() { return Promise.all(Object.values(this._panels).map(async (panel) => { return panel.initialize(); })); }, - + getPanel(panelName) { return this._panels[panelName]; }, @@ -384,7 +384,7 @@ const Logic = { } return this._currentIdentity; }, - + setCurrentIdentity(identity) { this._currentIdentity = identity; }, @@ -443,7 +443,7 @@ const Logic = { return recordingTabId === tab.id; } catch (e) { console.error("Failed to determine if recording: " + e.message); return false; } }, - + async setRecordingTab(tab) { const tabId = tab ? tab.id : browser.tabs.TAB_ID_NONE; return browser.runtime.sendMessage({ @@ -451,7 +451,7 @@ const Logic = { tabId }); }, - + generateIdentityName() { const defaultName = "Container #"; const ids = []; @@ -622,7 +622,37 @@ Logic.registerPanel(P_CONTAINERS_LIST, { window.close(); } }); - + + this.getAssignmentCheckbox().addEventListener("change", async () => { + const currentTab = await Logic.currentTab(); + if (!currentTab || !currentTab.cookieStoreId) { return; } + const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); + Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !this.getAssignmentCheckbox().checked); + }); + + Logic.addEnterHandler(this.getRecordButton(), async () => { + const currentTab = await Logic.currentTab(); + if (!currentTab || !currentTab.cookieStoreId) { return; } + if (!this.isRecordingEnabled()) { return; } + + const newRecordingTab = this.isRecordingActive() ? null : currentTab; + let showingPanel; + try { + // Show new recording started/stopped status + this.setRecordingActiveAndEnabled(!!newRecordingTab, true); + // Show recording panel + if (newRecordingTab) { showingPanel = Logic.showPanel(P_CONTAINER_RECORD); } + // Start/stop recording + await Logic.setRecordingTab(newRecordingTab); + } catch (e) { + // Failed - revert recording started/stopped status + this.setRecordingActiveAndEnabled(!newRecordingTab, true); + try { await showingPanel; } catch (e) { /* Ignore show error, as we're immediately going to change panel */ } + Logic.showPanel(P_CONTAINERS_LIST); + throw new Error("Failed to " + (newRecordingTab ? "start" : "stop") + " recording: " + e.message); + } + }); + document.addEventListener("keydown", (e) => { const selectables = [...document.querySelectorAll("[tabindex='0'], [tabindex='-1']")]; const element = document.activeElement; @@ -701,8 +731,12 @@ Logic.registerPanel(P_CONTAINERS_LIST, { browser.tabs.onUpdated.removeListener(this.tabUpdateHandler); }, + getAssignmentCheckbox() { + return document.getElementById("container-page-assigned"); + }, + setupAssignmentCheckbox(siteSettings, currentUserContextId) { - const assignmentCheckboxElement = document.getElementById("container-page-assigned"); + const assignmentCheckboxElement = this.getAssignmentCheckbox(); let checked = false; if (siteSettings && Number(siteSettings.userContextId) === currentUserContextId) { checked = true; @@ -714,34 +748,34 @@ Logic.registerPanel(P_CONTAINERS_LIST, { } assignmentCheckboxElement.disabled = disabled; }, - + + getRecordButton() { + return document.getElementById("record-link"); + }, + isRecordingEnabled() { - const recordLinkElement = document.getElementById("record-link"); - if (recordLinkElement.classList.contains("disabled")) { return false; } - return true; + return !this.getRecordButton().classList.contains("disabled"); }, - + isRecordingActive() { - const recordLinkElement = document.getElementById("record-link"); - if (recordLinkElement.classList.contains("active")) { return true; } - return false; + return this.getRecordButton().classList.contains("active"); }, - + setRecordingActiveAndEnabled(isActive, isEnabled) { - const recordLinkElement = document.getElementById("record-link"); - const recordIconElement = recordLinkElement.querySelector(".icon"); - + const recordButton = this.getRecordButton(); + const recordButtonIcon = recordButton.querySelector(".icon"); + if (!isEnabled) { - recordIconElement.src = CONTAINER_RECORD_DISABLED_SRC; - recordLinkElement.classList.remove("active"); - recordLinkElement.classList.add("disabled"); + recordButtonIcon.src = CONTAINER_RECORD_DISABLED_SRC; + recordButton.classList.remove("active"); + recordButton.classList.add("disabled"); } else { - recordIconElement.src = CONTAINER_RECORD_ENABLED_SRC; - recordLinkElement.classList.remove("disabled"); + recordButtonIcon.src = CONTAINER_RECORD_ENABLED_SRC; + recordButton.classList.remove("disabled"); if (isActive) { - recordLinkElement.classList.add("active"); + recordButton.classList.add("active"); } else { - recordLinkElement.classList.remove("active"); + recordButton.classList.remove("active"); } } }, @@ -749,34 +783,7 @@ Logic.registerPanel(P_CONTAINERS_LIST, { async prepareCurrentTabHeader() { const currentTab = await Logic.currentTab(); const currentTabElement = document.getElementById("current-tab"); - const assignmentCheckboxElement = document.getElementById("container-page-assigned"); - const recordLinkElement = document.getElementById("record-link"); const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); - assignmentCheckboxElement.addEventListener("change", () => { - Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked); - }); - Logic.addEnterHandler(recordLinkElement, async () => { - const currentTab = await Logic.currentTab(); - if (!currentTab) { return; } - if (!this.isRecordingEnabled()) { return; } - - const newRecordingTab = this.isRecordingActive() ? null : currentTab; - let showingPanel; - try { - // Show new recording started/stopped status - this.setRecordingActiveAndEnabled(!!newRecordingTab, true); - // Show recording panel - if (newRecordingTab) { showingPanel = Logic.showPanel(P_CONTAINER_RECORD); } - // Start/stop recording - await Logic.setRecordingTab(newRecordingTab); - } catch (e) { - // Failed - revert recording started/stopped status - this.setRecordingActiveAndEnabled(!newRecordingTab, true); - try { await showingPanel; } catch (e) { /* Ignore show error, as we're immediately going to change panel */ } - Logic.showPanel(P_CONTAINERS_LIST); - throw new Error("Failed to " + (newRecordingTab ? "start" : "stop") + " recording: " + e.message); - } - }); currentTabElement.hidden = !currentTab; this.setupAssignmentCheckbox(false, currentTabUserContextId); this.setRecordingActiveAndEnabled(false, false); From 9dfdadbc63e250d1e7e3508db327d066e035e00e Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 17:25:31 +0100 Subject: [PATCH 5/9] Recording - fix error end of message animation --- src/js/content-script.js | 74 ++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/js/content-script.js b/src/js/content-script.js index 9e0e58b1..caa77ff4 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -21,7 +21,7 @@ class PromiseBuilder { }).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } }) ]); } - + async _tryHandler(handler, name, ...args) { try { await handler(...args); @@ -30,32 +30,32 @@ class PromiseBuilder { this.reject(e); } } - + promise(handler) { if (handler) { this._tryHandler(handler, "promise", this); } return this._promise; } - + onCompletion(completion) { if (!this.completions) { this.completions = []; } this.completions.push(completion); return this; } - + onTimeout(delay, timeoutHandler) { const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); }; let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay); this.onCompletion(() => { clearTimeout(timeoutId); }); return this; } - + onFutureEvent(target, eventName, eventHandler) { const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); }; target.addEventListener(eventName, listener, {once: true}); this.onCompletion(() => { target.removeEventListener(eventName, listener); }); return this; } - + onEvent(target, eventName, eventHandler) { if (target === window) { eventName = eventName.toLowerCase(); @@ -68,7 +68,7 @@ class PromiseBuilder { case "complete": // Event already fired - run immediately this._tryHandler(eventHandler, eventName, this.resolve, this.reject); - return this; + return this; } } } @@ -92,22 +92,22 @@ class Animation { element.classList.add("show"); } } else { - element.classList.remove("show"); + element.classList.remove("show"); } }; - + return new PromiseBuilder() .onTimeout(timeoutDelay, resolves()) .onEvent(element, "transitionend", resolves()) .promise((promise) => { - + // Delay until element has been rendered requestAnimationFrame(() => { setTimeout(() => { animate(); }, 10); }); - + // Ensure animation always reaches final state promise.onCompletion(animate); }); @@ -132,13 +132,11 @@ class UIResponse { } } -let requests; - class UIRequestManager { static request(component, action, options) { // Try for quick return if (component.unique) { - const previous = requests && requests[component.name]; + const previous = this.requests && this.requests[component.name]; // Quick return if request already enqueued if (previous && previous.action === action) { @@ -153,7 +151,7 @@ class UIRequestManager { return previous.response; } } - + // Quick return if no request pending and element already added/removed if (!previous) { const element = this._get(component); @@ -164,19 +162,19 @@ class UIRequestManager { } } } - + // New request const response = new UIResponse(); const request = new UIRequest(component, action, options, response); - + // Enqueue let previous; if (component.unique) { - if (!requests) { requests = {}; } - previous = requests[component.name]; - requests[component.name] = request; + if (!this.requests) { this.requests = {}; } + previous = this.requests[component.name]; + this.requests[component.name] = request; } - + // Execute response.modifyingDOM = new Promise((resolve,reject) => { const modifiedDOM = {resolve,reject}; @@ -185,10 +183,10 @@ class UIRequestManager { this._execute(request, previous, modifiedDOM, animated); }); }); - + return response; } - + static _get(component) { const unique = component.unique; if (!unique) { return null; } @@ -207,16 +205,16 @@ class UIRequestManager { } } } - + static async _execute(request, previous, modifiedDOM, animated) { try { if (previous) { try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ } } - + const component = request.component; const options = request.options; - + // Get parent let parentElement; if ("querySelector" in component.parent) { @@ -228,24 +226,24 @@ class UIRequestManager { parentElement = this._get(component.parent); } } - + let element; - + // Add if (request.action === "add") { element = await component.create(options); if (component.onUpdate) { await component.onUpdate(element, options); } - + if (component.prepend) { parentElement.prepend(element); } else { parentElement.appendChild(element); } - + modifiedDOM.resolve(element); - + if (component.onAdd) { await component.onAdd(element, options); } - + // Remove } else { if (parentElement) { @@ -257,14 +255,16 @@ class UIRequestManager { modifiedDOM.resolve(element); } } - + animated.resolve(element); - + } catch (e) { modifiedDOM.reject(e); animated.reject(e); } finally { - if (requests[request.component.name] === request) { requests[request.component.name] = null; } + if (this.requests && this.requests[request.component.name] === request) { + this.requests[request.component.name] = null; + } } } } @@ -318,7 +318,7 @@ class Recording { text: "Sites will be automatically added to this container as you browse in this tab" }); elem.classList.add("recording"); - return elem; + return elem; } static onAdd(elem) { return Animation.toggle(elem, true); } static onRemove(elem) { return Animation.toggle(elem, false); } @@ -329,7 +329,7 @@ class Message { static async create(options) { // Message const msgElem = document.createElement("div"); - + // Text // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available msgElem.innerText = options.text; From 7a43e06767e6007740d8ccde9ebb5d85b7e7dc05 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 17:33:14 +0100 Subject: [PATCH 6/9] Recording - whitespace fixes --- src/js/background/assignManager.js | 8 +++---- src/js/background/backgroundLogic.js | 6 ++--- src/js/background/messageHandler.js | 36 ++++++++++++++-------------- test/helper.js | 4 ++-- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index fcf48945..33de7a93 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -116,7 +116,7 @@ const assignManager = { this.storageArea.setExempted(pageUrl, m.tabId); return true; }, - + _determineSetAssignmentDueToRecording(tabId, url, siteSettings) { if (siteSettings) { return false; } // Assignment already set if (!recordManager.isRecordingTabId(tabId)) { return false; } @@ -148,12 +148,12 @@ const assignManager = { return {}; } const userContextId = this.getUserContextIdFromCookieStore(tab); - + // Recording if (this._determineSetAssignmentDueToRecording(tab.id, options.url, siteSettings)) { await this._setOrRemoveAssignment(tab.id, options.url, userContextId, false); } - + if (!siteSettings || userContextId === siteSettings.userContextId || this.storageArea.isExempted(options.url, tab.id)) { @@ -387,7 +387,7 @@ const assignManager = { // Context menu has stored context IDs as strings, so we need to coerce // the value to a string for accurate checking userContextId = String(userContextId); - + if (!remove) { const tabs = await browser.tabs.query({}); const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl); diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index aaf0526a..b0bc30f7 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -99,14 +99,14 @@ const backgroundLogic = { if (value instanceof Promise) { return value; } return Promise.resolve(value); }, - + asTabId(tabId) { if (tabId === undefined || tabId === null) { return browser.tabs.TAB_ID_NONE; } return tabId; }, - + async getTabOrNull(tabId) { tabId = this.asTabId(tabId); if (tabId !== browser.tabs.TAB_ID_NONE) { @@ -353,7 +353,7 @@ const backgroundLogic = { cookieStoreId(userContextId) { return `firefox-container-${userContextId}`; }, - + async invokeBrowserMethod(name, args) { let target = browser; let indexOfDot; diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index c9251949..13ffec81 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -82,10 +82,10 @@ const messageHandler = { } return response; }); - + // Monitor browserAction popup this.browserAction.init(); - + // Handles external messages from webextensions const externalExtensionAllowed = {}; browser.runtime.onMessageExternal.addListener(async (message, sender) => { @@ -226,7 +226,7 @@ const messageHandler = { throw e; }); }, - + /** Sends a message to a tab, with following benefits: 1. Waits until sending AND animating is fully complete @@ -254,7 +254,7 @@ const messageHandler = { } catch (e) { if (this.tabRemoved) { break; } if (attempts >= MAX_ATTEMPTS) { throw e; } - + attempts++; await new Promise((resolve) => { setTimeout(resolve, 1000); @@ -262,7 +262,7 @@ const messageHandler = { } } while (!succeeded); } - + handleTabStatusLoading() { if (!this.tabLoading) { this.tabLoading = {}; @@ -271,20 +271,20 @@ const messageHandler = { }); } } - + handleTabStatusComplete() { if (this.tabLoading) { this.tabLoading.resolve(); this.tabLoading = null; } } - + handleTabRemoved() { this.tabRemoved = true; this.removeTabListeners(); this.handleTabStatusComplete(); } - + addTabListeners() { this.onTabsUpdated = (eventTabId, info) => { if (this.tabId === eventTabId) { @@ -295,27 +295,27 @@ const messageHandler = { } } }; - + this.onTabsRemoved = (eventTabId) => { if (this.tabId === eventTabId) { this.handleTabRemoved(); } }; - + browser.tabs.onUpdated.addListener(this.onTabsUpdated, { tabId: this.tabId, properties:["status"] }); browser.tabs.onRemoved.addListener(this.onTabsRemoved); } - + removeTabListeners() { browser.tabs.onUpdated.removeListener(this.onTabsUpdated); browser.tabs.onRemoved.removeListener(this.onTabsRemoved); } }, - + async sendTabMessage(tabId, message) { const tab = await backgroundLogic.getTabOrNull(tabId); if (!tab || tab.id === browser.tabs.TAB_ID_NONE) { throw new Error(`Cannot send message to tab ${tabId}`); } - + const sendMessage = new this.SendTabMessage(tabId, message); sendMessage.addTabListeners(); try { @@ -327,7 +327,7 @@ const messageHandler = { sendMessage.removeTabListeners(); } }, - + // Holds current browserAction popup state, dispatches events browserAction: { init() { @@ -336,7 +336,7 @@ const messageHandler = { this.onLoad(port); } }); - + browser.windows.onFocusChanged.addListener((windowId) => { this.currentWindowId = windowId; }); @@ -345,9 +345,9 @@ const messageHandler = { // Note a new connection can arrive before existing connection is disconnected. // Happens when you click on the browserAction button on two different windows if (this.popup) { this.onUnload(); } - + const popup = this.popup = { windowId: this.currentWindowId }; - + port.onDisconnect.addListener(() => { if (this.popup === popup) { this.onUnload(); @@ -359,7 +359,7 @@ const messageHandler = { this.onUpdate(popup, msg.update); } }); - + window.dispatchEvent(new Event("BrowserActionPopupLoad")); }, onUnload() { diff --git a/test/helper.js b/test/helper.js index 2bfd82e1..8c56ced6 100644 --- a/test/helper.js +++ b/test/helper.js @@ -21,7 +21,7 @@ module.exports = { window.browser.storage.local.set.resetHistory(); window.browser.runtime.connect.returns({ postMessage: sinon.stub() - }); + }); } } } @@ -33,7 +33,7 @@ module.exports = { async openNewTab(tab, options = {}) { return background.browser.tabs._create(tab, options); }, - + async browseToURL(tabId, url) { const [promise] = background.browser.webRequest.onBeforeRequest.addListener.yield({ frameId: 0, From 8c03a76422be7c208123bf9ad8b70f6c314b240f Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 22:25:09 +0100 Subject: [PATCH 7/9] Recording - stay on same page when start recording, make popup iframe draggable, add close button to messages --- src/css/content.css | 128 +++-- src/css/popup.css | 1 + src/img/drag.svg | 7 + src/js/background/messageHandler.js | 3 + src/js/background/recordManager.js | 95 +-- src/js/content-script.js | 857 ++++++++++++++++++---------- 6 files changed, 704 insertions(+), 387 deletions(-) create mode 100644 src/img/drag.svg diff --git a/src/css/content.css b/src/css/content.css index 6433e426..897c998f 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -1,10 +1,18 @@ +:root { + /* calculated from 12px */ + --block-line-separation-size: 0.33em; /* 10px */ + + /* Use for url and icon size */ + --icon-button-size: calc(calc(var(--block-line-separation-size) * 2) + 1.66em); /* 20px */ +} + #container-notifications, #container-notifications * { all: unset; + display: block; } #container-notifications { - display: block; inline-size: 100vw; inset-block-start: 0; /* stylelint-disable-line property-no-unknown */ inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */ @@ -12,8 +20,6 @@ margin-block-start: 0; margin-inline-end: 0; margin-inline-start: 0; - offset-block-start: 0; - offset-inline-start: 0; padding-block-end: 0; padding-block-start: 0; padding-inline-end: 0; @@ -22,86 +28,124 @@ z-index: 999999999999; } -#container-notifications > iframe { +#container-notifications > .popup { border: 1px solid; inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */ - inset-inline-end: 4px; /* stylelint-disable-line property-no-unknown */ - offset-block-start: 4px; - offset-inline-end: 4px; + inset-inline-end: 3em; /* stylelint-disable-line property-no-unknown */ + position: fixed; + z-index: 2; +} + +#container-notifications > .popup > .draggable { + background: #ebebeb url('../img/drag.svg'); + background-size: 100% 100%; + block-size: var(--icon-button-size); + border-block-end: 0.5px solid darkgray; + inline-size: 100%; + z-index: 3; +} + +#container-notifications > .popup > .draggable-mask { + background-color: black; + display: none; + inset-block-end: 0; /* stylelint-disable-line property-no-unknown */ + inset-block-start: 0; /* stylelint-disable-line property-no-unknown */ + inset-inline-end: 0; /* stylelint-disable-line property-no-unknown */ + inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */ + opacity: 0.5; position: absolute; z-index: 2; } -#container-notifications > div.recording { +#container-notifications > .popup.drag > .draggable-mask { + display: block; +} + +#container-notifications > .message.recording { z-index: 1; } -#container-notifications > div { - display: block; +#container-notifications > .message { max-block-size: 0; + opacity: 0; overflow: hidden; position: relative; - transition: all 1s cubic-bezier(0.07, 0.95, 0, 1); + transition: opacity 0.6s ease-in, max-block-size 1s cubic-bezier(0.07, 0.95, 0, 1); } -#container-notifications > div.show { +#container-notifications > .message.show { max-block-size: 500px; - transition: all 1s ease-in; + opacity: 1; + transition-property: max-block-size; + transition-timing-function: ease-in; } -#container-notifications > div:hover, -#container-notifications > div:focus, -#container-notifications > div:visited { +#container-notifications > .message:hover, +#container-notifications > .message:focus, +#container-notifications > .message:visited { color: #003f07; text-decoration: none; } -#container-notifications > div > div.real { +#container-notifications > .message > .real { inset-block-end: 0; /* stylelint-disable-line property-no-unknown */ - offset-block-end: 0; position: absolute; } -#container-notifications > div > div.dummy { +#container-notifications > .message > .dummy { visibility: hidden; } -#container-notifications > div > div > div { - align-items: center; +#container-notifications > .message > div > div { + align-items: stretch; background: #efefef; + block-size: 3em; color: #003f07; display: flex; - font: 12px sans-serif; + font: 1em sans-serif; inline-size: 100vw; - justify-content: start; - margin-block-end: 0; - margin-block-start: 0; - margin-inline-end: 0; - margin-inline-start: 0; - padding-block-end: 8px; - padding-block-start: 8px; - padding-inline-end: 8px; - padding-inline-start: 8px; - text-align: start; } -#container-notifications > div > div > div > .title { +#container-notifications > .message > div > div > .logo { + align-self: center; + block-size: 1em; + flex: 0 0 1em; + margin-inline-end: 3px; + margin-inline-start: 8px; +} + +#container-notifications > .message > div > div > .title { + align-self: center; + flex: 0; font-weight: bold; - padding-left: 0.5rem; - padding-right: 1rem; + margin-inline-end: 1em; + margin-inline-start: 0.5em; + white-space: nowrap; } -#container-notifications > div > div > div > .logo { - block-size: 16px; - display: inline-block; - inline-size: 16px; - margin-inline-end: 3px; +#container-notifications > .message > div > div > .text { + align-self: center; + flex: 1; +} + +#container-notifications > .message > div > div > .close { + background: url('../img/container-close-tab.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: 50%; + flex: 0 0 2em; + margin-inline-end: 0.5em; + opacity: 0.5; +} + +#container-notifications > .message > div > div > .close:hover { + opacity: 1; } -#container-notifications > div.recording > div > div { +#container-notifications > .message.recording > div > div { background: #fcc; } -#container-notifications > div.recording > div > div > .title { +#container-notifications > .message.recording > div > div > .title { color: red; } diff --git a/src/css/popup.css b/src/css/popup.css index fc4d4fa4..ea91c9ce 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -802,6 +802,7 @@ span ~ .panel-header-text { block-size: 100%; color: #fff; display: inline-block; + flex: 1; justify-content: center; padding-block-start: 6px; padding-inline-start: 30%; diff --git a/src/img/drag.svg b/src/img/drag.svg new file mode 100644 index 00000000..7705e402 --- /dev/null +++ b/src/img/drag.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 13ffec81..292315ca 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -43,6 +43,9 @@ const messageHandler = { case "setOrRemoveRecording": response = recordManager.setTabId(m.tabId); break; + case "setTabPopupPosition": + response = recordManager.setTabPopupPosition(m.tabId, m.x, m.y); + break; case "sortTabs": backgroundLogic.sortTabs(); break; diff --git a/src/js/background/recordManager.js b/src/js/background/recordManager.js index 7702ba18..915e4af1 100644 --- a/src/js/background/recordManager.js +++ b/src/js/background/recordManager.js @@ -1,31 +1,41 @@ const recordManager = { - recording: null, - listening: null, - + recording: undefined, + listening: undefined, + Recording: class { constructor(tab) { if (tab) { this.windowId = tab.windowId; this.tabId = tab.id; this.isTabActive = tab.active; + this.isTabReady = tab.url.startsWith("http"); + this.tabMessage = {}; } else { this.windowId = browser.windows.WINDOW_ID_NONE; this.tabId = browser.tabs.TAB_ID_NONE; this.isTabActive = false; + this.isTabReady = false; + this.tabMessage = undefined; } } - - get valid() { - return this.tabId !== browser.tabs.TAB_ID_NONE; + + get valid() { return this.tabId !== browser.tabs.TAB_ID_NONE; } + + updateTabPopup(active, opts) { + if (this.valid) { this.tabMessage.popup = active; this.updateTabPopupOptions(opts); } + } + + updateTabPopupOptions(opts) { + if (this.valid) { Object.assign(this.tabMessage.popupOptions, opts); } } - + async sendTabMessage() { return messageHandler.sendTabMessage(this.tabId, this.tabMessage); } - + async stop() { if (!this.valid) { return; } - + recordManager.listening.enabled = false; // Update GUI @@ -36,71 +46,76 @@ const recordManager = { return this.sendTabMessage(); } } - + async start() { if (!this.valid) { return; } recordManager.listening.enabled = true; - + // Update GUI const baPopup = messageHandler.browserAction.popup; - const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId); - this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} }; - const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") }); + const showTabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId); + this.tabMessage = { recording: true, popup: showTabPopup, popupOptions: {tabId: this.tabId, hide:!showTabPopup} }; + const showingPage = this.isTabReady + ? Promise.resolve() + : browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") }); const messagingTab = this.sendTabMessage(); return Promise.all([showingPage, messagingTab]); } - + // Re-show recording state on page load onTabsUpdated(tabId, changeInfo) { if (this.tabId === tabId && changeInfo.status === "complete") { this.sendTabMessage(); } } - + // Show/hide tabPopup on this tab show/hide onTabsActivated(activeInfo) { if (this.tabId === activeInfo.tabId) { + this.isTabActive = true; this.sendTabMessage(); + } else if (this.windowId === activeInfo.windowId) { + this.isTabActive = false; } } - + // Keep track of tab's windowId onTabsAttached(tabId, attachInfo) { if (this.tabId === tabId) { this.windowId = attachInfo.newWindowId; } } - + // Stop recording on close onTabsRemoved(tabId) { if (this.tabId === tabId) { recordManager.setTabId(browser.tabs.TAB_ID_NONE); } } - + // Show/hide tabPopup on hide/show browserActionPopup onToggleBrowserActionPopup(baPopupVisible, baPopup) { if (this.windowId === baPopup.windowId && this.isTabActive) { - this.tabMessage.popup = !baPopupVisible; - this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height }; + const showTabPopup = !baPopupVisible; + this.updateTabPopup(showTabPopup, { width:baPopup.width, height:baPopup.height, hide:!showTabPopup }); this.sendTabMessage(); } } }, - + Listening: class { constructor() { this._enabled = false; } - + get enabled() { return this._enabled; } - + set enabled(enabled) { if (this._enabled === !!enabled) { return; } this._enabled = !!enabled; - + if (enabled) { browser.tabs.onUpdated.addListener(this.onTabsUpdated, { properties: ["status"] }); browser.tabs.onActivated.addListener(this.onTabsActivated); @@ -117,7 +132,7 @@ const recordManager = { window.removeEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload); } } - + onTabsUpdated(...args) { recordManager.recording.onTabsUpdated(...args); } onTabsActivated(...args) { recordManager.recording.onTabsActivated(...args); } onTabsAttached(...args) { recordManager.recording.onTabsAttached(...args); } @@ -125,52 +140,56 @@ const recordManager = { onBrowserActionPopupLoad() { recordManager.recording.onToggleBrowserActionPopup(true, messageHandler.browserAction.popup); } onBrowserActionPopupUnload() { recordManager.recording.onToggleBrowserActionPopup(false, messageHandler.browserAction.popup); } }, - + init() { this.recording = new recordManager.Recording(); this.listening = new recordManager.Listening(); }, - + isRecordingTabId(tabId) { if (!this.recording.valid) { return false; } if (this.recording.tabId !== tabId) { return false; } return true; }, - + getTabId() { return this.recording.tabId; - }, - + }, + async setTabId(tabId) { // Ensure tab is recordable tabId = backgroundLogic.asTabId(tabId); const tab = await backgroundLogic.getTabOrNull(tabId); const wantRecordableTab = tabId !== browser.tabs.TAB_ID_NONE; const isRecordableTab = tab && "cookieStoreId" in tab; - + // Invalid tab - stop recording & throw error if (wantRecordableTab && !isRecordableTab) { this.setTabId(browser.tabs.TAB_ID_NONE); // Don't wait for stop throw new Error(`Recording not possible for tab with id ${tabId}`); } - + // Already recording if (this.recording.tabId === tabId) { return; } - + const oldRecording = this.recording; const newRecording = this.recording = new recordManager.Recording(tab); - + // Don't wait for stop - oldRecording.stop(); + if (oldRecording.valid) { oldRecording.stop(); } try { // But DO wait for start - await newRecording.start(); - + if (newRecording.valid) { await newRecording.start(); } + // If error while starting, immediately stop, but don't wait } catch (e) { this.setTabId(browser.tabs.TAB_ID_NONE); throw e; } + }, + + async setTabPopupPosition(tabId, x, y) { + if (this.isRecordingTabId(tabId)) { this.recording.updateTabPopupOptions({x,y}); } } }; diff --git a/src/js/content-script.js b/src/js/content-script.js index caa77ff4..807aafc8 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -1,395 +1,638 @@ -function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); } -function resolves(value) { return (resolve) => { resolve(value); }; } -// function rejects(reason) { return (resolve, reject) => { reject(asError(reason)); }; } - -// Easily build promises that: -// 1. combine reusable behaviours (e.g. onTimeout, onEvent) -// 2. have a cleanup phase (e.g. to remove listeners) -// 3. can be interrupted (e.g. on unload) -class PromiseBuilder { +class Defer extends Promise { constructor() { - this._promise = Promise.race([ - // Interrupter - new Promise((resolve, reject) => { this.interrupt = reject; }), - // Main - new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = (reason, options) => { - (options && options.interrupt ? this.interrupt : reject)(asError(reason)); - }; - // Cleanup - }).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } }) - ]); + let resolve, reject; + super((res, rej) => { resolve = res; reject = rej; }); + this.resolve = resolve; this.reject = reject; + } + + // Fix to make then/catch/finally return a vanilla Promise, not a Defer + static get [Symbol.species]() { return Promise; } + get [Symbol.toStringTag]() { return this.constructor.name; } +} + +/** + Wraps a promise chain that: + 1. can be interrupted (e.g. to stop an animation) + 2. has a cleanup phase (e.g. to remove listeners) + + Note: interrupting is important when the browser is about to redirect. The background + script may have previously sent us a message and we returned a promise while we show + the message using an animation. If the browser now redirects, the promise is lost + and the background script hangs waiting forever on a promise that will never finish. + By interrupting the promise, we ensure the background script receives an error. + */ +class Operation extends Defer { + constructor(name) { + super(); + this.name = name; + this.finished = this.error = this.completions = undefined; + const resolveFinally = this.resolve; + const rejectFinally = this.reject; + // eslint-disable-next-line promise/catch-or-return + new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }).then( + v => { this._setFinished(); resolveFinally(v); }, + v => { this.error = v || new Error(`${this} failed`); this._setFinished(); rejectFinally(this.error); }); } - async _tryHandler(handler, name, ...args) { - try { - await handler(...args); - } catch (e) { - console.error(`Failed: ${name}: ${e.message}`); - this.reject(e); + _setFinished() { + this.finished = true; + if (this.completions) { + this.completions.forEach(completion => { try { completion(this); } catch (e) { this.errored("completion", e); } }); + this.completions = undefined; } } - promise(handler) { - if (handler) { this._tryHandler(handler, "promise", this); } - return this._promise; + mustBeRunning(running, optional) { + const wantFinished = running === false; + const ok = wantFinished === !!this.finished; + if (!ok && !optional) { throw new Error(`${this} ${wantFinished ? "unfinished" : "cancelled"}`); } + return ok; } - onCompletion(completion) { - if (!this.completions) { this.completions = []; } - this.completions.push(completion); - return this; + addFinishListener(listener) { + if (this.finished) { + listener({target: this}); + } else { + if (!(this.completions || (this.completions = [])).find(c => c === listener)) { this.completions.push(listener); } + } + } + + removeFinishListener(listener) { + if (this.completions) { this.completions = this.completions.filter(c => c !== listener); } + } + + addEventListener(type, listener) { + if (/^finish$/i.test(type)) { this.addFinishListener(listener); } + else { throw new Error(`${this} unsupported event '${type}'`); } + } + + removeEventListener(type, listener) { + if (/^finish$/i.test(type)) { this.removeFinishListener(listener); } + } + + errored(name, e) { console.error("%s error during %s: %s", this, name, e); } + toString() { return this.name || this.constructor.name; } +} + +/** + Builds an operation with concurrent tasks (e.g. onTimeout, onEvent). + */ +class Operator { + constructor(operation) { + if (operation) { + this.operation = typeof operation === "string" ? new Operation(operation) : operation; + } else { + const name = this.constructor === Operator ? undefined : `${this.constructor.name}Operation`; + this.operation = new Operation(name); + } } - onTimeout(delay, timeoutHandler) { - const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); }; + // Performs a named task, checks if operation is already finished and handles errors. + async exec(handler, opts = {name: "exec"}, ...args) { + if (this.operation.mustBeRunning(!opts.finished, opts.optional)) { + try { + const result = await handler(this.operation, ...args); + this.operation.mustBeRunning(!opts.finished, opts.optional); + return result; + } catch (e) { + if (!this.operation.finished || opts.finished) { + this.operation.errored(name, e); + this.operation.reject(e); + } + } + } + } + + delay(millis) { + return this.exec(() => { return new Promise(resolve => setTimeout(resolve, millis)); }, {name: "delay"}); + } + + onStart(handler) { this.exec(handler, {name: "start"}); return this; } + onFinish(handler) { this.operation.addFinishListener(e => handler(e.target)); return this; } + + onTimeout(delay, handler) { + const timer = () => this.exec(handler, {name: "timeout", optional: true}); let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay); - this.onCompletion(() => { clearTimeout(timeoutId); }); + this.onFinish(() => clearTimeout(timeoutId)); return this; } - onFutureEvent(target, eventName, eventHandler) { - const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); }; - target.addEventListener(eventName, listener, {once: true}); - this.onCompletion(() => { target.removeEventListener(eventName, listener); }); + onEvent(target, type, handler) { + if (target) { + const options = {name: type, optional: true, finished: this.isFinishEvent(target, type)}; + if (this.isPriorEvent(target, type)) { + this.exec(handler, options, {target}); + } else { + const listener = event => this.exec(handler, options, event); + target.addEventListener(type, listener, {once: true}); + this.onFinish(() => target.removeEventListener(type, listener)); + } + } return this; } - onEvent(target, eventName, eventHandler) { - if (target === window) { - eventName = eventName.toLowerCase(); - if (eventName === "domcontentloaded" || eventName === "load") { - switch (document.readyState) { - case "loading": break; - case "interactive": - if (eventName === "load") { break; } - // Fall through - case "complete": - // Event already fired - run immediately - this._tryHandler(eventHandler, eventName, this.resolve, this.reject); - return this; - } + isPriorEvent(target, type) { + if (this.isWindowLoadEvent(target, type) || this.isWindowInteractiveEvent(target, type)) { + switch (document.readyState) { + case "loading": return false; + case "complete": return true; + case "interactive": return this.isWindowInteractiveEvent(target, type); } + } else if (this.isFinishEvent(target, type)) { + return target.finished; } - this.onFutureEvent(target, eventName, eventHandler); - return this; + return false; } + + isWindowLoadEvent(target, type) { return target === window && /^load$/i.test(type); } + isWindowInteractiveEvent(target, type) { return target === window && /^domcontentloaded$/i.test(type); } + isFinishEvent(target, type) { return "finished" in target && /^finish$/i.test(type); } } -class Animation { - static delay(delay = 350) { - return new Promise((resolve) => { setTimeout(resolve, delay); }); - } +class Animator extends Operator { + static isShown(elem) { return elem && elem.classList.contains("show"); } - static async toggle(element, show, timeoutDelay = 3000) { - const shown = element.classList.contains("show"); - if (shown === !!show) { return; } + toggle(elem, show, timeoutDelay = 3000) { + if (Animator.isShown(elem) === !!show) { return; } - const animate = () => { - if (show) { - if (!element.classList.contains("show")) { - element.classList.add("show"); - } - } else { - element.classList.remove("show"); + const animate = (operation) => { + if (!operation.finished) { + elem.classList[(show ? "add" : "remove")]("show"); } }; - return new PromiseBuilder() - .onTimeout(timeoutDelay, resolves()) - .onEvent(element, "transitionend", resolves()) - .promise((promise) => { - - // Delay until element has been rendered - requestAnimationFrame(() => { + return new Operator(`Animate${show ? "Show" : "Hide"}`) + // Ensure animation always reaches final state on timeout + .onTimeout(timeoutDelay, operation => { animate(operation); operation.resolve(); }) + .onEvent(elem, "transitionend", operation => operation.resolve()) + .onEvent(this.operation, "finish", operation => operation.reject("Interrupted")) + .onStart(operation => { + requestAnimationFrame(() => { // Delay until element has been rendered setTimeout(() => { - animate(); - }, 10); + animate(operation); + }, 1); }); - - // Ensure animation always reaches final state - promise.onCompletion(animate); - }); + }) + .operation; } } -class UIRequest { - constructor (component, action, options, response) { - this.component = component; - this.action = action; - this.options = options; - this.response = response || new UIResponse(); +class Draggable { + constructor(elem) { + this.elem = elem; + this.x = this.y = this.left = this.top = this.insetTop = this.insetLeft = this.insetBottom = this.insetRight = this.finishHandler = undefined; + this.onMouseDown = this.onMouseDown.bind(this); + this.onDrag = this.onDrag.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); } -} -class UIResponse { - constructor (value) { - let promise; - if (value instanceof Promise) { promise = value; } - if (value !== undefined) { promise = Promise.resolve(value); } - this.modifyingDOM = this.animating = promise; + start(finishHandler) { + this.finishHandler = finishHandler; + this.elem.querySelector(".draggable").addEventListener("mousedown", this.onMouseDown); } -} -class UIRequestManager { - static request(component, action, options) { - // Try for quick return - if (component.unique) { - const previous = this.requests && this.requests[component.name]; - - // Quick return if request already enqueued - if (previous && previous.action === action) { - // Previous request is also an add, but we've got an extra update to do as well - if (action === "add" && component.onUpdate && options) { - return new UIResponse(previous.response.animating.then((elem) => { - const updating = component.onUpdate(elem, options); - return updating ? updating.then(elem) : elem; - })); - // No update needed, so can just reuse previous request - } else { - return previous.response; - } - } + stop() { + this.onMouseUp(); + this.elem.querySelector(".draggable").removeEventListener("mousedown", this.onMouseDown); + this.finishHandler = undefined; + } - // Quick return if no request pending and element already added/removed - if (!previous) { - const element = this._get(component); - if (element) { - if (action === "add") { return new UIResponse(element); } - } else { - if (action === "remove") { return new UIResponse(null); } - } - } - } + onMouseDown(e) { + e.preventDefault(); + const rect = e.target.getBoundingClientRect(); + const minInsetX = rect.width - 30; + const minInsetY = rect.height - 30; + this.x = e.clientX; + this.y = e.clientY; + this.insetTop = e.clientY - rect.top - minInsetY; + this.insetLeft = e.clientX - rect.left - minInsetX; + this.insetBottom = rect.bottom - e.clientY - minInsetY; + this.insetRight = rect.right - e.clientX - minInsetX; + this.elem.classList.add("drag"); + document.addEventListener("mousemove", this.onDrag); + window.addEventListener("mouseup", this.onMouseUp); + } - // New request - const response = new UIResponse(); - const request = new UIRequest(component, action, options, response); + onDrag(e) { + e.preventDefault(); + const x = Math.max(this.insetLeft, Math.min(window.innerWidth - this.insetRight, e.clientX)); + const y = Math.max(this.insetTop, Math.min(window.innerHeight - this.insetBottom, e.clientY)); + const deltaX = this.x - x; + const deltaY = this.y - y; + this.x = x; + this.y = y; + this.left = this.elem.offsetLeft - deltaX; + this.top = this.elem.offsetTop - deltaY; + Draggable.moveTo(this.elem, this.left, this.top); + } - // Enqueue - let previous; - if (component.unique) { - if (!this.requests) { this.requests = {}; } - previous = this.requests[component.name]; - this.requests[component.name] = request; + onMouseUp() { + this.elem.classList.remove("drag"); + document.removeEventListener("mousemove", this.onDrag); + window.removeEventListener("mouseup", this.onMouseUp); + if (this.finishHandler) { + this.finishHandler(this.left, this.top); } + } - // Execute - response.modifyingDOM = new Promise((resolve,reject) => { - const modifiedDOM = {resolve,reject}; - response.animating = new Promise((resolve,reject) => { - const animated = {resolve,reject}; - this._execute(request, previous, modifiedDOM, animated); - }); - }); + static moveTo(elem, x, y) { + let left, top; + if (x === undefined || y === undefined) { + left = top = ""; + } else { + left = `min(calc(100vw - 30px), ${x}px)`; + top = `min(calc(100vh - 30px), ${y}px)`; + } + elem.style.left = left; + elem.style.top = top; + } +} - return response; +class Component { + static async toggle(show, options) { + const action = show ? "add" : "remove"; + const response = UI.request(this, action, options); + return response.animating; } - static _get(component) { - const unique = component.unique; - if (!unique) { return null; } - if (unique.id) { - return document.getElementById(unique.id); - } else { - if ("querySelector" in component.parent) { - return component.parent.querySelector(unique.selector); + static getElement(isAll) { + if (isAll) { return this.getElements(); } + const unique = this.unique; + if (unique) { + if (unique.id) { + return document.getElementById(unique.id); } else { - const parent = this._get(component.parent); - if (parent) { - return parent.querySelector(unique.selector); - } else { - return null; - } + const parentElem = this.getParentElement(); + return parentElem && parentElem.querySelector(unique.selector); } } } - static async _execute(request, previous, modifiedDOM, animated) { - try { - if (previous) { - try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ } + static getElements() { + const identifier = this.all || this.unique; + if (identifier) { + const parents = this.getParentElement(true); + if (parents) { + const selector = identifier.selector || `#${identifier.id}`; + return parents.flatMap(parent => Array.from(parent.querySelectorAll(selector))); } + } + } - const component = request.component; - const options = request.options; + static getParentElement(isAll) { + const parent = this.parent; + return "querySelector" in parent ? (isAll ? [parent] : parent) : parent.getElement(isAll); + } - // Get parent - let parentElement; - if ("querySelector" in component.parent) { - parentElement = component.parent; - } else { - if (request.action === "add") { - parentElement = await this.request(component.parent, "add", options).modifyingDOM; + static get options() { return this.unique || this.all ? ["all"] : []; } + static isReady() { return true; } + static toString() { return this.name; } +} + +const UI = { + requests: { + _store: [], + getRequest: function(component) { + if (component.unique) { return this._store[component.name]; } + }, + addRequest: function(request) { + if (request.component.unique) { this._store[request.component.name] = request; } + }, + removeRequest: function(request) { + if (this._store[request.component.name] === request) { + this._store[request.component.name] = undefined; + } + } + }, + + request: function(component, action, options = {}) { + const request = new UI.Request(component, action, options); + const previous = this.requests.getRequest(component); + + // Already requested + if (request.isEqual(previous)) { return previous.response; } + + // Element already added/removed + if (!previous) { + if (component.unique || request.options.all) { + const elem = component.getElement(request.options.all); + if (elem && (!request.options.all || elem.length > 0)) { + if (action === "add") { if (component.isReady(elem, options)) { return UI.Response.element(elem); } } } else { - parentElement = this._get(component.parent); + if (action === "remove") { return UI.Response.element(null); } } } + } - let element; + if (component.unique || request.options.all) { this.requests.addRequest(request); } + return new UI.Requestor(request, previous) + .onFinish(() => this.requests.removeRequest(request)) + .submit(); + }, + + Requestor: class extends Operator { + constructor (request, previous) { + super(`${request}`); + this.request = request; + this.previous = previous; + this.modifiedDOM = new Defer(); + this.request.response = new UI.Response(this.modifiedDOM, this.operation); + this.operation.addFinishListener(() => this.modifiedDOM.reject()); // Terminate immediately on interrupt + } - // Add - if (request.action === "add") { - element = await component.create(options); - if (component.onUpdate) { await component.onUpdate(element, options); } + submit() { + this.performRequest(); + return this.request.response; + } + + async performRequest() { + try { + await this.stillAnimatingPrevious(); + const existing = this.request.component.getElement(this.request.options.all); + const elem = await (this.request.action === "add" ? this.addElement(existing) : this.removeElement(existing)); + this.modifiedDOM.resolve(elem); + this.operation.resolve(elem); + } catch (e) { + this.modifiedDOM.reject(e); + this.operation.reject(e); + } + } - if (component.prepend) { - parentElement.prepend(element); + async addElement(elem) { + const alreadyAdded = elem; + if (!alreadyAdded) { + elem = await this.createElement(); + const parentElem = this.request.component.getParentElement() || await this.addParentElement(); + if (this.request.component.prepend) { + parentElem.prepend(elem); } else { - parentElement.appendChild(element); + parentElem.appendChild(elem); } + } - modifiedDOM.resolve(element); - - if (component.onAdd) { await component.onAdd(element, options); } + await this.event("onUpdate", elem); + this.modifiedDOM.resolve(elem); // Resolve before start animating + if (!alreadyAdded || !this.request.component.isReady(elem)) { + await this.event("onAdd", elem); + } + return elem; + } - // Remove - } else { - if (parentElement) { - element = this._get(component); - if (element) { - if (component.onRemove) { await component.onRemove(element, options); } - element.remove(); + async removeElement(elem) { + if (elem) { + const removeOne = async elem => { + await this.event("onRemove", elem); + if (this.request.options.hide) { + elem.style.display = "none"; + } else { + elem.remove(); } - modifiedDOM.resolve(element); + }; + if (this.request.options.all) { + await Promise.all(elem.map(e => removeOne(e))); + } else { + await removeOne(elem); } } + return elem; + } - animated.resolve(element); + async stillAnimatingPrevious() { + if (this.previous) { + try { await this.previous.response.animating; } catch (e) { /* Ignore request success/failure */ } + this.operation.mustBeRunning(); + } + } + + async addParentElement() { + const elem = await UI.request(this.request.component.parent, "add", this.request.options).modifyingDOM; + this.operation.mustBeRunning(); + return elem; + } - } catch (e) { - modifiedDOM.reject(e); - animated.reject(e); - } finally { - if (this.requests && this.requests[request.component.name] === request) { - this.requests[request.component.name] = null; + async createElement() { + const elem = await this.request.component.create(this.operation, this.request.options); + this.operation.mustBeRunning(); + return elem; + } + + async event(name, elem) { + if (this.request.component[name]) { + await this.request.component[name](elem, this.operation, this.request.options); + this.operation.mustBeRunning(); } } - } -} + }, + + Request: class { + constructor (component, action, options) { + this.component = component; + this.action = action; + this.options = this.validateOptions(options); + this.response = undefined; + } -class UI { - static async toggle(component, show, options) { - const action = show ? "add" : "remove"; - const response = UIRequestManager.request(component, action, options); - return response.animating; - } -} + validateOptions(options) { + return options && this.component.options.reduce((result, key) => { + if (key in options) { + let value = options[key]; + if (key === "all") { value = this.action === "remove" && (this.component.all || this.component.unique) ? !!value : undefined; } + if (value !== undefined) { result[key] = value; } + } + return result; + }, {}); + } -class Container { - static get parent() { return document.body; } - static get unique() { return { id: "container-notifications" }; } - static create() { - const elem = document.createElement("div"); - elem.id = this.unique.id; - return elem; - } -} + isEqual(other) { + return other && other.component === this.component && other.action === this.action && this.isEqualOptions(other.options); + } -class Popup { - static get parent() { return Container; } - static get unique() { return { selector: "iframe" }; } - static get prepend() { return true; } - static create(options) { - const elem = document.createElement("iframe"); - elem.setAttribute("sandbox", "allow-scripts allow-same-origin"); - elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId; - return elem; - } - static onUpdate(elem, options) { - if (!options) { return; } - if (options.width) { - const width = options.width; - const height = options.height || 400; - elem.style.width = `${width}px`; - elem.style.height = `${height}px`; + isEqualOptions(options) { + return options === this.options || (options && this.options && + Object.keys(options).every(k => options[k] === this.options[k])); } - } -} -class Recording { - static get parent() { return Container; } - static get unique() { return { selector: ".recording" }; } - static get prepend() { return true; } - static async create() { - const elem = await Message.create({ - title: "Recording", - text: "Sites will be automatically added to this container as you browse in this tab" - }); - elem.classList.add("recording"); - return elem; - } - static onAdd(elem) { return Animation.toggle(elem, true); } - static onRemove(elem) { return Animation.toggle(elem, false); } -} + toString() { return `${this.component}::${this.action}::${JSON.stringify(this.options)}`; } + }, -class Message { - static get parent() { return Container; } - static async create(options) { - // Message - const msgElem = document.createElement("div"); - - // Text - // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available - msgElem.innerText = options.text; - - // Title - if (options.title) { - const titleElem = document.createElement("span"); - titleElem.classList.add("title"); - titleElem.innerText = options.title; - msgElem.prepend(titleElem); - } - - // Icon - const imageElem = document.createElement("div"); - const imagePath = browser.extension.getURL("/img/container-site-d-24.png"); - imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`; - imageElem.classList.add("logo"); - msgElem.prepend(imageElem); - - // Real/dummy wrappers (required for stacking & sliding animations) - const dummyElem = document.createElement("div"); - dummyElem.appendChild(msgElem); - const realElem = document.importNode(dummyElem, true); // Clone - dummyElem.classList.add("dummy"); // For sizing - realElem.classList.add("real"); // For display - - // Outer container - const elem = document.createElement("div"); - elem.appendChild(dummyElem); - elem.appendChild(realElem); - - return elem; - } - static async onAdd(elem) { - await Animation.toggle(elem, true); - await Animation.delay(3000); - await Animation.toggle(elem, false); - elem.remove(); + Response: class { + constructor (modifyingDOM, animating) { + this.modifyingDOM = modifyingDOM; + this.animating = animating || modifyingDOM; + } + + static element(elem) { + return new this(Promise.resolve(elem)); + } + }, + + Container: class extends Component { + static get parent() { return document.body; } + static get unique() { return { id: "container-notifications" }; } + static create() { + const elem = document.createElement("div"); + elem.id = this.unique.id; + return elem; + } + }, + + Popup: class extends Component { + static get parent() { return UI.Container; } + static get unique() { return { selector: ".popup" }; } + static get prepend() { return true; } + static get options() { return ["all", "hide", "x", "y", "width", "height", "tabId"]; } + static create(operation, options) { + const popup = document.createElement("div"); + const mask = document.createElement("div"); + const draggable = document.createElement("div"); + const iframe = document.createElement("iframe"); + const popupURL = browser.runtime.getURL("/popup.html"); + const popupQueryString = options.tabId ? `?tabId=${options.tabId}` : ""; + popup.classList.add("popup"); + mask.classList.add("draggable-mask"); + draggable.classList.add("draggable"); + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin"); + iframe.src = `${popupURL}${popupQueryString}`; + popup.appendChild(draggable); + popup.appendChild(iframe); + popup.appendChild(mask); + Draggable.moveTo(popup, options.x, options.y); + new Draggable(popup).start((x, y) => { + browser.runtime.sendMessage({ method:"setTabPopupPosition", tabId:options.tabId, x, y }); + }); + return popup; + } + static isReady(elem) { return elem.style.display !== "none"; } + static onUpdate(popup, operation, options) { + popup.style.display = ""; + if (!options) { return; } + if (options.width) { + const width = options.width; + const height = options.height || 400; + popup.style.width = `${width}px`; + popup.querySelector("iframe").style.height = `${height}px`; + } + if (options.x && options.y) { + Draggable.moveTo(popup, options.x, options.y); + } + } + }, + + Recording: class extends Component { + static get parent() { return UI.Container; } + static get unique() { return { selector: ".recording" }; } + static get prepend() { return true; } + static async create(operation) { + const elem = await UI.Message.create(operation, { + title: "Recording", + text: "Sites will be automatically added to this container as you browse in this tab", + component: this + }); + elem.classList.add("recording"); + return elem; + } + static isReady(elem) { return Animator.isShown(elem); } + static onAdd(elem, operation) { return new Animator(operation).toggle(elem, true); } + static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); } + }, + + Message: class extends Component { + static get parent() { return UI.Container; } + static get all() { return { selector: ".message" }; } + static get options() { return ["all", "title", "text"]; } + static async create(operation, options) { + // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available + + // Message + const msgElem = document.createElement("div"); + + // Icon + const imageElem = document.createElement("div"); + const imagePath = browser.extension.getURL("/img/container-site-d-24.png"); + imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`; + imageElem.classList.add("logo"); + msgElem.appendChild(imageElem); + + // Title + if (options.title) { + const titleElem = document.createElement("div"); + titleElem.classList.add("title"); + titleElem.innerText = options.title; + msgElem.appendChild(titleElem); + } + + // Text + const textElem = document.createElement("div"); + textElem.classList.add("text"); + textElem.innerText = options.text; + msgElem.appendChild(textElem); + + // Close + const closeElem = document.createElement("div"); + closeElem.classList.add("close"); + msgElem.appendChild(closeElem); + + // Real/dummy wrappers (required for stacking & sliding animations) + const dummyElem = document.createElement("div"); + dummyElem.appendChild(msgElem); + const realElem = document.importNode(dummyElem, true); // Clone + dummyElem.classList.add("dummy"); // For sizing + realElem.classList.add("real"); // For display + + // Close listener + const finishedAnimating = operation.resolve; + realElem.querySelector(".close").addEventListener("click", (e) => { + finishedAnimating(); + e.target.closest(".message").classList.remove("show"); + }); + + // Outer container + const elem = document.createElement("div"); + elem.classList.add("message"); + elem.appendChild(dummyElem); + elem.appendChild(realElem); + + return elem; + } + static async onAdd(elem, operation) { + const animator = new Animator(operation); + await animator.toggle(elem, true); + await animator.delay(3000); + await animator.toggle(elem, false); + elem.remove(); + } + static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); } } -} +}; + +class Message extends Operation { + constructor(message) { super(); this.message = message; } + + async handleMessage() { + if (!UI.initialised) { + UI.initialised = true; + await Promise.all([UI.Popup.toggle(false, {all:true}), UI.Message.toggle(false, {all:true})]); + } -class Messages { - static async handle(message) { + const message = this.message; let animatePopup, animateRecording, animateMessage; - if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); } - if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); } - if ("text" in message) { animateMessage = UI.toggle(Message, true, message); } - await Promise.all([animatePopup, animateRecording, animateMessage]); + if ("popup" in message) { animatePopup = UI.Popup.toggle(message.popup, message.popupOptions); } + if ("recording" in message) { animateRecording = UI.Recording.toggle(message.recording); } + if ("text" in message) { animateMessage = UI.Message.toggle(true, message); } + return Promise.all([animatePopup, animateRecording, animateMessage]); } + toString() { return `Message: ${JSON.stringify(this.message)}`; } static async add(message) { - return new PromiseBuilder() - .onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); }) - .onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); }) - .promise(); + await new Operator(new Message(message)) + .onEvent(window, "unload", operation => operation.reject("window unload")) + .onEvent(window, "DOMContentLoaded", operation => operation.resolve(operation.handleMessage())) + .operation; } } browser.runtime.onMessage.addListener((message) => { if (message.to === "tab") { - return Messages.add(message.content); + return Message.add(message.content); } }); From bce9432f621e95a7b5cbb8a60e78d408fc8a0a39 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 22 Feb 2021 09:25:36 +0000 Subject: [PATCH 8/9] Recording - minor bugfix --- src/js/content-script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/content-script.js b/src/js/content-script.js index 807aafc8..65bed982 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -449,7 +449,7 @@ const UI = { isEqualOptions(options) { return options === this.options || (options && this.options && - Object.keys(options).every(k => options[k] === this.options[k])); + Object.keys(this.component.options).every(k => options[k] === this.options[k])); } toString() { return `${this.component}::${this.action}::${JSON.stringify(this.options)}`; } From 6b2b21408b101681a2f607eda44d9ebe482f54be Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 22 Feb 2021 11:01:39 +0000 Subject: [PATCH 9/9] Recording - fix prevent font-size of messages being affected by underlying website --- src/css/content.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/css/content.css b/src/css/content.css index 897c998f..b48e2e99 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -10,6 +10,7 @@ #container-notifications * { all: unset; display: block; + font-size: 16px; } #container-notifications {