tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Interactions.sys.mjs (25557B)


      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 
      5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
     11  InteractionsBlocklist:
     12    "moz-src:///browser/components/places/InteractionsBlocklist.sys.mjs",
     13  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     14  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     15  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
     16  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     17 });
     18 
     19 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
     20  return console.createInstance({
     21    prefix: "InteractionsManager",
     22    maxLogLevel: Services.prefs.getBoolPref(
     23      "browser.places.interactions.log",
     24      false
     25    )
     26      ? "Debug"
     27      : "Warn",
     28  });
     29 });
     30 
     31 XPCOMUtils.defineLazyServiceGetters(lazy, {
     32  idleService: ["@mozilla.org/widget/useridleservice;1", Ci.nsIUserIdleService],
     33 });
     34 
     35 XPCOMUtils.defineLazyPreferenceGetter(
     36  lazy,
     37  "pageViewIdleTime",
     38  "browser.places.interactions.pageViewIdleTime",
     39  60
     40 );
     41 
     42 XPCOMUtils.defineLazyPreferenceGetter(
     43  lazy,
     44  "saveInterval",
     45  "browser.places.interactions.saveInterval",
     46  10000
     47 );
     48 
     49 XPCOMUtils.defineLazyPreferenceGetter(
     50  lazy,
     51  "isHistoryEnabled",
     52  "places.history.enabled",
     53  false
     54 );
     55 
     56 XPCOMUtils.defineLazyPreferenceGetter(
     57  lazy,
     58  "breakupIfNoUpdatesForSeconds",
     59  "browser.places.interactions.breakupIfNoUpdatesForSeconds",
     60  60 * 60
     61 );
     62 
     63 const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
     64 const RECENT_BROWSER_INTERACTION_EXPIRY_TIME_MS = 60000;
     65 
     66 /**
     67 * Returns a monotonically increasing timestamp, that is critical to distinguish
     68 * database entries by creation time.
     69 */
     70 let gLastTime = 0;
     71 function monotonicNow() {
     72  let time = Date.now();
     73  if (time == gLastTime) {
     74    time++;
     75  }
     76  return (gLastTime = time);
     77 }
     78 
     79 /**
     80 * @typedef {object} DocumentInfo
     81 *   DocumentInfo is used to pass document information from the child process
     82 *   to _Interactions.
     83 * @property {boolean} isActive
     84 *   Set to true if the document is active, i.e. visible.
     85 * @property {string} url
     86 *   The url of the page that was interacted with.
     87 */
     88 
     89 /**
     90 * @typedef {object} InteractionInfo
     91 *   InteractionInfo is used to store information associated with interactions.
     92 * @property {number} totalViewTime
     93 *   Time in milliseconds that the page has been actively viewed for.
     94 * @property {string} url
     95 *   The url of the page that was interacted with.
     96 * @property {Interactions.DOCUMENT_TYPE} documentType
     97 *   The type of the document.
     98 * @property {number} typingTime
     99 *   Time in milliseconds that the user typed on the page
    100 * @property {number} keypresses
    101 *   The number of keypresses made on the page
    102 * @property {number} scrollingTime
    103 *   Time in milliseconds that the user spent scrolling the page
    104 * @property {number} scrollingDistance
    105 *   The distance, in pixels, that the user scrolled the page
    106 * @property {number} created_at
    107 *   Creation time as the number of milliseconds since the epoch.
    108 * @property {number} updated_at
    109 *   Last updated time as the number of milliseconds since the epoch.
    110 * @property {string} referrer
    111 *   The referrer to the url of the page that was interacted with (may be empty)
    112 */
    113 
    114 /**
    115 * The Interactions object sets up listeners and other approriate tools for
    116 * obtaining interaction information and passing it to the InteractionsManager.
    117 */
    118 class _Interactions {
    119  DOCUMENT_TYPE = {
    120    // Used when the document type is unknown.
    121    GENERIC: 0,
    122    // Used for pages serving media, e.g. videos.
    123    MEDIA: 1,
    124  };
    125 
    126  /**
    127   * This is used to store potential interactions. It maps the browser
    128   * to the current interaction information.
    129   * The current interaction is updated to the database when it transitions
    130   * to non-active, which occurs before a browser tab is closed, hence this
    131   * can be a weak map.
    132   *
    133   * @type {WeakMap<browser, InteractionInfo>}
    134   */
    135  #interactions = new WeakMap();
    136 
    137  /**
    138   * Tracks the currently active window so that we can avoid recording
    139   * interactions in non-active windows.
    140   *
    141   * @type {DOMWindow}
    142   */
    143  #activeWindow = undefined;
    144 
    145  /**
    146   * Tracks if the user is idle.
    147   *
    148   * @type {boolean}
    149   */
    150  #userIsIdle = false;
    151 
    152  /**
    153   * This stores the page view start time of the current page view.
    154   * For any single page view, this may be moved multiple times as the
    155   * associated interaction is updated for the current total page view time.
    156   *
    157   * @type {number}
    158   */
    159  _pageViewStartTime = ChromeUtils.now();
    160 
    161  /**
    162   * Stores interactions in the database, see the {@link InteractionsStore}
    163   * class. This is created lazily, see the `store` getter.
    164   *
    165   * @type {InteractionsStore | undefined}
    166   */
    167  #store = undefined;
    168 
    169  /**
    170   * Whether the component has been initialized.
    171   */
    172  #initialized = false;
    173 
    174  /**
    175   * Maps a browser to its interactions which are less than
    176   * RECENT_BROWSER_INTERACTION_EXPIRY_TIME_MS old.
    177   *
    178   * @type {WeakMap<browser, InteractionInfo>}
    179   */
    180  #recentInteractions = new WeakMap();
    181 
    182  /**
    183   * Initializes, sets up actors and observers.
    184   */
    185  init() {
    186    if (
    187      !Services.prefs.getBoolPref("browser.places.interactions.enabled", false)
    188    ) {
    189      return;
    190    }
    191 
    192    ChromeUtils.registerWindowActor("Interactions", {
    193      parent: {
    194        esModuleURI: "resource:///actors/InteractionsParent.sys.mjs",
    195      },
    196      child: {
    197        esModuleURI: "resource:///actors/InteractionsChild.sys.mjs",
    198        events: {
    199          DOMContentLoaded: {},
    200          pagehide: { mozSystemGroup: true },
    201        },
    202      },
    203      messageManagerGroups: ["browsers"],
    204    });
    205 
    206    this.#activeWindow = Services.wm.getMostRecentBrowserWindow();
    207 
    208    for (let win of lazy.BrowserWindowTracker.orderedWindows) {
    209      if (!win.closed) {
    210        this.#registerWindow(win);
    211      }
    212    }
    213    Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
    214    lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime);
    215 
    216    this.#initialized = true;
    217  }
    218 
    219  /**
    220   * Uninitializes, removes any observers that need cleaning up manually.
    221   */
    222  uninit() {
    223    if (this.#initialized) {
    224      lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime);
    225    }
    226  }
    227 
    228  /**
    229   * Resets any stored user or interaction state.
    230   * Used by tests.
    231   */
    232  async reset() {
    233    lazy.logConsole.debug("Database reset");
    234    this.#interactions = new WeakMap();
    235    this.#userIsIdle = false;
    236    this._pageViewStartTime = ChromeUtils.now();
    237    ChromeUtils.consumeInteractionData();
    238    await _Interactions.interactionUpdatePromise;
    239    await this.store.reset();
    240  }
    241 
    242  /**
    243   * Retrieve the underlying InteractionsStore object. This exists for testing
    244   * purposes and should not be abused by production code (for example it'd be
    245   * a bad idea to force flushes).
    246   *
    247   * @returns {InteractionsStore}
    248   */
    249  get store() {
    250    if (!this.#store) {
    251      this.#store = new InteractionsStore();
    252    }
    253    return this.#store;
    254  }
    255 
    256  /**
    257   * Registers the start of a new interaction.
    258   *
    259   * @param {Browser} browser
    260   *   The browser object associated with the interaction.
    261   * @param {DocumentInfo} docInfo
    262   *   The document information of the page associated with the interaction.
    263   */
    264  registerNewInteraction(browser, docInfo) {
    265    if (
    266      !browser ||
    267      !lazy.isHistoryEnabled ||
    268      !browser.browsingContext.useGlobalHistory
    269    ) {
    270      return;
    271    }
    272    let interaction = this.#interactions.get(browser);
    273    if (interaction && interaction.url != docInfo.url) {
    274      this.registerEndOfInteraction(browser);
    275    }
    276 
    277    if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) {
    278      lazy.logConsole.debug(
    279        "Ignoring a page as the URL is blocklisted",
    280        docInfo
    281      );
    282      return;
    283    }
    284 
    285    lazy.logConsole.debug("Tracking a new interaction", docInfo);
    286    let now = monotonicNow();
    287    interaction = {
    288      url: docInfo.url,
    289      referrer: docInfo.referrer,
    290      totalViewTime: 0,
    291      typingTime: 0,
    292      keypresses: 0,
    293      scrollingTime: 0,
    294      scrollingDistance: 0,
    295      created_at: now,
    296      updated_at: now,
    297    };
    298    this.#interactions.set(browser, interaction);
    299 
    300    // Only reset the time if this is being loaded in the active tab of the
    301    // active window.
    302    if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) {
    303      this._pageViewStartTime = ChromeUtils.now();
    304    }
    305 
    306    this.#recentInteractions.set(browser, [
    307      ...(this.#recentInteractions.get(browser) ?? []),
    308      interaction,
    309    ]);
    310 
    311    this.#pruneOldRecentInteractions(browser);
    312  }
    313 
    314  /**
    315   * Registers the end of an interaction, e.g. if the user navigates away
    316   * from the page. This will store the final interaction details and clear
    317   * the current interaction.
    318   *
    319   * @param {Browser} browser
    320   *   The browser object associated with the interaction.
    321   */
    322  registerEndOfInteraction(browser) {
    323    // Not having a browser passed to us probably means the tab has gone away
    324    // before we received the notification - due to the tab being a background
    325    // tab. Since that will be a non-active tab, it is acceptable that we don't
    326    // update the interaction. When switching away from active tabs, a TabSelect
    327    // notification is generated which we handle elsewhere.
    328    if (
    329      !browser ||
    330      !lazy.isHistoryEnabled ||
    331      !browser.browsingContext.useGlobalHistory
    332    ) {
    333      return;
    334    }
    335    lazy.logConsole.debug("Saw the end of an interaction");
    336 
    337    this.#updateInteraction(browser);
    338    this.#interactions.delete(browser);
    339  }
    340 
    341  /**
    342   * Updates the current interaction
    343   *
    344   * @param {Browser} [browser]
    345   *   The browser object that has triggered the update, if known. This is
    346   *   used to check if the browser is in the active window, and as an
    347   *   optimization to avoid obtaining the browser object.
    348   */
    349  #updateInteraction(browser = undefined) {
    350    _Interactions.#updateInteraction_async(
    351      browser,
    352      this.#activeWindow,
    353      this.#userIsIdle,
    354      this.#interactions,
    355      this._pageViewStartTime,
    356      this.store
    357    );
    358  }
    359 
    360  /**
    361   * Fetches recent interactions for a browser
    362   *
    363   * @param {Browser} browser
    364   *   The browser object that we are fetching recent interactions for.
    365   */
    366  async getRecentInteractionsForBrowser(browser) {
    367    // We need to force update the active interaction's total view time
    368    // to get an accurate reading.
    369    this.#updateInteraction();
    370    await _Interactions.interactionUpdatePromise;
    371    return this.#recentInteractions.get(browser);
    372  }
    373 
    374  /**
    375   * Removes stale interactions from #recentInteractions that were updated
    376   * more than RECENT_BROWSER_INTERACTION_EXPIRY_TIME_MS ago.
    377   *
    378   * @param {Browser} browser
    379   *  The browser object that we are pruning stale recent interactions for.
    380   */
    381  #pruneOldRecentInteractions(browser) {
    382    const now = Date.now();
    383 
    384    const interactions = this.#recentInteractions.get(browser);
    385    if (!interactions) {
    386      return;
    387    }
    388 
    389    const interactionstoTrack = interactions.filter(
    390      interaction =>
    391        now - interaction.updated_at <=
    392        RECENT_BROWSER_INTERACTION_EXPIRY_TIME_MS
    393    );
    394 
    395    if (interactionstoTrack.length) {
    396      this.#recentInteractions.set(browser, interactionstoTrack);
    397    } else {
    398      this.#recentInteractions.delete(browser);
    399    }
    400  }
    401 
    402  /**
    403   * Stores the promise created in updateInteraction_async so that we can await its fulfillment
    404   * when sychronization is needed.
    405   */
    406  static interactionUpdatePromise = Promise.resolve();
    407 
    408  /**
    409   * Returns the interactions update promise to be used when sychronization is needed from tests.
    410   *
    411   * @returns {Promise<void>}
    412   */
    413  get interactionUpdatePromise() {
    414    return _Interactions.interactionUpdatePromise;
    415  }
    416 
    417  /**
    418   * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions.
    419   *
    420   * @param {Browser} browser
    421   *   The browser object that has triggered the update, if known.
    422   * @param {DOMWindow} activeWindow
    423   *   The active window.
    424   * @param {boolean} userIsIdle
    425   *   Whether the user is idle.
    426   * @param {WeakMap<Browser, InteractionInfo>} interactions
    427   *   A map of interactions for each browser instance
    428   * @param {number} pageViewStartTime
    429   *   The time the page was loaded.
    430   * @param {InteractionsStore} store
    431   *   The interactions store.
    432   */
    433  static async #updateInteraction_async(
    434    browser,
    435    activeWindow,
    436    userIsIdle,
    437    interactions,
    438    pageViewStartTime,
    439    store
    440  ) {
    441    if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) {
    442      lazy.logConsole.debug(
    443        "Not updating interaction as there is no active window"
    444      );
    445      return;
    446    }
    447 
    448    // We do not update the interaction when the user is idle, since we will
    449    // have already updated it when idle was signalled.
    450    // Sometimes an interaction may be signalled before idle is cleared, however
    451    // worst case we'd only loose approx 2 seconds of interaction detail.
    452    if (userIsIdle) {
    453      lazy.logConsole.debug("Not updating interaction as the user is idle");
    454      return;
    455    }
    456 
    457    if (!browser) {
    458      browser = activeWindow.gBrowser.selectedTab.linkedBrowser;
    459    }
    460 
    461    let interaction = interactions.get(browser);
    462    if (!interaction) {
    463      lazy.logConsole.debug("No interaction to update");
    464      return;
    465    }
    466 
    467    interaction.totalViewTime += ChromeUtils.now() - pageViewStartTime;
    468    Interactions._pageViewStartTime = ChromeUtils.now();
    469 
    470    const interactionData = ChromeUtils.consumeInteractionData();
    471    const typing = interactionData.Typing;
    472    if (typing) {
    473      interaction.typingTime += typing.interactionTimeInMilliseconds;
    474      interaction.keypresses += typing.interactionCount;
    475    }
    476 
    477    // Collect the scrolling data and add the interaction to the store on completion
    478    _Interactions.interactionUpdatePromise =
    479      _Interactions.interactionUpdatePromise
    480        .then(async () => ChromeUtils.collectScrollingData())
    481        .then(
    482          result => {
    483            interaction.scrollingTime += result.interactionTimeInMilliseconds;
    484            interaction.scrollingDistance += result.scrollingDistanceInPixels;
    485          },
    486          reason => {
    487            console.error(reason);
    488          }
    489        )
    490        .then(() => {
    491          interaction.updated_at = monotonicNow();
    492 
    493          lazy.logConsole.debug("Add to store: ", interaction);
    494          store.add(interaction);
    495        });
    496  }
    497 
    498  /**
    499   * Handles a window becoming active.
    500   *
    501   * @param {DOMWindow} win
    502   *   The window that has become active.
    503   */
    504  #onActivateWindow(win) {
    505    lazy.logConsole.debug("Window activated");
    506 
    507    if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
    508      return;
    509    }
    510 
    511    this.#activeWindow = win;
    512    this._pageViewStartTime = ChromeUtils.now();
    513  }
    514 
    515  /**
    516   * Handles a window going inactive.
    517   */
    518  #onDeactivateWindow() {
    519    lazy.logConsole.debug("Window deactivate");
    520 
    521    this.#updateInteraction();
    522    this.#activeWindow = undefined;
    523  }
    524 
    525  /**
    526   * Handles the TabSelect notification. If enough time has passed between the
    527   * current time and the last time the current tab was selected and interacted
    528   * with, the existing interaction will end, and a new one will begin. This
    529   * approach accounts for scenarios where a user might leave a tab open for an
    530   * extended period (e.g. pinned tabs), and engage in distinct sessions. A
    531   * delay is used to prevent the creation of numerous short, separate
    532   * interactions that may occur when a user quickly switches between tabs.
    533   *
    534   * @param {Browser} previousBrowser
    535   *   The instance of the browser that the user switched away from.
    536   */
    537  #onTabSelect(previousBrowser) {
    538    lazy.logConsole.debug("Tab switched");
    539 
    540    this.#updateInteraction(previousBrowser);
    541 
    542    this._pageViewStartTime = ChromeUtils.now();
    543 
    544    let browser = this.#activeWindow?.gBrowser.selectedBrowser;
    545    if (browser && this.#interactions.has(browser)) {
    546      let interaction = this.#interactions.get(browser);
    547      let timePassedSinceUpdateSeconds =
    548        (Date.now() - interaction.updated_at) / 1000;
    549      if (timePassedSinceUpdateSeconds >= lazy.breakupIfNoUpdatesForSeconds) {
    550        this.registerEndOfInteraction(browser);
    551        this.registerNewInteraction(browser, {
    552          url: browser.currentURI.spec,
    553          referrer: null,
    554          isActive: true,
    555        });
    556      }
    557    }
    558  }
    559 
    560  /**
    561   * Handles various events and forwards them to appropriate functions.
    562   *
    563   * @param {DOMEvent} event
    564   *   The event that will be handled
    565   */
    566  handleEvent(event) {
    567    switch (event.type) {
    568      case "TabSelect":
    569        this.#onTabSelect(event.detail.previousTab.linkedBrowser);
    570        break;
    571      case "activate":
    572        this.#onActivateWindow(event.target);
    573        break;
    574      case "deactivate":
    575        this.#onDeactivateWindow(event.target);
    576        break;
    577      case "unload":
    578        this.#unregisterWindow(event.target);
    579        break;
    580    }
    581  }
    582 
    583  /**
    584   * Handles notifications from the observer service.
    585   *
    586   * @param {nsISupports} subject
    587   *   The subject of the notification.
    588   * @param {string} topic
    589   *   The topic of the notification.
    590   */
    591  observe(subject, topic) {
    592    switch (topic) {
    593      case DOMWINDOW_OPENED_TOPIC:
    594        this.#onWindowOpen(subject);
    595        break;
    596      case "idle":
    597        lazy.logConsole.debug("User went idle");
    598        // We save the state of the current interaction when we are notified
    599        // that the user is idle.
    600        this.#updateInteraction();
    601        this.#userIsIdle = true;
    602        break;
    603      case "active":
    604        lazy.logConsole.debug("User became active");
    605        this.#userIsIdle = false;
    606        this._pageViewStartTime = ChromeUtils.now();
    607        break;
    608    }
    609  }
    610 
    611  /**
    612   * Handles registration of listeners in a new window.
    613   *
    614   * @param {DOMWindow} win
    615   *   The window to register in.
    616   */
    617  #registerWindow(win) {
    618    if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
    619      return;
    620    }
    621 
    622    win.addEventListener("TabSelect", this, true);
    623    win.addEventListener("deactivate", this, true);
    624    win.addEventListener("activate", this, true);
    625  }
    626 
    627  /**
    628   * Handles removing of listeners from a window.
    629   *
    630   * @param {DOMWindow} win
    631   *   The window to remove listeners from.
    632   */
    633  #unregisterWindow(win) {
    634    win.removeEventListener("TabSelect", this, true);
    635    win.removeEventListener("deactivate", this, true);
    636    win.removeEventListener("activate", this, true);
    637  }
    638 
    639  /**
    640   * Handles a new window being opened, waits for load and checks that
    641   * it is a browser window, then adds listeners.
    642   *
    643   * @param {DOMWindow} win
    644   *   The window being opened.
    645   */
    646  #onWindowOpen(win) {
    647    win.addEventListener(
    648      "load",
    649      () => {
    650        if (
    651          win.document.documentElement.getAttribute("windowtype") !=
    652          "navigator:browser"
    653        ) {
    654          return;
    655        }
    656        this.#registerWindow(win);
    657      },
    658      { once: true }
    659    );
    660  }
    661 
    662  QueryInterface = ChromeUtils.generateQI([
    663    "nsIObserver",
    664    "nsISupportsWeakReference",
    665  ]);
    666 }
    667 
    668 export const Interactions = new _Interactions();
    669 
    670 /**
    671 * Store interactions data in the Places database.
    672 * To improve performance the writes are buffered every `saveInterval`
    673 * milliseconds. Even if this means we could be trying to write interaction for
    674 * pages that in the meanwhile have been removed, that's not a problem because
    675 * we won't be able to insert entries having a NULL place_id, they will just be
    676 * ignored.
    677 * Use .add(interaction) to request storing of an interaction.
    678 * Use .pendingPromise to await for any pending writes to have happened.
    679 */
    680 class InteractionsStore {
    681  /**
    682   * Timer to run database updates on.
    683   */
    684  #timer = undefined;
    685  /**
    686   * Tracks interactions replicating the unique index in the underlying schema.
    687   * Interactions are keyed by url and then created_at.
    688   *
    689   * @type {Map<string, Map<number, InteractionInfo>>}
    690   */
    691  #interactions = new Map();
    692  /**
    693   * Used to unblock the queue of promises when the timer is cleared.
    694   */
    695  #timerResolve = undefined;
    696 
    697  constructor() {
    698    // Block async shutdown to ensure the last write goes through.
    699    this.progress = {};
    700    lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker(
    701      "Interactions.sys.mjs:: store",
    702      async () => this.flush(),
    703      { fetchState: () => this.progress }
    704    );
    705 
    706    // Can be used to wait for the last pending write to have happened.
    707    this.pendingPromise = Promise.resolve();
    708  }
    709 
    710  /**
    711   * Synchronizes the pending interactions with the storage device.
    712   *
    713   * @returns {Promise} resolved when the pending data is on disk.
    714   */
    715  async flush() {
    716    if (this.#timer) {
    717      lazy.clearTimeout(this.#timer);
    718      this.#timerResolve();
    719      await this.#updateDatabase();
    720    }
    721  }
    722 
    723  /**
    724   * Completely clears the store and any pending writes.
    725   * This exists for testing purposes.
    726   */
    727  async reset() {
    728    await lazy.PlacesUtils.withConnectionWrapper(
    729      "Interactions.sys.mjs::reset",
    730      async db => {
    731        await db.executeCached(`DELETE FROM moz_places_metadata`);
    732      }
    733    );
    734    if (this.#timer) {
    735      lazy.clearTimeout(this.#timer);
    736      this.#timer = undefined;
    737      this.#timerResolve();
    738      this.#interactions.clear();
    739    }
    740  }
    741 
    742  /**
    743   * Registers an interaction to be stored persistently. At the end of the call
    744   * the interaction has not yet been added to the store, tests can await
    745   * flushStore() for that.
    746   *
    747   * @param {InteractionInfo} interaction
    748   *   The document information to write.
    749   */
    750  add(interaction) {
    751    lazy.logConsole.debug("Preparing interaction for storage", interaction);
    752 
    753    let interactionsForUrl = this.#interactions.get(interaction.url);
    754    if (!interactionsForUrl) {
    755      interactionsForUrl = new Map();
    756      this.#interactions.set(interaction.url, interactionsForUrl);
    757    }
    758    interactionsForUrl.set(interaction.created_at, interaction);
    759 
    760    if (!this.#timer) {
    761      let promise = new Promise(resolve => {
    762        this.#timerResolve = resolve;
    763        this.#timer = lazy.setTimeout(() => {
    764          this.#updateDatabase().catch(console.error).then(resolve);
    765        }, lazy.saveInterval);
    766      });
    767      this.pendingPromise = this.pendingPromise.then(() => promise);
    768    }
    769  }
    770 
    771  async #updateDatabase() {
    772    this.#timer = undefined;
    773 
    774    // Reset the buffer.
    775    let interactions = this.#interactions;
    776    if (!interactions.size) {
    777      return;
    778    }
    779    // Don't clear() this, since that would also clear interactions.
    780    this.#interactions = new Map();
    781 
    782    let params = {};
    783    let SQLInsertFragments = [];
    784    let i = 0;
    785    for (let interactionsForUrl of interactions.values()) {
    786      for (let interaction of interactionsForUrl.values()) {
    787        params[`url${i}`] = interaction.url;
    788        params[`referrer${i}`] = interaction.referrer;
    789        params[`created_at${i}`] = interaction.created_at;
    790        params[`updated_at${i}`] = interaction.updated_at;
    791        params[`document_type${i}`] =
    792          interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC;
    793        params[`total_view_time${i}`] =
    794          Math.round(interaction.totalViewTime) || 0;
    795        params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0;
    796        params[`key_presses${i}`] = interaction.keypresses || 0;
    797        params[`scrolling_time${i}`] =
    798          Math.round(interaction.scrollingTime) || 0;
    799        params[`scrolling_distance${i}`] =
    800          Math.round(interaction.scrollingDistance) || 0;
    801        SQLInsertFragments.push(`(
    802          (SELECT id FROM moz_places_metadata
    803            WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i})
    804              AND created_at = :created_at${i}),
    805          (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}),
    806          (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}),
    807          :created_at${i},
    808          :updated_at${i},
    809          :document_type${i},
    810          :total_view_time${i},
    811          :typing_time${i},
    812          :key_presses${i},
    813          :scrolling_time${i},
    814          :scrolling_distance${i}
    815        )`);
    816        i++;
    817      }
    818    }
    819 
    820    lazy.logConsole.debug(`Storing ${i} entries in the database`);
    821 
    822    this.progress.pendingUpdates = i;
    823    await lazy.PlacesUtils.withConnectionWrapper(
    824      "Interactions.sys.mjs::updateDatabase",
    825      async db => {
    826        await db.executeCached(
    827          `
    828          WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS (
    829            VALUES ${SQLInsertFragments.join(", ")}
    830          )
    831          INSERT OR REPLACE INTO moz_places_metadata (
    832            id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance
    833          ) SELECT * FROM inserts WHERE place_id NOT NULL;
    834        `,
    835          params
    836        );
    837      }
    838    );
    839    this.progress.pendingUpdates = 0;
    840 
    841    Services.obs.notifyObservers(null, "places-metadata-updated");
    842  }
    843 }