1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4"use strict";
5
6ChromeUtils.defineModuleGetter(
7  this,
8  "FxAccounts",
9  "resource://gre/modules/FxAccounts.jsm"
10);
11ChromeUtils.defineModuleGetter(
12  this,
13  "Services",
14  "resource://gre/modules/Services.jsm"
15);
16ChromeUtils.defineModuleGetter(
17  this,
18  "PrivateBrowsingUtils",
19  "resource://gre/modules/PrivateBrowsingUtils.jsm"
20);
21
22class _BookmarkPanelHub {
23  constructor() {
24    this._id = "BookmarkPanelHub";
25    this._trigger = { id: "bookmark-panel" };
26    this._handleMessageRequest = null;
27    this._addImpression = null;
28    this._sendTelemetry = null;
29    this._initialized = false;
30    this._response = null;
31    this._l10n = null;
32
33    this.messageRequest = this.messageRequest.bind(this);
34    this.toggleRecommendation = this.toggleRecommendation.bind(this);
35    this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this);
36    this.collapseMessage = this.collapseMessage.bind(this);
37  }
38
39  /**
40   * @param {function} handleMessageRequest
41   * @param {function} addImpression
42   * @param {function} sendTelemetry - Used for sending user telemetry information
43   */
44  init(handleMessageRequest, addImpression, sendTelemetry) {
45    this._handleMessageRequest = handleMessageRequest;
46    this._addImpression = addImpression;
47    this._sendTelemetry = sendTelemetry;
48    this._l10n = new DOMLocalization([]);
49    this._initialized = true;
50  }
51
52  uninit() {
53    this._l10n = null;
54    this._initialized = false;
55    this._handleMessageRequest = null;
56    this._addImpression = null;
57    this._sendTelemetry = null;
58    this._response = null;
59  }
60
61  /**
62   * Checks if a similar cached requests exists before forwarding the request
63   * to ASRouter. Caches only 1 request, unique identifier is `request.url`.
64   * Caching ensures we don't duplicate requests and telemetry pings.
65   * Return value is important for the caller to know if a message will be
66   * shown.
67   *
68   * @returns {obj|null} response object or null if no messages matched
69   */
70  async messageRequest(target, win) {
71    if (!this._initialized) {
72      return false;
73    }
74
75    if (
76      this._response &&
77      this._response.win === win &&
78      this._response.url === target.url &&
79      this._response.content
80    ) {
81      this.showMessage(this._response.content, target, win);
82      return true;
83    }
84
85    // If we didn't match on a previously cached request then make sure
86    // the container is empty
87    this._removeContainer(target);
88    const response = await this._handleMessageRequest({
89      triggerId: this._trigger.id,
90    });
91
92    return this.onResponse(response, target, win);
93  }
94
95  /**
96   * If the response contains a message render it and send an impression.
97   * Otherwise we remove the message from the container.
98   */
99  onResponse(response, target, win) {
100    this._response = {
101      ...response,
102      collapsed: false,
103      target,
104      win,
105      url: target.url,
106    };
107
108    if (response && response.content) {
109      // Only insert localization files if we need to show a message
110      win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
111      win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl");
112      this.showMessage(response.content, target, win);
113      this.sendImpression();
114      this.sendUserEventTelemetry("IMPRESSION", win);
115    } else {
116      this.hideMessage(target);
117    }
118
119    target.infoButton.disabled = !response;
120
121    return !!response;
122  }
123
124  showMessage(message, target, win) {
125    if (this._response && this._response.collapsed) {
126      this.toggleRecommendation(false);
127      return;
128    }
129
130    const createElement = elem =>
131      target.document.createElementNS("http://www.w3.org/1999/xhtml", elem);
132    let recommendation = target.container.querySelector("#cfrMessageContainer");
133    if (!recommendation) {
134      recommendation = createElement("div");
135      const headerContainer = createElement("div");
136      headerContainer.classList.add("cfrMessageHeader");
137      recommendation.setAttribute("id", "cfrMessageContainer");
138      recommendation.addEventListener("click", async e => {
139        target.hidePopup();
140        const url = await FxAccounts.config.promiseConnectAccountURI(
141          "bookmark"
142        );
143        win.ownerGlobal.openLinkIn(url, "tabshifted", {
144          private: false,
145          triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
146            {}
147          ),
148          csp: null,
149        });
150        this.sendUserEventTelemetry("CLICK", win);
151      });
152      recommendation.style.color = message.color;
153      recommendation.style.background = `linear-gradient(135deg, ${message.background_color_1} 0%, ${message.background_color_2} 70%)`;
154      const close = createElement("button");
155      close.setAttribute("id", "cfrClose");
156      close.setAttribute("aria-label", "close");
157      close.addEventListener("click", e => {
158        this.sendUserEventTelemetry("DISMISS", win);
159        this.collapseMessage();
160        target.close(e);
161      });
162      const title = createElement("h1");
163      title.setAttribute("id", "editBookmarkPanelRecommendationTitle");
164      const content = createElement("p");
165      content.setAttribute("id", "editBookmarkPanelRecommendationContent");
166      const cta = createElement("button");
167      cta.setAttribute("id", "editBookmarkPanelRecommendationCta");
168
169      // If `string_id` is present it means we are relying on fluent for translations
170      if (message.text.string_id) {
171        this._l10n.setAttributes(
172          close,
173          message.close_button.tooltiptext.string_id
174        );
175        this._l10n.setAttributes(title, message.title.string_id);
176        this._l10n.setAttributes(content, message.text.string_id);
177        this._l10n.setAttributes(cta, message.cta.string_id);
178      } else {
179        close.setAttribute("title", message.close_button.tooltiptext);
180        title.textContent = message.title;
181        content.textContent = message.text;
182        cta.textContent = message.cta;
183      }
184
185      headerContainer.appendChild(title);
186      headerContainer.appendChild(close);
187      recommendation.appendChild(headerContainer);
188      recommendation.appendChild(content);
189      recommendation.appendChild(cta);
190      target.container.appendChild(recommendation);
191    }
192
193    this.toggleRecommendation(true);
194    this._adjustPanelHeight(win, recommendation);
195  }
196
197  /**
198   * Adjust the size of the container for locales where the message is
199   * longer than the fixed 150px set for height
200   */
201  async _adjustPanelHeight(window, messageContainer) {
202    const { document } = window;
203    // Contains the screenshot of the page we are bookmarking
204    const screenshotContainer = document.getElementById(
205      "editBookmarkPanelImage"
206    );
207    // Wait for strings to be added which can change element height
208    await document.l10n.translateElements([messageContainer]);
209    window.requestAnimationFrame(() => {
210      let { height } = messageContainer.getBoundingClientRect();
211      if (height > 150) {
212        messageContainer.classList.add("longMessagePadding");
213        // Get the new value with the added padding
214        height = messageContainer.getBoundingClientRect().height;
215        // Needs to be adjusted to match the message height
216        screenshotContainer.style.height = `${height}px`;
217      }
218    });
219  }
220
221  /**
222   * Restore the panel back to the original size so the slide in
223   * animation can run again
224   */
225  _restorePanelHeight(window) {
226    const { document } = window;
227    // Contains the screenshot of the page we are bookmarking
228    document.getElementById("editBookmarkPanelImage").style.height = "";
229  }
230
231  toggleRecommendation(visible) {
232    if (!this._response) {
233      return;
234    }
235
236    const { target } = this._response;
237    if (visible === undefined) {
238      // When called from the info button of the bookmark panel
239      target.infoButton.checked = !target.infoButton.checked;
240    } else {
241      target.infoButton.checked = visible;
242    }
243    if (target.infoButton.checked) {
244      // If it was ever collapsed we need to cancel the state
245      this._response.collapsed = false;
246      target.container.removeAttribute("disabled");
247    } else {
248      target.container.setAttribute("disabled", "disabled");
249    }
250  }
251
252  collapseMessage() {
253    this._response.collapsed = true;
254    this.toggleRecommendation(false);
255  }
256
257  _removeContainer(target) {
258    if (target || (this._response && this._response.target)) {
259      const container = (
260        target || this._response.target
261      ).container.querySelector("#cfrMessageContainer");
262      if (container) {
263        this._restorePanelHeight(this._response.win);
264        container.remove();
265      }
266    }
267  }
268
269  hideMessage(target) {
270    this._removeContainer(target);
271    this.toggleRecommendation(false);
272    this._response = null;
273  }
274
275  forceShowMessage(browser, message) {
276    const doc = browser.ownerGlobal.gBrowser.ownerDocument;
277    const win = browser.ownerGlobal.window;
278    const panelTarget = {
279      container: doc.getElementById("editBookmarkPanelRecommendation"),
280      infoButton: doc.getElementById("editBookmarkPanelInfoButton"),
281      document: doc,
282      close: e => {
283        e.stopPropagation();
284        this.toggleRecommendation(false);
285      },
286    };
287    // Remove any existing message
288    this.hideMessage(panelTarget);
289    // Reset the reference to the panel elements
290    this._response = { target: panelTarget, win };
291    // Required if we want to preview messages that include fluent strings
292    win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
293    win.MozXULElement.insertFTLIfNeeded("browser/branding/sync-brand.ftl");
294    this.showMessage(message.content, panelTarget, win);
295  }
296
297  sendImpression() {
298    this._addImpression(this._response);
299  }
300
301  sendUserEventTelemetry(event, win) {
302    // Only send pings for non private browsing windows
303    if (
304      !PrivateBrowsingUtils.isBrowserPrivate(
305        win.ownerGlobal.gBrowser.selectedBrowser
306      )
307    ) {
308      this._sendPing({
309        message_id: this._response.id,
310        bucket_id: this._response.id,
311        event,
312      });
313    }
314  }
315
316  _sendPing(ping) {
317    this._sendTelemetry({
318      type: "DOORHANGER_TELEMETRY",
319      data: { action: "cfr_user_event", source: "CFR", ...ping },
320    });
321  }
322}
323
324this._BookmarkPanelHub = _BookmarkPanelHub;
325
326/**
327 * BookmarkPanelHub - singleton instance of _BookmarkPanelHub that can initiate
328 * message requests and render messages.
329 */
330this.BookmarkPanelHub = new _BookmarkPanelHub();
331
332const EXPORTED_SYMBOLS = ["BookmarkPanelHub", "_BookmarkPanelHub"];
333