tor-browser

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

HistoryController.sys.mjs (14210B)


      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 const lazy = {};
      6 
      7 import { getLogger } from "chrome://browser/content/firefoxview/helpers.mjs";
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  PlacesQuery: "resource://gre/modules/PlacesQuery.sys.mjs",
     11  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     12 });
     13 
     14 let XPCOMUtils = ChromeUtils.importESModule(
     15  "resource://gre/modules/XPCOMUtils.sys.mjs"
     16 ).XPCOMUtils;
     17 
     18 XPCOMUtils.defineLazyPreferenceGetter(
     19  lazy,
     20  "maxRowsPref",
     21  "browser.firefox-view.max-history-rows",
     22  -1
     23 );
     24 
     25 const HISTORY_MAP_L10N_IDS = {
     26  sidebar: {
     27    "history-date-today": "sidebar-history-date-today",
     28    "history-date-yesterday": "sidebar-history-date-yesterday",
     29    "history-date-this-month": "sidebar-history-date-this-month",
     30    "history-date-prev-month": "sidebar-history-date-prev-month",
     31  },
     32  firefoxview: {
     33    "history-date-today": "firefoxview-history-date-today",
     34    "history-date-yesterday": "firefoxview-history-date-yesterday",
     35    "history-date-this-month": "firefoxview-history-date-this-month",
     36    "history-date-prev-month": "firefoxview-history-date-prev-month",
     37  },
     38 };
     39 
     40 /**
     41 * When sorting by date or site, each card "item" is a single visit.
     42 *
     43 * When sorting by date *and* site, each card "item" is a mapping of site
     44 * domains to their respective list of visits.
     45 *
     46 * @typedef {HistoryVisit | [string, HistoryVisit[]]} CardItem
     47 */
     48 
     49 /**
     50 * A list of visits displayed on a card.
     51 *
     52 * @typedef {object} CardEntry
     53 *
     54 * @property {string} domain
     55 * @property {CardItem[]} items
     56 * @property {string} l10nId
     57 */
     58 
     59 export class HistoryController {
     60  /**
     61   * @type {{ entries: CardEntry[]; searchQuery: string; sortOption: string; }}
     62   */
     63  historyCache;
     64  host;
     65  searchQuery;
     66  sortOption;
     67  #todaysDate;
     68  #yesterdaysDate;
     69 
     70  constructor(host, options) {
     71    this.placesQuery = new lazy.PlacesQuery();
     72    this.searchQuery = "";
     73    this.sortOption = "date";
     74    this.searchResultsLimit = options?.searchResultsLimit || 300;
     75    this.component = HISTORY_MAP_L10N_IDS?.[options?.component]
     76      ? options?.component
     77      : "firefoxview";
     78    this.historyCache = {
     79      entries: null,
     80      searchQuery: null,
     81      sortOption: null,
     82    };
     83    this.host = host;
     84 
     85    host.addController(this);
     86  }
     87 
     88  hostConnected() {
     89    this.placesQuery.observeHistory(historyMap => this.updateCache(historyMap));
     90  }
     91 
     92  hostDisconnected() {
     93    this.placesQuery.close();
     94  }
     95 
     96  deleteFromHistory() {
     97    return lazy.PlacesUtils.history.remove(this.host.triggerNode.url);
     98  }
     99 
    100  onSearchQuery(e) {
    101    this.searchQuery = e.detail.query;
    102    this.updateCache();
    103  }
    104 
    105  onChangeSortOption(e, value = e.target.value) {
    106    this.sortOption = value;
    107    this.updateCache();
    108  }
    109 
    110  get historyVisits() {
    111    return this.historyCache.entries || [];
    112  }
    113 
    114  get isHistoryPending() {
    115    return this.historyCache.entries === null;
    116  }
    117 
    118  get searchResults() {
    119    if (this.historyCache.searchQuery && this.historyCache.entries?.length) {
    120      return this.historyCache.entries[0].items;
    121    }
    122    return null;
    123  }
    124 
    125  get totalVisitsCount() {
    126    return this.historyVisits.reduce(
    127      (count, entry) => count + entry.items.length,
    128      0
    129    );
    130  }
    131 
    132  get isHistoryEmpty() {
    133    return !this.historyVisits.length;
    134  }
    135 
    136  /**
    137   * Update cached history.
    138   *
    139   * @param {CachedHistory} [historyMap]
    140   *   If provided, performs an update using the given data (instead of fetching
    141   *   it from the db).
    142   */
    143  async updateCache(historyMap) {
    144    const { searchQuery, sortOption } = this;
    145    const entries = searchQuery
    146      ? await this.#getVisitsForSearchQuery(searchQuery)
    147      : await this.#getVisitsForSortOption(sortOption, historyMap);
    148    if (
    149      this.searchQuery !== searchQuery ||
    150      this.sortOption !== sortOption ||
    151      !entries
    152    ) {
    153      // This query is stale, discard results and do not update the cache / UI.
    154      return;
    155    }
    156    for (const { items } of entries) {
    157      for (const item of items) {
    158        switch (sortOption) {
    159          case "datesite": {
    160            // item is a [ domain, visit[] ] entry.
    161            const [, visits] = item;
    162            for (const visit of visits) {
    163              this.#normalizeVisit(visit);
    164            }
    165            break;
    166          }
    167          default:
    168            // item is a single visit.
    169            this.#normalizeVisit(item);
    170        }
    171      }
    172    }
    173    this.historyCache = { entries, searchQuery, sortOption };
    174    this.host.requestUpdate();
    175  }
    176 
    177  /**
    178   * Normalize data for fxview-tabs-list.
    179   *
    180   * @param {HistoryVisit} visit
    181   *   The visit to format.
    182   */
    183  #normalizeVisit(visit) {
    184    visit.time = visit.date.getTime();
    185    visit.title = visit.title || visit.url;
    186    visit.icon = `page-icon:${visit.url}`;
    187    visit.primaryL10nId = "fxviewtabrow-tabs-list-tab";
    188    visit.primaryL10nArgs = JSON.stringify({
    189      targetURI: visit.url,
    190    });
    191    visit.secondaryL10nId = "fxviewtabrow-options-menu-button";
    192    visit.secondaryL10nArgs = JSON.stringify({
    193      tabTitle: visit.title || visit.url,
    194    });
    195  }
    196 
    197  async #getVisitsForSearchQuery(searchQuery) {
    198    let items = [];
    199    try {
    200      items = await this.placesQuery.searchHistory(
    201        searchQuery,
    202        this.searchResultsLimit
    203      );
    204    } catch (e) {
    205      getLogger("HistoryController").warn(
    206        "There is a new search query in progress, so cancelling this one.",
    207        e
    208      );
    209    }
    210    return [{ items }];
    211  }
    212 
    213  async #getVisitsForSortOption(sortOption, historyMap) {
    214    if (!historyMap) {
    215      const fetchedHistory = await this.#fetchHistory();
    216      if (!fetchedHistory) {
    217        return null;
    218      }
    219      historyMap = fetchedHistory;
    220    }
    221    switch (sortOption) {
    222      case "date":
    223        this.#setTodaysDate();
    224        return this.#getVisitsForDate(historyMap);
    225      case "site":
    226        return this.#getVisitsForSite(historyMap);
    227      case "datesite":
    228        this.#setTodaysDate();
    229        return this.#getVisitsForDateSite(historyMap);
    230      case "lastvisited":
    231        return this.#getVisitsForLastVisited(historyMap);
    232      default:
    233        return [];
    234    }
    235  }
    236 
    237  #setTodaysDate() {
    238    const now = new Date();
    239    this.#todaysDate = new Date(
    240      now.getFullYear(),
    241      now.getMonth(),
    242      now.getDate()
    243    );
    244    this.#yesterdaysDate = new Date(
    245      now.getFullYear(),
    246      now.getMonth(),
    247      now.getDate() - 1
    248    );
    249  }
    250 
    251  /**
    252   * Get a list of visits, sorted by date, in reverse chronological order.
    253   *
    254   * @param {Map<number, HistoryVisit[]>} historyMap
    255   * @returns {CardEntry[]}
    256   */
    257  #getVisitsForDate(historyMap) {
    258    const entries = [];
    259    const visitsFromToday = this.#getVisitsFromToday(historyMap);
    260    const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap);
    261    const visitsByDay = this.#getVisitsByDay(historyMap);
    262    const visitsByMonth = this.#getVisitsByMonth(historyMap);
    263 
    264    // Add visits from today and yesterday.
    265    if (visitsFromToday.length) {
    266      entries.push({
    267        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"],
    268        items: visitsFromToday,
    269      });
    270    }
    271    if (visitsFromYesterday.length) {
    272      entries.push({
    273        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"],
    274        items: visitsFromYesterday,
    275      });
    276    }
    277 
    278    // Add visits from this month, grouped by day.
    279    visitsByDay.forEach(visits => {
    280      entries.push({
    281        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"],
    282        items: visits,
    283      });
    284    });
    285 
    286    // Add visits from previous months, grouped by month.
    287    visitsByMonth.forEach(visits => {
    288      entries.push({
    289        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"],
    290        items: visits,
    291      });
    292    });
    293    return entries;
    294  }
    295 
    296  #getVisitsFromToday(cachedHistory) {
    297    const mapKey = this.placesQuery.getStartOfDayTimestamp(this.#todaysDate);
    298    const visits = cachedHistory.get(mapKey) ?? [];
    299    return [...visits];
    300  }
    301 
    302  #getVisitsFromYesterday(cachedHistory) {
    303    const mapKey = this.placesQuery.getStartOfDayTimestamp(
    304      this.#yesterdaysDate
    305    );
    306    const visits = cachedHistory.get(mapKey) ?? [];
    307    return [...visits];
    308  }
    309 
    310  /**
    311   * Get a list of visits per day for each day on this month, excluding today
    312   * and yesterday.
    313   *
    314   * @param {CachedHistory} cachedHistory
    315   *   The history cache to process.
    316   * @returns {CardItem[]}
    317   *   A list of visits for each day.
    318   */
    319  #getVisitsByDay(cachedHistory) {
    320    const visitsPerDay = [];
    321    for (const [time, visits] of cachedHistory.entries()) {
    322      const date = new Date(time);
    323      if (
    324        this.#isSameDate(date, this.#todaysDate) ||
    325        this.#isSameDate(date, this.#yesterdaysDate)
    326      ) {
    327        continue;
    328      } else if (!this.#isSameMonth(date, this.#todaysDate)) {
    329        break;
    330      } else {
    331        visitsPerDay.push(visits);
    332      }
    333    }
    334    return visitsPerDay;
    335  }
    336 
    337  /**
    338   * Get a list of visits per month for each month, excluding this one, and
    339   * excluding yesterday's visits if yesterday happens to fall on the previous
    340   * month.
    341   *
    342   * @param {CachedHistory} cachedHistory
    343   *   The history cache to process.
    344   * @returns {CardItem[]}
    345   *   A list of visits for each month.
    346   */
    347  #getVisitsByMonth(cachedHistory) {
    348    const visitsPerMonth = [];
    349    let previousMonth = null;
    350    for (const [time, visits] of cachedHistory.entries()) {
    351      const date = new Date(time);
    352      if (
    353        this.#isSameMonth(date, this.#todaysDate) ||
    354        this.#isSameDate(date, this.#yesterdaysDate)
    355      ) {
    356        continue;
    357      }
    358      const month = this.placesQuery.getStartOfMonthTimestamp(date);
    359      if (month !== previousMonth) {
    360        visitsPerMonth.push(visits);
    361      } else if (this.sortOption === "datesite") {
    362        // CardItem type is currently Map<string, HistoryVisit[]>.
    363        visitsPerMonth[visitsPerMonth.length - 1] = this.#mergeMaps(
    364          visitsPerMonth.at(-1),
    365          visits
    366        );
    367      } else {
    368        visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth
    369          .at(-1)
    370          .concat(visits);
    371      }
    372      previousMonth = month;
    373    }
    374    return visitsPerMonth;
    375  }
    376 
    377  /**
    378   * Given two date instances, check if their dates are equivalent.
    379   *
    380   * @param {Date} dateToCheck
    381   * @param {Date} date
    382   * @returns {boolean}
    383   *   Whether both date instances have equivalent dates.
    384   */
    385  #isSameDate(dateToCheck, date) {
    386    return (
    387      dateToCheck.getDate() === date.getDate() &&
    388      this.#isSameMonth(dateToCheck, date)
    389    );
    390  }
    391 
    392  /**
    393   * Given two date instances, check if their months are equivalent.
    394   *
    395   * @param {Date} dateToCheck
    396   * @param {Date} month
    397   * @returns {boolean}
    398   *   Whether both date instances have equivalent months.
    399   */
    400  #isSameMonth(dateToCheck, month) {
    401    return (
    402      dateToCheck.getMonth() === month.getMonth() &&
    403      dateToCheck.getFullYear() === month.getFullYear()
    404    );
    405  }
    406 
    407  /**
    408   * Merge two maps of (domain: string) => HistoryVisit[] into a single map.
    409   *
    410   * @param {Map<string, HistoryVisit[]>} oldMap
    411   * @param {Map<string, HistoryVisit[]>} newMap
    412   * @returns {Map<string, HistoryVisit[]>}
    413   */
    414  #mergeMaps(oldMap, newMap) {
    415    const map = new Map(oldMap);
    416    for (const [domain, newVisits] of newMap) {
    417      const oldVisits = map.get(domain);
    418      map.set(domain, oldVisits?.concat(newVisits) ?? newVisits);
    419    }
    420    return map;
    421  }
    422 
    423  /**
    424   * Get a list of visits, sorted by site, in alphabetical order.
    425   *
    426   * @param {Map<string, HistoryVisit[]>} historyMap
    427   * @returns {CardEntry[]}
    428   */
    429  #getVisitsForSite(historyMap) {
    430    return Array.from(historyMap.entries(), ([domain, items]) => ({
    431      domain,
    432      items,
    433      l10nId: domain ? null : "firefoxview-history-site-localhost",
    434    })).sort((a, b) => a.domain.localeCompare(b.domain));
    435  }
    436 
    437  /**
    438   * Get a list of visits, sorted by date and site, in reverse chronological
    439   * order.
    440   *
    441   * @param {Map<number, Map<string, HistoryVisit[]>>} historyMap
    442   * @returns {CardEntry[]}
    443   */
    444  #getVisitsForDateSite(historyMap) {
    445    const entries = [];
    446    const visitsFromToday = this.#getVisitsFromToday(historyMap);
    447    const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap);
    448    const visitsByDay = this.#getVisitsByDay(historyMap);
    449    const visitsByMonth = this.#getVisitsByMonth(historyMap);
    450 
    451    /**
    452     * Sorts items alphabetically by domain name.
    453     *
    454     * @param {[string, HistoryVisit[]][]} items
    455     * @returns {[string, HistoryVisit[]][]} The items in sorted order.
    456     */
    457    function sortItems(items) {
    458      return items.sort(([aDomain], [bDomain]) =>
    459        aDomain.localeCompare(bDomain)
    460      );
    461    }
    462 
    463    // Add visits from today and yesterday.
    464    if (visitsFromToday.length) {
    465      entries.push({
    466        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"],
    467        items: sortItems(visitsFromToday),
    468      });
    469    }
    470    if (visitsFromYesterday.length) {
    471      entries.push({
    472        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"],
    473        items: sortItems(visitsFromYesterday),
    474      });
    475    }
    476 
    477    // Add visits from this month, grouped by day.
    478    visitsByDay.forEach(visits => {
    479      entries.push({
    480        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"],
    481        items: sortItems([...visits]),
    482      });
    483    });
    484 
    485    // Add visits from previous months, grouped by month.
    486    visitsByMonth.forEach(visits => {
    487      entries.push({
    488        l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"],
    489        items: sortItems([...visits]),
    490      });
    491    });
    492 
    493    return entries;
    494  }
    495 
    496  /**
    497   * Get a list of visits sorted by recency.
    498   *
    499   * @param {HistoryVisit[]} items
    500   * @returns {CardEntry[]}
    501   */
    502  #getVisitsForLastVisited(items) {
    503    return [{ items }];
    504  }
    505 
    506  async #fetchHistory() {
    507    return this.placesQuery.getHistory({
    508      daysOld: 60,
    509      limit: lazy.maxRowsPref,
    510      sortBy: this.sortOption,
    511    });
    512  }
    513 }