chromium/chrome/browser/resources/side_panel/companion/companion.ts

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

import './strings.m.js';

import {assert} from '//resources/js/assert.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import type {Url} from '//resources/mojo/url/mojom/url.mojom-webui.js';

import type {ImageQuery, LinkOpenMetadata, VisualSearchResult} from './companion.mojom-webui.js';
import {MethodType, PromoAction, PromoType} from './companion.mojom-webui.js';
import type {CompanionProxy} from './companion_proxy.js';
import {CompanionProxyImpl} from './companion_proxy.js';

/**
 * Method arguments to be passed as part of the JSON message object to be sent
 * across the postmessage boundary.
 * Keep this file in sync with
 * google3/java/com/google/lens/web/interfaces/standalone/companionweb/service/companion_parent_communication_service.ts
 */
enum ParamType {
  // Arguments for iframe -> browser communication.
  // Mandatory arguments.
  METHOD_TYPE = 'type',

  // Arguments for MethodType.kOnCqCandidatesAvailable.
  CQ_TEXT_DIRECTIVES = 'cqTextDirectives',

  // Optional arguments.
  // Arguments for MethodType.kOnExpsOptInStatusAvailable.
  IS_EXPS_OPTED_IN = 'isExpsOptedIn',

  // Arguments for MethodType.kOnPromoAction.
  PROMO_ACTION = 'promoAction',
  PROMO_TYPE = 'promoType',

  // Arguments for MethodType.kOnPhFeedback.
  PH_FEEDBACK = 'phFeedback',

  // Arguments for MethodType.kOnOpenInNewTabButtonURLChanged.
  URL_FOR_OPEN_IN_NEW_TAB = 'urlForOpenInNewTab',

  // Arguments for MethodType.kRecordUiSurfaceShown.
  UI_SURFACE = 'uiSurface',

  // Arguments for MethodType.kRecordUiSurfaceShown.
  UI_SURFACE_POSITION = 'uiSurfacePosition',
  CHILD_ELEMENT_AVAILABLE_COUNT = 'childElementAvailableCount',
  CHILD_ELEMENT_SHOWN_COUNT = 'childElementShownCount',

  // Arguments for MethodType.kRecordUiSurfaceClicked.
  CLICK_POSITION = 'clickPosition',

  // Arguments for MethodType.kOnCqJamptagClicked.
  CQ_JUMPTAG_TEXT = 'cqJumptagText',

  // Arguments for MethodType.kOpenUrlInBrowser
  URL_TO_OPEN = 'urlToOpen',
  USE_NEW_TAB = 'useNewTab',

  // Arguments for MethodType.kNotifyLinkOpen for browser -> iframe
  // communication.
  LINK_OPEN_OPENED_URL = 'openedUrl',
  LINK_OPEN_METADATA = 'openMetadata',

  // Arguments for browser -> iframe communication.
  COMPANION_UPDATE_PARAMS = 'companionUpdateParams',

  // Arguments for sending text find results from browser to iframe.
  CQ_TEXT_FIND_RESULTS = 'cqTextFindResults',

  // Arguments for sending Visual Search results from browser to iframe.
  VISUAL_SEARCH_PARAMS = 'visualSearchParams',

  // Arguments for sending Visual Search alt text from browser to iframe.
  VISUAL_SEARCH_IMAGE_ALT_TEXTS = 'visualSearchImageAltTexts',

  // Arguments for sending companion loading state from iframe to browser.
  COMPANION_LOADING_STATE = 'companionLoadingState',

  // Arguments for sending page title from browser to iframe.
  PAGE_TITLE = 'pageTitle',

  // Arguments for sending innerHtml from browser to iframe.
  INNER_HTML = 'innerHtml',
}

const companionProxy: CompanionProxy = CompanionProxyImpl.getInstance();

// Validation check for incoming enums from the iframe postMessage().
function validatePromoArguments(promoType: any, promoAction: any): boolean {
  const isValidType = Object.values(PromoType).includes(promoType);
  const isValidAction = Object.values(PromoAction).includes(promoAction);
  return isValidType && isValidAction;
}

function initialize() {
  // For the initial navigation, we update our iframe src to pass new
  // URL.
  companionProxy.callbackRouter.loadCompanionPage.addListener((newUrl: Url) => {
    const frame = document.body.querySelector('iframe');
    assert(frame);
    frame.src = newUrl.url;
  });

  // For subsequent navigations, we send a post message.
  companionProxy.callbackRouter.updateCompanionPage.addListener(
      (companionUpdateProto: string) => {
        const companionOrigin =
            new URL(loadTimeData.getString('companion_origin')).origin;
        const message = {
          [ParamType.METHOD_TYPE]: MethodType.kUpdateCompanionPage,
          [ParamType.COMPANION_UPDATE_PARAMS]: companionUpdateProto,
        };

        const frame = document.body.querySelector('iframe');
        assert(frame);
        if (frame.contentWindow) {
          frame.contentWindow.postMessage(message, companionOrigin);
        }
      });

  companionProxy.callbackRouter.updatePageContent.addListener(
      (pageTitle: string, innerHtml: string) => {
        const companionOrigin =
            new URL(loadTimeData.getString('companion_origin')).origin;
        const message = {
          [ParamType.METHOD_TYPE]: MethodType.kUpdatePageContent,
          [ParamType.PAGE_TITLE]: pageTitle,
          [ParamType.INNER_HTML]: innerHtml,
        };

        const frame = document.body.querySelector('iframe');
        assert(frame);
        if (frame.contentWindow) {
          frame.contentWindow.postMessage(message, companionOrigin);
        }
      });

  // On image queries, we need to send a POST to the iframe using a form in the
  // WebUI.
  companionProxy.callbackRouter.onImageQuery.addListener(
      (imageQuery: ImageQuery) => {
        const queryForm = document.body.querySelector('form');
        const imageDataInput =
            document.getElementById('image-data') as HTMLInputElement;
        const imageUrlInput =
            document.getElementById('image-src-url') as HTMLInputElement;
        const widthInput =
            document.getElementById('image-width') as HTMLInputElement;
        const heightInput =
            document.getElementById('image-height') as HTMLInputElement;
        const downscaledDimensionsInput =
            document.getElementById('image-downscaled-dimensions') as
            HTMLInputElement;
        assert(queryForm);
        assert(imageDataInput);
        assert(imageUrlInput);
        assert(widthInput);
        assert(heightInput);
        assert(downscaledDimensionsInput);
        queryForm.setAttribute('action', imageQuery.uploadUrl.url);
        // The original Uint8Array that gets passed does not have an array
        // buffer due to how it is initialized. Thus, we have to create a
        // Uint8Array with the same data as |imageBytes| in order to properly
        // create a blob from it.
        const imageBytesWithBuffer = new Uint8Array(imageQuery.imageBytes);
        const blob =
            new Blob([imageBytesWithBuffer], {type: imageQuery.contentType});
        const file =
            new File([blob], 'filename.jpg', {type: imageQuery.contentType});

        // Create a DataTransfer to create a file list for the images we want to
        // query.
        const container = new DataTransfer();
        container.items.add(file);

        // Assign all values on the form and submit to initiate request.
        imageDataInput.files = container.files;
        imageUrlInput.value = imageQuery.imageUrl.url;
        widthInput.value = String(imageQuery.width);
        heightInput.value = String(imageQuery.height);
        downscaledDimensionsInput.value =
            `${imageQuery.downscaledWidth},${imageQuery.downscaledHeight}`;
        queryForm.submit();
        queryForm.reset();
      });

  companionProxy.callbackRouter.onCqFindTextResultsAvailable.addListener(
      (textDirectives: string[], results: boolean[]) => {
        const companionOrigin =
            new URL(loadTimeData.getString('companion_origin')).origin;
        const message = {
          [ParamType.METHOD_TYPE]: MethodType.kOnCqFindTextResultsAvailable,
          [ParamType.CQ_TEXT_DIRECTIVES]: textDirectives,
          [ParamType.CQ_TEXT_FIND_RESULTS]: results,
        };

        const frame = document.body.querySelector('iframe');
        assert(frame);
        if (frame.contentWindow) {
          frame.contentWindow.postMessage(message, companionOrigin);
        }
      });

  // POST dataUris from the Visual Search classification results to the iframe
  companionProxy.callbackRouter.onDeviceVisualClassificationResult.addListener(
      (results: VisualSearchResult[]) => {
        const dataUris = results.map(result => result.dataUri);
        const altTexts = results.map(result => result.altText);
        const message = {
          [ParamType.METHOD_TYPE]:
              MethodType.kOnDeviceVisualClassificationResult,
          [ParamType.VISUAL_SEARCH_PARAMS]: dataUris,
          [ParamType.VISUAL_SEARCH_IMAGE_ALT_TEXTS]: altTexts,
        };

        const companionOrigin =
            new URL(loadTimeData.getString('companion_origin')).origin;
        const frame = document.body.querySelector('iframe');
        assert(frame);
        if (frame.contentWindow) {
          frame.contentWindow.postMessage(message, companionOrigin);
        }
      });

  companionProxy.callbackRouter.onNavigationError.addListener(() => {
    const networkErrorOverlay = document.getElementById('network-error-page');
    const frame = document.body.querySelector('iframe');
    assert(frame);
    assert(networkErrorOverlay);

    // Hide the frame and show the network error overlay.
    networkErrorOverlay.style.display = 'block';
    frame.style.display = 'none';
  });

  companionProxy.callbackRouter.notifyLinkOpen.addListener(
      (openedUrl: Url, metadata: LinkOpenMetadata) => {
        const companionOrigin =
            new URL(loadTimeData.getString('companion_origin')).origin;
        const message = {
          [ParamType.METHOD_TYPE]: MethodType.kNotifyLinkOpen,
          [ParamType.LINK_OPEN_OPENED_URL]: openedUrl.url,
          [ParamType.LINK_OPEN_METADATA]: metadata,
        };

        const frame = document.body.querySelector('iframe');
        assert(frame);
        if (frame.contentWindow) {
          frame.contentWindow.postMessage(message, companionOrigin);
        }
      });

  companionProxy.handler.showUI();
}

// Handler for postMessage() calls from the embedded iframe.
function onCompanionMessageEvent(event: MessageEvent) {
  // Because the |companion_origin| string has a trailing slash that can cause
  // failures when doing a string comparison, convert the string to a URL and
  // compare the origin to prevent failures when origins are the same but
  // strings differ.
  const validOrigin =
      new URL(loadTimeData.getString('companion_origin')).origin;
  if (validOrigin !== event.origin) {
    return;
  }

  const data = event.data;
  const methodType = data[ParamType.METHOD_TYPE];
  if (methodType === MethodType.kOnRegionSearchClicked) {
    companionProxy.handler.onRegionSearchClicked();
  } else if (methodType === MethodType.kOnPromoAction) {
    const promoType = data[ParamType.PROMO_TYPE];
    const promoAction = data[ParamType.PROMO_ACTION];
    if (validatePromoArguments(promoType, promoAction)) {
      companionProxy.handler.onPromoAction(promoType, promoAction);
    }
  } else if (methodType === MethodType.kOnExpsOptInStatusAvailable) {
    companionProxy.handler.onExpsOptInStatusAvailable(
        data[ParamType.IS_EXPS_OPTED_IN]);
  } else if (methodType === MethodType.kOnOpenInNewTabButtonURLChanged) {
    const openInNewTabUrl: Url = {url: data[ParamType.URL_FOR_OPEN_IN_NEW_TAB]};
    companionProxy.handler.onOpenInNewTabButtonURLChanged(openInNewTabUrl);
  } else if (methodType === MethodType.kRecordUiSurfaceShown) {
    const uiSurfacePosition = data[ParamType.UI_SURFACE_POSITION] || -1;
    const childElementAvailableCount =
        data[ParamType.CHILD_ELEMENT_AVAILABLE_COUNT] || -1;
    const childElementShownCount =
        data[ParamType.CHILD_ELEMENT_SHOWN_COUNT] || -1;
    companionProxy.handler.recordUiSurfaceShown(
        data[ParamType.UI_SURFACE], uiSurfacePosition,
        childElementAvailableCount, childElementShownCount);
  } else if (methodType === MethodType.kRecordUiSurfaceClicked) {
    const clickPosition = data[ParamType.CLICK_POSITION] || -1;
    companionProxy.handler.recordUiSurfaceClicked(
        data[ParamType.UI_SURFACE], clickPosition);
  } else if (methodType === MethodType.kOnCqCandidatesAvailable) {
    companionProxy.handler.onCqCandidatesAvailable(
        data[ParamType.CQ_TEXT_DIRECTIVES]);
  } else if (methodType === MethodType.kOnPhFeedback) {
    companionProxy.handler.onPhFeedback(data[ParamType.PH_FEEDBACK]);
  } else if (methodType === MethodType.kOnCqJumptagClicked) {
    companionProxy.handler.onCqJumptagClicked(data[ParamType.CQ_JUMPTAG_TEXT]);
  } else if (methodType === MethodType.kOpenUrlInBrowser) {
    const urlToOpen: Url = {url: data[ParamType.URL_TO_OPEN] || ''};
    companionProxy.handler.openUrlInBrowser(
        urlToOpen, data[ParamType.USE_NEW_TAB]);
  } else if (methodType === MethodType.kCompanionLoadingState) {
    companionProxy.handler.onLoadingState(
        data[ParamType.COMPANION_LOADING_STATE]);
  } else if (methodType === MethodType.kRefreshCompanionPage) {
    companionProxy.handler.refreshCompanionPage();
  } else if (methodType === MethodType.kServerSideUrlFilterEvent) {
    companionProxy.handler.onServerSideUrlFilterEvent();
  }
}

window.addEventListener('message', onCompanionMessageEvent, false);
document.addEventListener('DOMContentLoaded', initialize);