chromium/chrome/browser/resources/chromeos/emoji_picker/app.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './icons.html.js';
import './emoji_image.js';
import './emoji_gif_nudge_overlay.js';
import './emoji_group.js';
import './emoji_group_button.js';
import './emoji_search.js';
import './emoji_error.js';
import './emoji_category_button.js';
import './text_group_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_auto_img/cr_auto_img.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icons.css.js';

import {getInstance as getAnnouncerInstance} from '//resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {CrIconButtonElement} from '//resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './app.html.js';
import * as constants from './constants.js';
import {EmojiGroupComponent} from './emoji_group.js';
import {Category, Feature, Status} from './emoji_picker.mojom-webui.js';
import {EmojiPickerApiProxy} from './emoji_picker_api_proxy.js';
import {EmojiSearch} from './emoji_search.js';
import * as events from './events.js';
import {CATEGORY_METADATA, CATEGORY_TABS, EMOJI_GROUP_TABS, GIF_CATEGORY_METADATA, gifCategoryTabs, SUBCATEGORY_TABS, TABS_CATEGORY_START_INDEX, TABS_CATEGORY_START_INDEX_GIF_SUPPORT} from './metadata_extension.js';
import {EmojiPreferencesStore, GifNudgeHistoryStore, RecentlyUsedStore} from './store.js';
import {CategoryEnum, Emoji, EmojiGroupData, EmojiGroupElement, EmojiVariants, Gender, GifSubcategoryData, PreferenceMapping, SubcategoryData, Tone} from './types.js';

export interface EmojiPickerApp {
  $: {
    'left-chevron': CrIconButtonElement,
    'list-container': HTMLDivElement,
    'right-chevron': CrIconButtonElement,
    'search-container': EmojiSearch,
    bar: HTMLDivElement,
    dummyTab: HTMLDivElement,
    groups: HTMLDivElement,
    message: HTMLDivElement,
    tabs: HTMLDivElement,
  };
}


export class EmojiPickerApp extends PolymerElement {
  static get is() {
    return 'emoji-picker-app' as const;
  }

  static get template() {
    return getTemplate();
  }

  static configs() {
    return {
      'dataUrls': {
        [CategoryEnum.EMOJI]: [
          '/emoji_15_0_ordering_start.json',
          '/emoji_15_0_ordering_remaining.json',
        ],
        [CategoryEnum.EMOTICON]: ['/emoticon_ordering.json'],
        [CategoryEnum.SYMBOL]: ['/symbol_ordering.json'],
        // GIFs are not preloaded hence the empty data url.
        [CategoryEnum.GIF]: [''],
      },
    };
  }

  static get properties() {
    return {
      category: {type: String, value: 'emoji', observer: 'onCategoryChanged'},
      emojiGroupTabs: {type: Array},
      dummyTab: {
        type: Object,
        value: () => ({
          name: '',
          groupId: '-1',
          active: false,
          disabled: false,
          category: CategoryEnum.GIF,
        }),
      },
      categoriesData: {type: Array, value: () => ([])},
      categoriesGroupElements: {type: Array, value: () => ([])},
      activeInfiniteGroupId: {type: String, value: null},
      categoriesHistory: {type: Object, value: () => ({})},
      globalTone: {type: Number, value: null},
      globalGender: {type: Number, value: null},
      pagination: {type: Number, value: 1, observer: 'onPaginationChanged'},
      searchLazyIndexing: {type: Boolean, value: true},
      textSubcategoryBarEnabled: {
        type: Boolean,
        value: false,
        computed: 'isTextSubcategoryBarEnabled(category)',
        reflectToAttribute: true,
      },
      searchExtensionEnabled: {type: Boolean, value: false},
      incognito: {type: Boolean, value: true},
      gifSupport: {type: Boolean, value: false},
      sealSupport: {type: Boolean, value: false},
      variantGroupingSupport: {type: Boolean, value: false},
      showGifNudgeOverlay: {type: Boolean, value: false},
      nextGifPos: {type: Object, value: () => ({})},
      status: {type: Status, value: null},
      errorMessage: {type: String, value: constants.NO_INTERNET_VIEW_ERROR_MSG},
      useMojoSearch: {type: Boolean, value: false},
    };
  }
  private category: CategoryEnum;
  private emojiGroupTabs: SubcategoryData[] = EMOJI_GROUP_TABS;
  private dummyTab: SubcategoryData;
  private allCategoryTabs: SubcategoryData[] = SUBCATEGORY_TABS;
  categoriesData: EmojiGroupData;
  categoriesGroupElements: EmojiGroupElement[];
  activeInfiniteGroupId: string|null; // null before Trending GIFs are fetched
  private categoriesHistory: {[index in CategoryEnum]: RecentlyUsedStore|null};
  private emojiPreferences: EmojiPreferencesStore|null = null;
  private globalTone: Tone|null = null;
  private globalGender: Gender|null = null;
  private pagination: number;
  private searchLazyIndexing: boolean;
  private textSubcategoryBarEnabled: boolean;
  private searchExtensionEnabled: boolean;
  private incognito: boolean;
  private gifSupport: boolean;
  private sealSupport: boolean;
  private variantGroupingSupport: boolean;
  private showGifNudgeOverlay: boolean;
  private activeVariant: EmojiGroupComponent|null = null;
  private apiProxy: EmojiPickerApiProxy = EmojiPickerApiProxy.getInstance();
  private autoScrollingToGroup: boolean = false;
  private highlightBarMoving: boolean = false;
  private nextGifPos: {[key: string]: string};
  private status: Status|null;
  private previousGifValidation: Date;
  private fetchAndProcessDataPromise: Promise<void>|null;
  private useMojoSearch = false;

  constructor() {
    super();

    // Incognito mode is set based on the default value.
    this.updateIncognitoState(this.incognito);

    this.previousGifValidation = this.loadPreviousGifValidationTime();

    this.addEventListener(
        events.GROUP_BUTTON_CLICK,
        (ev: events.GroupButtonClickEvent) =>
            this.selectGroup(ev.detail.group));
    this.addEventListener(
        events.EMOJI_TEXT_BUTTON_CLICK,
        (ev: events.EmojiTextButtonClickEvent) =>
            this.onEmojiTextButtonClick(ev));
    this.addEventListener(
        events.EMOJI_IMG_BUTTON_CLICK,
        (ev: events.EmojiImgButtonClickEvent) =>
            this.onEmojiImgButtonClick(ev));
    this.addEventListener(
        events.EMOJI_CLEAR_RECENTS_CLICK,
        (ev: events.EmojiClearRecentClickEvent) => this.clearRecentEmoji(ev));
    // variant popup related handlers
    this.addEventListener(
        events.EMOJI_VARIANTS_SHOWN,
        (ev: events.EmojiVariantsShownEvent) => this.onShowEmojiVariants(ev));
    this.addEventListener('click', () => this.hideDialogs());
    this.addEventListener(
        events.CATEGORY_BUTTON_CLICK,
        (ev: events.CategoryButtonClickEvent) =>
            this.onCategoryButtonClick(ev.detail.categoryName));
    this.addEventListener(
        'search',
        ev => this.onSearchChanged((ev as CustomEvent<string>).detail));
    this.addEventListener(events.GIF_ERROR_TRY_AGAIN, this.onClickTryAgain);

    // This function will be passed down to some child element, thus we need
    // `bind(this)`.
    this.closeGifNudgeOverlay = this.closeGifNudgeOverlay.bind(this);
  }

  private filterGroupTabByPagination(pageNumber: number): (tab: {
    pagination: number,
    groupId: string,
  }) => boolean {
    return function(tab: {pagination: number, groupId: string}) {
      return tab.pagination === pageNumber && !tab.groupId.includes('history');
    };
  }

  private async initHistoryUi(incognito: boolean) {
    if (incognito !== this.incognito) {
      await this.updateIncognitoState(incognito);
    }
    this.updateHistoryTabDisabledProperty();
    // Make highlight bar visible (now we know where it should be) and
    // add smooth sliding.
    this.updateActiveGroup();
    this.$.bar.style.display = 'block';
    this.$.bar.style.transition = 'left 200ms';
  }

  override ready() {
    super.ready();

    const METADATA =
        this.gifSupport ? GIF_CATEGORY_METADATA : CATEGORY_METADATA;

    // Ensure first category is emoji for compatibility with V1.
    if (METADATA[0]?.name !== CategoryEnum.EMOJI) {
      throw new Error(
          `First category is ${METADATA[0]?.name} but must be 'emoji'.`);
    }

    const dataUrls = EmojiPickerApp.configs().dataUrls;
    // Create an ordered list of category and urls based on the order that
    // categories need to appear in the UIs.
    const categoryDataUrls =
        METADATA.filter((item) => dataUrls[item.name])
            .map(
                item => ({'category': item.name, 'urls': dataUrls[item.name]}));

    // Fetch and process all the data.
    this.fetchAndProcessDataPromise = this.fetchAndProcessData(categoryDataUrls);

    this.updateStyles({
      '--emoji-group-button-size': constants.EMOJI_GROUP_SIZE_PX,
      '--emoji-picker-width': constants.EMOJI_PICKER_WIDTH_PX,
      '--emoji-picker-height': constants.EMOJI_PICKER_HEIGHT_PX,
      '--emoji-size': constants.EMOJI_SIZE_PX,
      '--emoji-per-row': constants.EMOJI_PER_ROW,
      '--emoji-picker-search-side-padding':
          constants.EMOJI_PICKER_SIDE_PADDING_PX,
      '--emoji-picker-side-padding': constants.EMOJI_PICKER_SIDE_PADDING_PX,
      '--emoji-picker-top-padding': constants.EMOJI_PICKER_TOP_PADDING_PX,
      '--emoji-spacing': constants.EMOJI_SPACING_PX,
      '--emoji-group-spacing': constants.EMOJI_GROUP_SPACING_PX,
      '--visual-content-padding': constants.VISUAL_CONTENT_PADDING_PX,
      '--visual-content-width': constants.VISUAL_CONTENT_WIDTH_PX,
      '--tab-button-margin': constants.TAB_BUTTON_MARGIN_PX,
      '--text-group-button-padding': constants.TEXT_GROUP_BUTTON_PADDING_PX,
    });
  }

  private async ensureFetchAndProcessDataFinished(): Promise<void> {
    if (this.fetchAndProcessDataPromise !== null) {
      await this.fetchAndProcessDataPromise;
    }
  }

  /**
   * Fetches data and updates all the variables that are required to render
   * EmojiPicker UI. This function serves as the main entry for creating and
   * managing async calls dealing with fetching data and rendering UI in the
   * correct order. These include:
   *   * Feature list
   *   * Incognito state
   *   * Category data (emoji, emoticon, etc.)
   */
  private async fetchAndProcessData(
      categoryDataUrls: Array<{category: CategoryEnum, urls: string[]}>) {
    // Create a flat list of urls (with details) that need to be fetched and
    // rendered sequentially.
    const dataUrls = categoryDataUrls.flatMap(
        item =>
            // Create url details of the category.
        item.urls.map(
            (url, index) => ({
              'category': item.category,
              'url': url,
              'categoryLastPartition': index === item.urls.length - 1,
            }),
            ),
    );

    const firstResult = dataUrls[0];
    if (!firstResult) {
      return;
    }

    // Update feature list, incognito state and fetch data of first url.
    const initialData =
        await Promise
            .all(
                [
                  this.fetchOrderingData(firstResult.url),
                  this.apiProxy.getFeatureList().then(
                      (response: {featureList: number[]}) =>
                          this.setActiveFeatures(response.featureList)),
                  this.apiProxy.isIncognitoTextField().then(
                      (response: {incognito: boolean}) =>
                          this.initHistoryUi(response.incognito)),
                ],
                )
            .then(values => values[0]);  // Map to the fetched data only.

    // After initial data is loaded, if the GIF nudge is not shown before, show
    // the GIF nudge.
    if (this.gifSupport && !GifNudgeHistoryStore.hasNudgeShown()) {
      this.showGifNudgeOverlay = true;
    }

    if (this.gifSupport) {
      this.updateStyles({
        '--emoji-category-size': constants.V2_5_EMOJI_CATEGORY_SIZE_PX,
        '--emoji-group-button-size': constants.V2_5_EMOJI_GROUP_SIZE_PX,
        '--emoji-picker-side-padding':
            constants.V2_5_EMOJI_PICKER_SIDE_PADDING_PX,
        '--emoji-picker-search-side-padding':
            constants.V2_5_EMOJI_PICKER_SEARCH_SIDE_PADDING_PX,
        '--emoji-spacing': constants.V2_5_EMOJI_SPACING_PX,
        '--emoji-group-spacing': constants.V2_5_EMOJI_GROUP_SPACING_PX,
        '--visual-content-width': constants.V2_5_VISUAL_CONTENT_WIDTH_PX,
      });
    }

    // Update UI and relevant features based on the initial data.
    this.updateCategoryData(
        // If we don't have 1 data URL, a crash probably isn't a bad idea
        initialData, dataUrls[0]!.category, dataUrls[0]!.categoryLastPartition,
        false);

    // Show the UI after the initial data is rendered.
    afterNextRender(this, () => {
      this.apiProxy.showUi();
    });

    // Filter data, remove the first url as it is
    // already added and shown.
    const remainingData = dataUrls.slice(1);

    let prevFetchPromise: Promise<EmojiGroupData> = Promise.resolve([]);
    let prevRenderPromise = Promise.resolve();

    // Create a chain of promises for fetching and rendering data of
    // different categories in the correct order.
    remainingData.forEach(
        (dataUrl, index) => {
          // Fetch the url only after the previous url is fetched.
          prevFetchPromise =
              prevFetchPromise.then(() => this.fetchOrderingData(dataUrl.url));

          // Update category data after the data is fetched and the previous
          // category data update/rendering completed successfully.
          prevRenderPromise = Promise
                                  .all(
                                      [prevRenderPromise, prevFetchPromise],
                                      )
                                  // Hacky cast below, but should be safe
                                  .then((values) => values[1])
                                  .then(
                                      (data) => this.updateCategoryData(
                                          data,
                                          dataUrl.category,
                                          dataUrl.categoryLastPartition,
                                          index === remainingData.length - 1,
                                          ),
                                  );
        },
    );

    if (this.gifSupport) {
      await this.fetchAndProcessGifData(prevFetchPromise, prevRenderPromise);
    }
  }

  private fetchAndProcessGifData(
      prevFetchPromise: Promise<EmojiGroupData> = Promise.resolve([]),
      prevRenderPromise = Promise.resolve()): Promise<void> {
    this.validateRecentlyUsedGifs();

    // Set Recently Used and Trending in the GIF tabs first before fetching
    // Tenor API data
    const trendingGifData = {name: constants.TRENDING};
    const initialCategoryTabs = {
      ...CATEGORY_TABS,
      gif: this.setGifGroupsPagination([trendingGifData]),
    };
    this.allCategoryTabs = gifCategoryTabs(initialCategoryTabs);

    // Fetch Tenor API category groups
    const categoriesFetchPromise =
        prevFetchPromise.then(() => this.apiProxy.getCategories());

    const categoriesRenderPromise =
        Promise.all([prevRenderPromise, categoriesFetchPromise])
            .then((values) => {
              const {gifCategories} = values[1];
              const categoryTabs = {
                ...CATEGORY_TABS,
                gif: this.setGifGroupsPagination(
                    [trendingGifData, ...gifCategories]),
              };

              gifCategories.map(
                  category => this.nextGifPos[category.name] = '');
              this.allCategoryTabs = gifCategoryTabs(categoryTabs);

              // If user is on GIF category, update emojiGroupTabs to
              // re-render emoji picker and display newly fetched GIF tabs
              if (this.category === CategoryEnum.GIF) {
                const gifTabs = this.allCategoryTabs.filter(
                    tab => tab.category === CategoryEnum.GIF);
                this.set('emojiGroupTabs', gifTabs);
                this.updateActiveGroup();
              }
            });
    const featuredGifFetchPromise =
        categoriesFetchPromise.then(() => this.apiProxy.getFeaturedGifs());

    return Promise.all([categoriesRenderPromise, featuredGifFetchPromise])
        .then((values) => {
          const {status, featuredGifs} = values[1];
          this.status = status;
          const trendingGifsElement: EmojiVariants[] =
              this.apiProxy.convertTenorGifsToEmoji(featuredGifs);

          const trendingGifs = [{
            group: constants.TRENDING,
            category: CategoryEnum.GIF,
            emoji: trendingGifsElement,
          }];
          this.nextGifPos[constants.TRENDING] = featuredGifs.next;

          this.updateCategoryData(trendingGifs, CategoryEnum.GIF);
          this.activeInfiniteGroupId =
              this.allCategoryTabs.find(tab => tab.name === constants.TRENDING)
                  ?.groupId as string;
        });
  }

  onClickTryAgain() {
    // The network error illustration only displays in GIF panel under offline
    // mode; in this case, after reloading data, we should switch back to GIF
    // panel (if it is successful).
    this.fetchAndProcessGifData().then(() => {
      this.onCategoryButtonClick(CategoryEnum.GIF);
    });
  }

  private canScrollToGroup(category: CategoryEnum, groupId: string): boolean {
    if (!this.isInfiniteCategory(category)) {
      return true;
    }

    // There will always be a trending GIF group (initialised at beginning),
    // hence it is safe to cast as SubcategoryData
    const trendingGifGroup =
        this.allCategoryTabs.find(
            tab =>
                (tab.category === CategoryEnum.GIF &&
                 tab.name === constants.TRENDING)) as SubcategoryData;
    const historyGifGroup = this.categoriesGroupElements.find(
        group => group.category === CategoryEnum.GIF && group.isHistory);
    return [trendingGifGroup.groupId, historyGifGroup?.groupId].includes(
        groupId);
  }

  private isInfiniteCategory(category: CategoryEnum) {
    return category === CategoryEnum.GIF;
  }

  private setActiveFeatures(featureList: Feature[]) {
    this.searchExtensionEnabled =
        featureList.includes(Feature.EMOJI_PICKER_SEARCH_EXTENSION);
    this.gifSupport = featureList.includes(Feature.EMOJI_PICKER_GIF_SUPPORT);
    this.useMojoSearch = featureList.includes(Feature.EMOJI_PICKER_MOJO_SEARCH);
    this.sealSupport = featureList.includes(Feature.EMOJI_PICKER_SEAL_SUPPORT);
    this.variantGroupingSupport =
        featureList.includes(Feature.EMOJI_PICKER_VARIANT_GROUPING_SUPPORT);

    this.updateEmojiPreferencesStore();
  }

  private fetchOrderingData(url: string): Promise<EmojiGroupData> {
    return new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.onloadend = () => resolve(JSON.parse(xhr.responseText));
      xhr.open('GET', url);
      xhr.send();
    });
  }

  /**
   * Processes a new category data and updates any needed variables and UIs
   * accordingly.
   *
   * @param data The category data to be processes.
   *    Note: category field will be added to the each EmojiGroup in data.
   * @param category Category of the data.
   * @param categoryLastPartition True if no future data updates are expected
   *     for the given category.
   * @param lastPartition True if no future data updates are expected.
   */
  private updateCategoryData(
      data: EmojiGroupData, category: CategoryEnum,
      categoryLastPartition = false, lastPartition = false) {
    // TODO(b/233270589): Add category to the underlying data.
    // Add category field to the data.
    data.forEach((emojiGroup) => {
      emojiGroup.category = category;
    });

    // Create recently used emoji group for the category as its first
    // group element.
    const startIndexes = this.gifSupport ?
        TABS_CATEGORY_START_INDEX_GIF_SUPPORT :
        TABS_CATEGORY_START_INDEX;
    const startIndex = startIndexes.get(category);
    if (startIndex === this.categoriesGroupElements.length) {
      const historyGroupElement = this.createEmojiGroupElement(
          this.getHistoryEmojis(category), {}, true, startIndex);
      this.push('categoriesGroupElements', historyGroupElement);
    }

    // Convert the emoji group data to elements.
    const baseIndex = this.categoriesGroupElements.length;
    const categoriesGroupElements: EmojiGroupElement[] = [];

    data.filter(item => !item.searchOnly).forEach((emojiGroup, index) => {
      if (emojiGroup.category === CategoryEnum.GIF &&
          emojiGroup.emoji.length === 0) {
        // EmojiGroup.emoji will be empty if and only if it is a gif category
        // and there's an error when trying to fetch gifs.
        return;
      }

      if (this.variantGroupingSupport && category === CategoryEnum.EMOJI) {
        emojiGroup.emoji.forEach((emoji) => {
          this.fillEmojiVariantAttributes(emoji);
        });

        this.categoryHistoryUpdated(CategoryEnum.EMOJI);
      }

      const tabIndex = baseIndex + index;
      const tabCategory = this.allCategoryTabs[tabIndex]?.category;

      categoriesGroupElements.push(
          this.createEmojiGroupElement(
              emojiGroup.emoji, this.getEmojiGroupPreference(category), false,
              tabIndex),
      );

      // TODO(b/233271528): Remove assert after removing metadata.
      // Ensure category of emoji groups match tab entries.
      console.assert(
          tabCategory === category,
          `Tab category at index ${tabIndex} is ${tabCategory} ` +
              `but corresponding group category in data is ${category}.`);
    });

    // Update emoji data for other features such as search.
    if (category !== CategoryEnum.GIF) {
      this.push('categoriesData', ...data);
    }
    // Update group elements for the emoji picker.
    this.push('categoriesGroupElements', ...categoriesGroupElements);

    if (categoryLastPartition) {
      this.dispatchEvent(events.createCustomEvent(
          events.CATEGORY_DATA_LOADED, {'category': category}));
    }

    if (lastPartition) {
      // If all data is fetched, trigger search index.
      this.searchLazyIndexing = false;

      // TODO(b/233271528): Remove the following after removing metadata.
      const numEmojiGroups = this.categoriesGroupElements.length;
      const dataMatchSubcategoryTabs =
          numEmojiGroups === this.allCategoryTabs.length;

      // Ensure hard-coded tabs match the loaded data.
      if (!this.gifSupport) {
        console.assert(
            dataMatchSubcategoryTabs,
            `The Number of tabs "${
                this.allCategoryTabs.length}" does not match ` +
                ` the number of loaded groups "${numEmojiGroups}".`,
        );
      }

      afterNextRender(
          this,
          async () => {
            switch ((await this.apiProxy.getInitialCategory()).category) {
              // by default, do nothing.
              default:
              case Category.kEmojis:
                break;
              case Category.kSymbols:
                await this.onCategoryButtonClick(CategoryEnum.SYMBOL);
                break;
              case Category.kEmoticons:
                await this.onCategoryButtonClick(CategoryEnum.EMOTICON);
                break;
              case Category.kGifs:
                await this.onCategoryButtonClick(CategoryEnum.GIF);
                break;
            }

            const initialQuery = (await this.apiProxy.getInitialQuery()).query;
            if (initialQuery !== '') {
              this.$['search-container'].setSearchQuery(initialQuery);
            }

            this.apiProxy.onUiFullyLoaded();
            this.dispatchEvent(
                events.createCustomEvent(events.EMOJI_PICKER_READY, {}));
          },
      );
    }
  }

  /**
   * Fills any gaps in the tone and gender information for variants of the
   * emoji; the build script omits this information in some cases to reduce
   * build size. Variant and grouping information is also copied to the
   * corresponding history emoji, if it exists, because existing store data
   * may not have the information.
   */
  private fillEmojiVariantAttributes(emoji: EmojiVariants) {
    const {base, alternates, groupedTone, groupedGender} = emoji;

    if (!base.name || !alternates || !(groupedTone || groupedGender)) {
      return;
    }

    alternates.forEach((variant) => {
      if (groupedTone) {
        variant.tone ??= Tone.DEFAULT;
      }

      if (groupedGender) {
        variant.gender ??= Gender.DEFAULT;
      }
    });

    this.categoriesHistory[CategoryEnum.EMOJI]?.fillEmojiVariantAttributes(
        base.name, alternates, groupedTone, groupedGender);
  }

  private onSearchChanged(newValue: string) {
    this.$['list-container'].style.display = newValue ? 'none' : '';
  }

  private onBarTransitionStart() {
    this.highlightBarMoving = true;
  }

  private onBarTransitionEnd() {
    this.highlightBarMoving = false;
  }

  private onEmojiTextButtonClick(ev: events.EmojiTextButtonClickEvent) {
    const category = ev.detail.category;
    this.insertText(category, ev.detail);
  }

  private onEmojiImgButtonClick(ev: events.EmojiImgButtonClickEvent) {
    const category = ev.detail.category;
    this.insertVisualContent(category, ev.detail);
  }

  private async insertText(category: CategoryEnum, item: events.TextItem) {
    const {text, isVariant} = item;
    this.$.message.textContent = text + ' inserted.';

    this.insertHistoryTextItem(category, item);

    const searchLength = this.$['search-container']
                             .shadowRoot!.querySelector('cr-search-field')
                             ?.getSearchInput()
                             ?.value?.length ??
        0;

    // TODO(b/217276960): change to a more generic name
    this.apiProxy.insertEmoji(text, isVariant, searchLength);
  }

  private insertVisualContent(category: CategoryEnum, item: events.VisualItem) {
    this.apiProxy.insertGif(item.visualContent.url.full);
    this.insertHistoryVisualContentItem(category, item);
  }

  isGifInErrorState(status: Status): boolean {
    return this.gifSupport && status !== Status.kHttpOk;
  }

  private clearRecentEmoji(event: events.EmojiClearRecentClickEvent) {
    const category = event.detail.category;
    const item = event.detail.item;
    this.clearHistoryData(category, item);
    afterNextRender(this, () => {
      this.updateActiveGroup();
      this.updateHistoryTabDisabledProperty();
    });
  }

  private async setGifGroupElements(activeGroupId: string) {
    // Check if GIFs have already been previously fetched and cached
    let gifGroupElements = this.categoriesGroupElements.find(
        group => group.groupId === activeGroupId);
    const isCached = !!gifGroupElements;

    // Call API only if the GIF elements for this search query has not been
    // cached
    if (!gifGroupElements) {
      // searchQuery will never be undefined
      const searchQuery =
          this.allCategoryTabs
              .find(element => element.groupId === activeGroupId)
              ?.name as string;
      const {searchGifs} = await this.apiProxy.searchGifs(searchQuery);
      const gifElements = this.apiProxy.convertTenorGifsToEmoji(searchGifs);
      this.nextGifPos[searchQuery] = searchGifs.next;

      const activeCategoryIndex =
          this.allCategoryTabs.findIndex(tab => tab.groupId === activeGroupId);

      gifGroupElements = this.createEmojiGroupElement(
          gifElements, {}, false, activeCategoryIndex);
    }

    this.setActiveInfiniteGroup(activeGroupId);
    if (!isCached) {
      this.categoriesGroupElements =
          [...this.categoriesGroupElements, gifGroupElements];
    }
  }

  private setActiveInfiniteGroup(activeGroupId: string) {
    this.updateActiveGroup(activeGroupId);
    this.activeInfiniteGroupId = activeGroupId;
  }

  private async selectGroup(newGroup: string) {
    await this.ensureFetchAndProcessDataFinished();

    if (this.category === CategoryEnum.GIF) {
      await this.setGifGroupElements(newGroup);
    }

    // focus and scroll to selected group's first emoji.
    const group =
        this.shadowRoot?.querySelector(`div[data-group="${newGroup}"]`);

    if (group) {
      const target = (group.querySelector('.group')?.shadowRoot?.querySelector(
                         '#fake-focus-target')) as HTMLElement |
          null;
      target?.focus();
      group.scrollIntoView();
    }
  }

  private onEmojiScroll() {
    // The scroll event is fired very frequently while scrolling.
    // Thus we wrap it with `requestAnimationFrame`
    requestAnimationFrame(() => {
      this.updateActiveCategory();
      this.updateActiveGroup();
      // Using ! here as this.status will always exist when GIF support is on.
      if (this.category === CategoryEnum.GIF &&
          !this.isGifInErrorState(this.status!)) {
        this.checkScrollPosition();
      }
    });
  }

  private onRightChevronClick() {
    if (!this.textSubcategoryBarEnabled) {
      // ! safe due to &&
      this.$.tabs.scrollLeft = constants.EMOJI_PICKER_TOTAL_EMOJI_WIDTH *
          constants.EMOJI_NUM_TABS_IN_FIRST_PAGE;
      this.scrollToGroup(
          EMOJI_GROUP_TABS[constants.GROUP_PER_ROW - 1]?.groupId);
      this.$.bar.style.left = constants.EMOJI_PICKER_TOTAL_EMOJI_WIDTH_PX;
    } else {
      const maxPagination =
          this.getPaginationArray(this.emojiGroupTabs).pop() ?? 0;
      this.pagination = Math.min(this.pagination + 1, maxPagination);
      this.updateCurrentGroupTabs();
    }
  }

  private onRightChevronKeyDown(event: KeyboardEvent) {
    // Moves focus to the first button under the group of current category if
    // user tries to move to the next element from right chevron button in a11y
    // mode.
    if (event.code === 'Tab' && !event.shiftKey) {
      const currentGroups = this.shadowRoot!
        .querySelectorAll<EmojiGroupComponent>(
          `emoji-group[category='${this.category}'`);

      // The first group might be a history group. If the user has no history
      // item, we should continue to check the second group.
      for (const group of currentGroups) {
        const button = group.firstEmojiButton();
        if (button) {
          button.focus();
          event.preventDefault();
          return;
        }
      }
    }

    // Announcement for a11y.
    getAnnouncerInstance().announce('New sections available');
  }

  private onLeftChevronClick() {
    this.pagination = Math.max(this.pagination - 1, 1);
    this.updateCurrentGroupTabs();

    // Announcement for a11y.
    getAnnouncerInstance().announce('New sections available');
  }

  private updateCurrentGroupTabs() {
    const nextTab =
        this.emojiGroupTabs.find((tab) => tab.pagination === this.pagination);
    if (this.category === CategoryEnum.GIF && nextTab) {
      this.setGifGroupElements(nextTab.groupId);
    }
    this.scrollToGroup(nextTab?.groupId);
  }

  scrollToGroup(newGroup?: string) {
    // TODO(crbug/1152237): This should use behaviour:'smooth', but when you do
    // that it doesn't scroll.
    if (newGroup) {
      this.shadowRoot!.querySelector(`div[data-group="${newGroup}"]`)
          ?.scrollIntoView();
    }
  }

  private onGroupsScroll() {
    this.updateChevrons();

    // This stops the GIF group tabs bar from bouncing back
    // when clicking on left/right chevron.
    if (this.category === CategoryEnum.GIF) {
      return;
    }

    requestAnimationFrame(() => this.updateActiveGroup());
  }

  private updateChevrons() {
    const leftChevron = this.$['left-chevron'];
    const rightChevron = this.$['right-chevron'];
    // bail early if required elements don't exist
    if (!leftChevron || !rightChevron) {
      return;
    }
    if (!this.textSubcategoryBarEnabled) {
      leftChevron.style.display = 'none';
      rightChevron.style.display = 'none';
    } else {
      leftChevron.style.display = this.pagination >= 2 ? 'flex' : 'none';
      rightChevron.style.display = this.pagination <
              (this.getPaginationArray(this.emojiGroupTabs).pop() ?? 0) ?
          'flex' :
          'none';
    }
  }

  /**
   * @returns the id of the emoji or emoticon group currently in view.
   * This does not apply to GIFs, since each infinite set of GIF elements for
   * the GIF categories are displayed on a separate page, and cannot be
   * accessible from other categories via scrolling.
   */
  private getActiveGroupIdFromScrollPosition(): string {
    // get bounding rect of scrollable emoji region.
    const thisRect = this.$.groups.getBoundingClientRect();

    return this.getActiveGroupAndId(thisRect).id;
  }

  getActiveGroupAndId(thisRect: DOMRect):
      {group: HTMLElement|undefined, id: string} {
    const groupElements = Array.from(
        this.$['groups']?.querySelectorAll<HTMLElement>('[data-group]') ?? []);

    // activate the first group which is visible for at least 20 pixels,
    // i.e. whose bottom edge is at least 20px below the top edge of the
    // scrollable region.
    const activeGroup = groupElements.find(
        el => el.getBoundingClientRect().bottom - thisRect.top >= 20);

    let activeGroupId;
    if (activeGroup === undefined) {
      if (this.status && this.isGifInErrorState(this.status)) {
        // If there's an error Trending gifs will be empty, so activeGroup
        // cannot be found from scroll position, have to set it manually.
        activeGroupId = constants.TRENDING_GROUP_ID;
      } else {
        activeGroupId = 'emoji-history';
      }
    } else {
      activeGroupId = activeGroup.dataset['group'] ?? '';
    }
    return {group: activeGroup, id: activeGroupId};
  }

  private async checkScrollPosition(): Promise<void> {
    if (this.activeInfiniteGroupId === null) {
      return;
    }

    // get bounding rect of scrollable emoji region.
    const thisRect = this.$.groups.getBoundingClientRect();

    const activeGroupInfo = this.getActiveGroupAndId(thisRect);
    if (!activeGroupInfo.group) {
      return;
    }

    // Don't append new GIFs if the initial set is still rendering.
    if (activeGroupInfo.group.getBoundingClientRect().height <=
        thisRect.height) {
      return;
    }

    // If there's less than 300px until the end of the GIF category, load more
    // GIFs.
    if (activeGroupInfo.group.getBoundingClientRect().bottom -
            thisRect.bottom <=
        300) {
      // Using ! here as you can only scroll on a GIF category after the first
      // set of fetched GIFs have been pushed to this.categoriesGroupElements,
      // i.e. There will always be an element group that matches with the
      // current activeInfiniteGroupId.
      const searchQuery =
          this.categoriesGroupElements
              .find(
                  group => group.groupId === this.activeInfiniteGroupId)!.name;
      // No need to append to history group.
      if (searchQuery === constants.RECENTLY_USED) {
        return;
      }

      let gifElements;
      if (searchQuery === constants.TRENDING) {
        const {featuredGifs} =
            await this.apiProxy.getFeaturedGifs(this.nextGifPos[searchQuery]);
        gifElements = featuredGifs;
      } else {
        const {searchGifs} = await this.apiProxy.searchGifs(
            searchQuery, this.nextGifPos[searchQuery]);
        gifElements = searchGifs;
      }

      this.nextGifPos[searchQuery] = gifElements.next;
      const gifs = this.apiProxy.convertTenorGifsToEmoji(gifElements);
      this.appendGifElements(searchQuery, gifs);
    }
  }

  private appendGifElements(subcategory: string, gifs: EmojiVariants[]) {
    const categoryIndex =
        this.categoriesGroupElements.findIndex(tab => tab.name === subcategory);
    if (categoryIndex === -1) {
      return;
    }
    this.push(['categoriesGroupElements', categoryIndex, 'emoji'], ...gifs);
  }

  /*
   * Active group is updated with scroll position for emoji types with finite
   * elements (emojis, emoticons, symbols). However, active groups for emoji
   * types with infinite will be passed through a groupId because they cannot be
   * determined with scroll position.
   */
  private updateActiveGroup(groupId?: string) {
    let activeGroupId = groupId;
    if (groupId == null) {
      activeGroupId = this.getActiveGroupIdFromScrollPosition();
    }

    if (this.category === CategoryEnum.GIF) {
      this.set('activeInfiniteGroupId', activeGroupId);
    }

    this.set(
        'pagination', this.getPaginationFromGroupId(activeGroupId as string));
    this.updateChevrons();
    const bar = this.$.bar;

    let index = 0;
    // set active to true for selected group and false for others.
    this.emojiGroupTabs.forEach((g, i) => {
      const isActive = g.groupId === activeGroupId;
      if (isActive) {
        index = i;
      }
      this.set(['emojiGroupTabs', i, 'active'], isActive);
    });

    // Ensure that the history tab is not set as active if it is empty.
    if (index === 0 && this.isCategoryHistoryEmpty(this.category)) {
      this.set(['emojiGroupTabs', 0, 'active'], false);
      this.set(['emojiGroupTabs', 1, 'active'], true);
      index = 1;
    }

    // Once tab scroll is updated, update the position of the highlight bar.
    if (!this.highlightBarMoving) {
      // Update the scroll position of the emoji groups so that active group is
      // visible.
      if (!this.textSubcategoryBarEnabled) {

        // The value here means the width of an emoji tab button + extra right
        // side spacing. It's different between the versions before GIF support
        // and after.
        const totalWidth = this.gifSupport ?
          constants.V2_5_EMOJI_PICKER_TOTAL_EMOJI_WIDTH :
          constants.EMOJI_PICKER_TOTAL_EMOJI_WIDTH;

        bar.style.width = constants.EMOJI_HIGHLIGHTER_WIDTH_PX;
        bar.style.left = `${index * totalWidth}px`;
      } else {
        // Cast below should be safe as worst case array is empty.
        const subcategoryTabs =
            Array.from(this.$.tabs.querySelectorAll<HTMLElement>('.tab'));

        // for text group button, the highlight bar only spans its inner width,
        // which excludes both padding and margin.
        if (index < subcategoryTabs.length) {
          const padding = this.gifSupport ?
              constants.V2_5_EMOJI_PICKER_SIDE_PADDING :
              constants.EMOJI_PICKER_SIDE_PADDING;
          const barInlineGap =
              constants.TAB_BUTTON_MARGIN + constants.TEXT_GROUP_BUTTON_PADDING;
          const currentTab = subcategoryTabs[index];
          bar.style.left = `${
              (currentTab?.offsetLeft ?? 0) - padding -
              this.calculateTabScrollLeftPosition(this.pagination)}px`;
          bar.style.width = `${
              (subcategoryTabs[index]?.clientWidth ?? 0) - barInlineGap * 2}px`;
        } else {
          bar.style.left = '0px';
          bar.style.width = '0px';
        }
      }
    }
  }

  /**
   * Update active category by using vertical scroll position.
   */
  private updateActiveCategory() {
    const activeGroupId = this.getActiveGroupIdFromScrollPosition();

    const currentCategory =
        this.allCategoryTabs.find((tab) => tab.groupId === activeGroupId)
            ?.category;
    this.set('category', currentCategory);
  }

  private preventV2TabScrolling() {
    this.$.tabs.scrollLeft =
        this.calculateTabScrollLeftPosition(this.pagination);
  }

  private hideDialogs() {
    this.hideEmojiVariants();

    for (const category of Object.values(CategoryEnum)) {
      if (!this.isCategoryHistoryEmpty(category)) {
        const historyButton =
            this.shadowRoot!.querySelector<EmojiGroupComponent>(
                `emoji-group.history[category=${category}]`);
        if (historyButton) {
          historyButton.showClearRecents = false;
        }
      }
    }
  }

  private hideEmojiVariants() {
    if (this.activeVariant) {
      this.activeVariant.hideEmojiVariants();
      this.activeVariant = null;
    }
  }

  /**
   * Disables the history tab when there is no usage history for the
   * selected category and enables it otherwise.
   */
  private updateHistoryTabDisabledProperty() {
    this.set(
        ['emojiGroupTabs', 0, 'disabled'],
        this.isCategoryHistoryEmpty(this.category),
    );
  }

  /**
   * Gets recently used emojis for a category. It gets the history items
   * and convert them to emojis.
   */
  private getHistoryEmojis(category: CategoryEnum): EmojiVariants[] {
    if (this.incognito) {
      return [];
    }

    return this.categoriesHistory[category]?.getHistory().map(
               emoji => ({
                 base: {
                   string: emoji.base.string,
                   name: emoji.base.name,
                   visualContent: emoji.base.visualContent,
                   keywords: [],
                   tone: emoji.base.tone,
                   gender: emoji.base.gender,
                 },
                 alternates: emoji.alternates?.map(
                                 (alternate: Emoji):
                                     Emoji => {
                                       return {
                                         string: alternate.string,
                                         name: alternate.name,
                                         keywords:
                                             [...(alternate.keywords ?? [])],
                                         tone: alternate.tone,
                                         gender: alternate.gender,
                                       };
                                     }) ??
                     [],
                 groupedTone: emoji.groupedTone,
                 groupedGender: emoji.groupedGender,
               })) ??
        [];
  }

  /**
   * Handles the event where history or preferences are modified for a
   * category.
   *
   */
  private categoryHistoryUpdated(
      category: CategoryEnum, historyUpdated = true,
      _preferenceUpdated = true) {
    // History item is assumed to be the first item of each category.
    const historyIndexes = this.gifSupport ?
        TABS_CATEGORY_START_INDEX_GIF_SUPPORT :
        TABS_CATEGORY_START_INDEX;
    const historyIndex = historyIndexes.get(category);

    // If history group is already added, then update it.
    if (historyUpdated && (historyIndex !== undefined) &&
        historyIndex < this.categoriesGroupElements.length) {
      this.set(
          ['categoriesGroupElements', historyIndex, 'emoji'],
          this.getHistoryEmojis(category));
    }

    // Note: preference update is not handled because it is an expensive
    // operation and adds no value to the current version.
    // If needed in the future, its addition requires iterating over all
    // categoriesGroupElement of the category and setting their preferences
    // here.
  }

  /**
   * Updates incognito state and variables that needs to be updated by the
   * change of incognito state.
   *
   */
  async updateIncognitoState(incognito: boolean) {
    this.incognito = incognito;
    this.updateEmojiPreferencesStore();

    // Load the history item for each category.
    // Initialise all objects before async for extra safety.
    for (const category of Object.values(CategoryEnum)) {
      this.categoriesHistory[category] =
          incognito ? null : new RecentlyUsedStore(category);
    }
    for (const category of Object.values(CategoryEnum)) {
      await this.categoriesHistory[category]?.mergeWithPrefsHistory();
      this.categoryHistoryUpdated(category);
    }
  }

  /**
   * Updates the emoji preferences store, global tone, and global gender.
   */
  updateEmojiPreferencesStore() {
    this.emojiPreferences = this.incognito || !this.variantGroupingSupport ?
        null :
        new EmojiPreferencesStore();
    this.globalTone = this.emojiPreferences?.getTone() ?? null;
    this.globalGender = this.emojiPreferences?.getGender() ?? null;
  }

  /**
   * Inserts a new item to the history of a category. It will do nothing during
   * incognito state.
   *
   */
  private insertHistoryTextItem(category: CategoryEnum, item: events.TextItem) {
    if (this.incognito) {
      return;
    }

    const {
      text,
      baseEmoji,
      alternates,
      name,
      tone,
      gender,
      groupedTone,
      groupedGender,
    } = item;

    this.categoriesHistory[category]?.bumpItem(category, {
      base: {string: text, name, tone, gender},
      alternates,
      groupedTone,
      groupedGender,
    });

    let preferenceUpdated = false;

    if (!this.variantGroupingSupport || !(groupedTone || groupedGender)) {
      preferenceUpdated =
          !!this.categoriesHistory[category]?.savePreferredVariant(
              text, baseEmoji);
    }

    this.categoryHistoryUpdated(category, true, preferenceUpdated);

    if (!this.variantGroupingSupport) {
      return;
    }

    if (tone !== undefined) {
      this.emojiPreferences?.setTone(tone);
    }

    if (gender !== undefined) {
      this.emojiPreferences?.setGender(gender);
    }
  }

  /**
   * Inserts a new item to the history of a visual content category. It will do
   * nothing during incognito state.
   */
  private insertHistoryVisualContentItem(
      category: CategoryEnum, item: events.VisualItem) {
    if (this.incognito) {
      return;
    }

    const {name, visualContent} = item;

    this.categoriesHistory[category]?.bumpItem(
        category,
        {base: {visualContent: visualContent, name: name}, alternates: []});

    this.categoryHistoryUpdated(category, true, undefined);
  }

  /**
   * Clears history item(s) for a category.
   */
  private clearHistoryData(category: CategoryEnum, item?: EmojiVariants) {
    if (this.incognito) {
      return;
    }

    if (item === undefined) {
      this.categoriesHistory[category]?.clearRecents();
    } else {
      this.categoriesHistory[category]?.clearItem(category, item);
    }

    this.categoryHistoryUpdated(category, true, false);
  }

  /**
   * Check if the history items of a category is empty.
   *
   * @returns {boolean} True for empty history.
   */
  private isCategoryHistoryEmpty(category: CategoryEnum) {
    return this.incognito || this.categoriesHistory[category]?.isHistoryEmpty();
  }

  /**
   * @returns True if the emoji should use the global variant preference, or
   * false if it should revert to the individual preference.
   */
  private shouldUseGroupedPreference(isHistory: boolean): boolean {
    return this.variantGroupingSupport && !isHistory;
  }

  /**
   * Gets HTML classes for an emoji group element.
   *
   * The emojis need to be passed in directly so Polymer registers changes
   * e.g. when clearing history emojis.
   * The currEmojiGroup is passed in for additional attribute info.
   *
   * @returns {string} HTML element class attribute.
   */
  private getEmojiGroupClassNames(
      emojis: EmojiVariants[], currEmojiGroup: EmojiGroupElement,
      activeCategory: CategoryEnum, activeInfiniteGroupId: string) {
    const baseClassNames = currEmojiGroup.isHistory ? 'group history' : 'group';

    // Make emoji hidden if it is empty.
    // Note: Filtering empty groups in dom-repeat is expensive due to
    // re-rendering, so making it hidden is more efficient.
    if (!emojis || emojis.length === 0) {
      return baseClassNames + ' hidden';
    }

    if (!this.gifSupport) {
      return baseClassNames;
    }

    // If the active group can be reached via scrolling (i.e. emojis, emoticons,
    // symbols, recently used GIFs, trending GIFs), whereas the current group
    // being rendered cannot, the current group should be hidden, and vice
    // versa. i.e. When on an emoji group, you can scroll down to other emoticon
    // or symbol groups, or recently used or trending GIFs, but cannot scroll
    // past trending GIFs to view #tag GIF groups.
    const canScrollToActiveGroup =
        this.canScrollToGroup(activeCategory, activeInfiniteGroupId);
    const canScrollToCurrGroup =
        this.canScrollToGroup(currEmojiGroup.category, currEmojiGroup.groupId);
    const canScrollToCurrGroupFromActive =
        (canScrollToCurrGroup && canScrollToActiveGroup);

    // For GIF category groups (not including history or recently used), only
    // the currently active group should be displayed and all other groups
    // should be hidden.
    const currGroupIsActiveInfiniteGroup =
        currEmojiGroup.groupId === activeInfiniteGroupId;

    if (!canScrollToCurrGroupFromActive && !currGroupIsActiveInfiniteGroup) {
      return baseClassNames + ' hidden';
    }
    return baseClassNames;
  }

  /**
   * Create an instance of emoji group element.
   */
  private createEmojiGroupElement(
      emoji: EmojiVariants[], preferences: PreferenceMapping,
      isHistory: boolean, subcategoryIndex: number): EmojiGroupElement {
    const baseDetails = {
      'emoji': emoji,
      'preferences': preferences,
      'isHistory': isHistory,
    };

    return (
        Object.assign({}, baseDetails, this.allCategoryTabs[subcategoryIndex]));
  }

  /**
   * Gets preferences for an emoji group.
   *
   */
  private getEmojiGroupPreference(category: CategoryEnum): PreferenceMapping {
    return this.categoriesHistory[category]?.getPreferenceMapping() ?? {};
  }

  private onShowEmojiVariants(ev: events.EmojiVariantsShownEvent) {
    // Hide the currently shown emoji variants if the new one belongs
    // to a different emoji group.
    if (this.activeVariant && ev.detail.owner !== this.activeVariant) {
      this.hideEmojiVariants();
    }

    this.activeVariant = ev.detail.owner as EmojiGroupComponent;

    // Updates the UI if a variant is shown.
    if (ev.detail.variants) {
      this.$.message.textContent = ev.detail.baseEmoji + ' variants shown.';
      this.positionEmojiVariants(ev.detail.variants);
    }
  }

  private positionEmojiVariants(variants: HTMLElement) {
    // TODO(crbug.com/1174311): currently positions horizontally within page.
    // ideal UI would be overflowing the bounds of the page.
    // also need to account for vertical positioning.

    // compute width required for the variant popup as: SIZE * columns + 10.
    // SIZE is emoji width in pixels. number of columns is determined by width
    // of variantRows, then one column each for the base emoji and skin tone
    // indicators if present. 10 pixels are added for padding and the shadow.

    // Reset any existing left margin before calculating a new position.
    variants.style.marginLeft = '0';

    // get size of emoji picker
    const pickerRect = this.getBoundingClientRect();

    // determine how much overflows the right edge of the window.
    const rect = variants.getBoundingClientRect();
    const overflowWidth = rect.x + rect.width - pickerRect.width;
    // shift left by overflowWidth rounded up to next multiple of EMOJI_SIZE.
    const shift = constants.EMOJI_ICON_SIZE *
        Math.ceil(overflowWidth / constants.EMOJI_ICON_SIZE);
    // negative value means we are already within bounds, so no shift needed.
    variants.style.marginLeft = `-${Math.max(shift, 0)}px`;
    // Now, examine vertical scrolling and scroll if needed. Not quire sure why
    // we need listcontainer.offsetTop, but it makes things work.
    const groups = this.$.groups;
    const scrollTop = groups.scrollTop;
    const variantTop = variants.offsetTop;
    const variantBottom = variantTop + variants.offsetHeight;
    const listTop = this.$['list-container'].offsetTop;
    if (variantBottom > scrollTop + (groups.offsetHeight) + listTop) {
      groups.scrollTo({
        top: variantBottom - (groups.offsetHeight) - listTop,
        left: 0,
        behavior: 'smooth',
      });
    }
  }

  /**
   * Triggers when category property changes
   */
  private onCategoryChanged(newCategoryName: string) {
    const categoryTabs =
        this.allCategoryTabs.filter(tab => tab.category === newCategoryName);
    this.set('emojiGroupTabs', categoryTabs);
    this.updateActiveGroup();
    this.updateHistoryTabDisabledProperty();
    this.$.tabs.scrollLeft =
        this.calculateTabScrollLeftPosition(this.pagination);
  }

  private async onCategoryButtonClick(newCategory: CategoryEnum) {
    await this.ensureFetchAndProcessDataFinished();

    this.set('category', newCategory);
    this.set('pagination', 1);

    if (newCategory === CategoryEnum.GIF) {
      this.set('activeInfiniteGroupId', this.emojiGroupTabs[0]?.groupId);
    }

    if (this.$['search-container'].searchNotEmpty()) {
      this.$['search-container'].setSearchQuery('');
      afterNextRender(this, () => {
        this.scrollToGroup(this.emojiGroupTabs[0]?.groupId);
      });
    } else {
      this.scrollToGroup(this.emojiGroupTabs[0]?.groupId);
    }
  }

  /**
   * Trigger when pagination changes
   */
  private onPaginationChanged(newPage: number) {
    // Left chevron has the same margin as the text subcategory button.
    this.$.tabs.scrollLeft = this.calculateTabScrollLeftPosition(newPage);
  }

  /**
   * Returns true if the subcategory bar requires text group buttons.
   */
  private isTextSubcategoryBarEnabled(category: string) {
    // Categories that require its subcategory bar to be labelled by text.
    const textCategories = ['symbol', 'emoticon', 'gif'];
    return textCategories.includes(category);
  }

  /**
   * Returns the array of page numbers which starts at 1 and finishes at the
   * last pagination.
   */
  private getPaginationArray(tabs: SubcategoryData[]) {
    // cast safe here since it is the result of filter.
    const paginations =
        tabs.map(tab => tab.pagination).filter(num => num) as number[];
    const lastPagination = Math.max(...paginations);
    return Array.from(Array(lastPagination), (_, idx) => idx + 1);
  }

  /**
   * Dynamically calculate the pagination for the GIF category groups tabs to
   * determine the number of tabs that can fit in each page. Existing
   * emoji/emoticon/symbol tabs pagination is hardcoded since the tabs remain
   * consistent, but GIF tabs change after every API call.
   */
  private setGifGroupsPagination(gifGroupTabs: GifSubcategoryData[]):
      GifSubcategoryData[] {
    const gifCategoriesGroupData = gifGroupTabs;

    // Max tabs bar width accounts for the chevron clicks and/or history button
    const maxTabsWidth = constants.EMOJI_PICKER_WIDTH -
        2 * constants.EMOJI_PICKER_SIDE_PADDING - 2 * constants.EMOJI_ICON_SIZE;

    let totalTabsWidth = 0;
    let pagination = 1;

    for (const tabData of gifCategoriesGroupData) {
      // Calculate maximum number of tabs that can fit within the tabs bar width
      // to set pagination
      const tabWidth = this.getTabElementWidth(tabData.name);
      if (totalTabsWidth + tabWidth > maxTabsWidth) {
        pagination += 1;
        totalTabsWidth = 0;
      }
      totalTabsWidth += tabWidth;
      tabData.pagination = pagination;
    }

    return gifCategoriesGroupData;
  }

  /**
   * Calculate the width of the specific tab element before it
   * is rendered by customising the dummy element with the tab name
   * and finding width.
   */
  private getTabElementWidth(tabName: string): number {
    this.dummyTab = {
      name: tabName,
      groupId: '-1',
      active: false,
      disabled: false,
      category: CategoryEnum.GIF,
    };

    return this.$.dummyTab.clientWidth;
  }

  /**
   * Returns true if the page is not the first.
   */
  private isNotFirstPage(pageNumber: number) {
    return pageNumber !== 1;
  }

  private getPaginationFromGroupId(groupId: string) {
    const tab = this.allCategoryTabs.find((tab) => tab.groupId === groupId);
    if (tab) {
      return tab.pagination;
    } else {
      throw new Error('Tab not found.');
    }
  }

  private calculateTabScrollLeftPosition(page: number) {
    const chevronMargin = constants.TAB_BUTTON_MARGIN;
    const offsetByLeftChevron = constants.EMOJI_ICON_SIZE + chevronMargin;
    return (page === 1) ?
        0 :
        (page - 1) * constants.EMOJI_PICKER_WIDTH - offsetByLeftChevron;
  }

  private getTabIndex(itemPagination: number, currentPagination: number) {
    return itemPagination === currentPagination ? 0 : -1;
  }

  // The gifSupport field ensures that this function gets called when
  // gifSupport is updated, allowing the correct categories to be shown
  private getCategoryMetadata(gifSupport: boolean, category: string) {
    // This determines whether the GIF category button will appear
    const METADATA = gifSupport ? GIF_CATEGORY_METADATA : CATEGORY_METADATA;
    return METADATA.map(data => ({
                          name: data.name,
                          icon: data.icon,
                          active: data.name === category,
                        }));
  }

  /**
   * Checks if recently used GIFs are still valid if we open the emoji picker
   * and it has been 24 hours since the last validation.
   */
  private async validateRecentlyUsedGifs() {
    // This check ensures that we don't try and validate recently used GIFs
    // if the validating process is already currently happening.
    const currentTime = new Date();

    if ((currentTime.getTime() - this.previousGifValidation.getTime()) >
        constants.TWENTY_FOUR_HOURS) {
      const updated = await this.categoriesHistory[CategoryEnum.GIF]?.validate(
          this.apiProxy);

      this.previousGifValidation = currentTime;
      window.localStorage.setItem(
          constants.GIF_VALIDATION_DATE, currentTime.toJSON());

      if (updated) {
        this.categoryHistoryUpdated(CategoryEnum.GIF);
      }
    }
  }

  private loadPreviousGifValidationTime(): Date {
    const stored = window.localStorage.getItem(constants.GIF_VALIDATION_DATE);
    if (!stored) {
      // First time opening the Emoji Picker so there should be no recently used
      // GIFs to render.
      return new Date();
    }
    return new Date(stored);
  }

  private computeListContainerClass(category: CategoryEnum, status: Status): string {
    // Only displays emoji-error if there is no internet connection and we are in GIF category.
    if (category === CategoryEnum.GIF && status !== Status.kHttpOk) {
      return 'error-only';
    }
    // Do not display GIF emoji groups if there is no internet connection and we are in non-GIF
    // category.
    if (category !== CategoryEnum.GIF && status !== Status.kHttpOk) {
      return 'no-gif';
    }
    return '';
  }

  private getLeftChevronAriaLabel(gifSupport: boolean): string | undefined {
    return gifSupport ? 'Previous' : undefined;
  }

  private getRightChevronAriaLabel(gifSupport: boolean): string | undefined {
    return gifSupport ? 'Next': undefined;
  }

  private closeGifNudgeOverlay() {
    if (this.showGifNudgeOverlay) {
      this.showGifNudgeOverlay = false;
    }

    GifNudgeHistoryStore.setNudgeShown(true);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [EmojiPickerApp.is]: EmojiPickerApp;
  }
  interface HTMLElementEventMap {
    [events.EMOJI_PICKER_READY]: CustomEvent;
    [events.CATEGORY_DATA_LOADED]: events.CategoryDataLoadEvent;
  }
}


customElements.define(EmojiPickerApp.is, EmojiPickerApp);