diff --git a/src/assets/img/close.svg b/src/assets/img/close.svg new file mode 100644 index 00000000..e561d647 --- /dev/null +++ b/src/assets/img/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/background/butterBarService.js b/src/background/butterBarService.js new file mode 100644 index 00000000..06b514be --- /dev/null +++ b/src/background/butterBarService.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @ts-check +import { Component } from "./component.js"; +import { PropertyType } from "../shared/ipc.js"; + +import { IBindable, property } from "../shared/property.js"; +import { VPNController } from "./vpncontroller/vpncontroller.js"; +import { ConflictObserver } from "./conflictObserver.js"; + +/** + * + * ButterBarService manages 'Butter Bar' alerts shown + * in the UI. + */ + +export class ButterBarService extends Component { + // Gets exposed to UI + static properties = { + butterBarList: PropertyType.Bindable, + removeAlert: PropertyType.Function, + }; + + /** @type {IBindable>} */ + // List of alerts passed to the UI + butterBarList = property([]); + + /** @type {Array} */ + // List of alert IDs that have been dismissed + dismissedAlerts = []; + + /** + * + * @param {*} receiver + * @param {VPNController} vpnController + * @param {ConflictObserver} conflictObserver + */ + constructor(receiver, vpnController, conflictObserver) { + super(receiver); + this.vpnController = vpnController; + this.conflictObserver = conflictObserver; + } + + async init() { + console.log("Initializing ButterBarService"); + await this.conflictObserver.updateList(); + + this.vpnController.interventions.subscribe((interventions) => { + const alert = new ButterBarAlert( + "conflictingProgram", + "alert_conflictingProgram", + "howToFix", + "https://support.mozilla.org/kb/program-your-computer-interferes-mozilla-vpn-exten?utm_medium=mozilla-vpn&utm_source=vpn-extension" + ); + this.maybeCreateAlert(interventions, alert); + }); + + this.conflictObserver.conflictingAddons.subscribe((conflictingAddons) => { + const alert = new ButterBarAlert( + "alert_conflictingExtensions", + "alert_conflictingExtensions", + "learnWhatToDo", + "https://support.mozilla.org/kb/if-another-extension-interferes-mozilla-vpn?utm_medium=mozilla-vpn&utm_source=vpn-extension" + ); + + this.maybeCreateAlert(conflictingAddons, alert); + }); + } + /** + * @param {Array} list + * @param {ButterBarAlert} alert + */ + maybeCreateAlert(list, alert) { + if (list.length == 0) { + return; + } + const { alertId } = alert; + + if ( + this.alertWasDismissed(alertId, this.dismissedAlerts) || + this.alertInButterBarList(alertId, this.butterBarList.value) + ) { + return; + } + + return this.butterBarList.value.push(alert); + } + + /** + * @param {string} id + * @param {Array} dismissedAlerts + */ + alertWasDismissed(id, dismissedAlerts) { + return dismissedAlerts.some((alertId) => alertId == id); + } + + /** + * @param {string} id + * @param {Array} butterBarList + */ + alertInButterBarList(id, butterBarList) { + return butterBarList.some((alert) => alert.alertId == id); + } + + removeAlert(id) { + const newAlertList = this.butterBarList.value.filter( + ({ alertId }) => alertId !== id + ); + this.dismissedAlerts.push(id); + this.butterBarList.set(newAlertList); + return; + } +} + +export class ButterBarAlert { + /** + * @param {string} alertId + * @param {string} alertMessage + * @param {string} linkText + * @param {string} linkUrl + */ + constructor(alertId, alertMessage, linkText, linkUrl) { + (this.alertId = alertId), + (this.alertMessage = alertMessage), + (this.linkText = linkText), + (this.linkUrl = linkUrl); + } +} diff --git a/src/background/conflictObserver.js b/src/background/conflictObserver.js index 655f8d2b..53c5911d 100644 --- a/src/background/conflictObserver.js +++ b/src/background/conflictObserver.js @@ -41,6 +41,10 @@ export class ConflictObserver { * @returns {boolean} */ static isConflicting(addon) { - return addon.enabled && addon.permissions.includes("proxy"); + return ( + addon.enabled && + addon.permissions.includes("proxy") && + addon.id !== "vpn@mozilla.com" + ); } } diff --git a/src/background/main.js b/src/background/main.js index 97a6c1e6..c4a90745 100644 --- a/src/background/main.js +++ b/src/background/main.js @@ -14,6 +14,8 @@ import { ExtensionController } from "./extensionController/index.js"; import { expose } from "../shared/ipc.js"; import { TabReloader } from "./tabReloader.js"; import { ConflictObserver } from "./conflictObserver.js"; +import { ButterBarService } from "./butterBarService.js"; + const log = Logger.logger("Main"); class Main { @@ -43,6 +45,11 @@ class Main { this.vpnController ); tabReloader = new TabReloader(this, this.extController, this.proxyHandler); + butterBarService = new ButterBarService( + this, + this.vpnController, + this.conflictObserver + ); async init() { log("Hello from the background script!"); @@ -54,6 +61,7 @@ class Main { expose(this.extController); expose(this.proxyHandler); expose(this.conflictObserver); + expose(this.butterBarService); this.#handlingEvent = false; this.#processPendingEvents(); diff --git a/src/background/tabHandler.js b/src/background/tabHandler.js index 019ce488..b9f4003a 100644 --- a/src/background/tabHandler.js +++ b/src/background/tabHandler.js @@ -76,6 +76,9 @@ export class TabHandler extends Component { } async maybeShowIcon() { + if (!this.siteContexts) { + return; + } const currentTab = await Utils.getCurrentTab(); if (!currentTab) { return; diff --git a/src/components/butter-bar.js b/src/components/butter-bar.js new file mode 100644 index 00000000..e70fc116 --- /dev/null +++ b/src/components/butter-bar.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html, LitElement, css } from "../vendor/lit-all.min.js"; +import { ghostButtonStyles } from "../components/styles.js"; +import { tr } from "../shared/i18n.js"; +import { butterBarService } from "../../ui/browserAction/backend.js"; + +export class ButterBar extends LitElement { + static properties = { + alertId: { type: String }, + alertMessage: { type: String }, + linkText: { type: String }, + linkUrl: { type: String }, + }; + + constructor() { + super(); + this.alertId = ""; + this.alertMessage = ""; + this.linkText = ""; + this.linkUrl = ""; + } + + render() { + const openLink = (url) => { + browser.tabs.create({ url }); + window.close(); + }; + + const removeAlert = (id) => { + butterBarService.removeAlert(id); + this.dispatchEvent(new CustomEvent("resize-popup", { bubbles: true })); + }; + + return html` +
+ + +
+ `; + } + + static styles = css` + ${ghostButtonStyles} + .butter-bar { + margin-block-end: 16px; + background: var(--grey10); + border-radius: 4px; + display: flex; + flex-direction: row; + justify-content: space-between; + overflow: clip; + width: 320px; + word-wrap: anywhere; + box-sizing: border-box; + } + + .butter-bar-text { + text-align: center; + flex: 1; + font-size: 13px; + padding: 8px 16px; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + justify-items: center; + box-sizing: border-box; + width: 280px; + } + + button.butter-bar-close.ghost-btn { + inline-size: 40px; + border: none; + } + + button.butter-bar-close.ghost-btn::before { + border-radius: 0px; + } + + a { + margin-inline-start: 5px; + font-family: "Inter Semi Bold"; + color: inherit; + } + + a:hover { + color: #000000; + opacity: 1; + transition: opacity 0.2s ease-in-out; + } + + @media (prefers-color-scheme: dark) { + .butter-bar { + background: rgba(255, 255, 255, 0.02); + } + + a:hover { + opacity: 0.7; + color: #ffffff; + } + a:active { + opacity: 0.5; + } + img { + filter: invert(); + } + } + `; +} +customElements.define("butter-bar", ButterBar); diff --git a/src/ui/browserAction/backend.js b/src/ui/browserAction/backend.js index 9cb0a6dc..2b258dcd 100644 --- a/src/ui/browserAction/backend.js +++ b/src/ui/browserAction/backend.js @@ -27,5 +27,6 @@ import { getExposedObject } from "../../shared/ipc.js"; export const vpnController = await getExposedObject("VPNController"); export const extController = await getExposedObject("ExtensionController"); export const proxyHandler = await getExposedObject("ProxyHandler"); +export const butterBarService = await getExposedObject("ButterBarService"); export const ready = Promise.all([vpnController, proxyHandler]); diff --git a/src/ui/browserAction/popupPage.js b/src/ui/browserAction/popupPage.js index 18f36bc3..126e40ba 100644 --- a/src/ui/browserAction/popupPage.js +++ b/src/ui/browserAction/popupPage.js @@ -12,7 +12,12 @@ import { live, } from "../../vendor/lit-all.min.js"; -import { vpnController, proxyHandler, extController } from "./backend.js"; +import { + vpnController, + proxyHandler, + extController, + butterBarService, +} from "./backend.js"; import { Utils } from "../../shared/utils.js"; import { tr } from "../../shared/i18n.js"; @@ -31,6 +36,7 @@ import "./../../components/vpncard.js"; import "./../../components/titlebar.js"; import "./../../components/iconbutton.js"; import "./../../components/mz-rings.js"; +import "./../../components/butter-bar.js"; import { SiteContext } from "../../background/proxyHandler/siteContext.js"; import { ServerCity, @@ -58,6 +64,7 @@ export class BrowserActionPopup extends LitElement { _siteContext: { type: Object }, hasSiteContext: { type: Boolean }, _siteContexts: { type: Array }, + alerts: { type: Array }, }; constructor() { @@ -74,10 +81,13 @@ export class BrowserActionPopup extends LitElement { this._siteContexts = s; }); extController.state.subscribe((s) => { - console.log(s); this.extState = s; this.updatePage(); }); + butterBarService.butterBarList.subscribe((s) => { + this.alerts = s; + this.updatePage(); + }); this.updatePage(); } updatePage() { @@ -172,6 +182,19 @@ export class BrowserActionPopup extends LitElement {
+
+ ${this.alerts.map( + (alert) => html` + + + ` + )} +
{ + test("Alerts are not created if conflict lists are empty", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = []; + const newList = butterBarService.maybeCreateAlert(list, testButterBarAlert); + expect(newList).toBe(undefined); + }); + + test("Alerts can be added to the butter bar list and removed", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = [1]; + butterBarService.maybeCreateAlert(list, testButterBarAlert); + expect(butterBarService.butterBarList.value.length).toBe(1); + + butterBarService.removeAlert("new-alert"); + expect(butterBarService.butterBarList.value.length).toBe(0); + }); + + test("Duplicate alerts are not added to the butter bar list", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = [1]; + butterBarService.maybeCreateAlert(list, testButterBarAlert); + expect(butterBarService.butterBarList.value.length).toBe(1); + + butterBarService.maybeCreateAlert(list, testButterBarAlert); + expect(butterBarService.butterBarList.value.length).toBe(1); + }); + + test("ButterBarService.alertWasDismissed returns true if the ID is in the provided list", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = ["someId"]; + const dismissed = butterBarService.alertWasDismissed("someId", list); + expect(dismissed).toBe(true); + }); + + test("ButterBarService.alertWasDismissed returns false if the ID is not in the provided list", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = []; + const dismissed = butterBarService.alertWasDismissed("someId", list); + expect(dismissed).toBe(false); + }); + + describe("ButterBarService.alertInButterBarList", () => { + test("Returns true if the alert ID is in the provided list", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + + const list = []; + list.push(testButterBarAlert); + + const alertAlreadyInList = butterBarService.alertInButterBarList( + "new-alert", + list + ); + expect(alertAlreadyInList).toBe(true); + }); + + test("Returns false if the alert ID is not in the provided list", () => { + const conflictObserver = new TestConflictObserver(); + const butterBarService = new ButterBarService( + new TestRegister(), + conflictObserver + ); + const list = []; + const alertAlreadyInList = butterBarService.alertInButterBarList( + "new-alert", + list + ); + expect(alertAlreadyInList).toBe(false); + }); + }); +});