chromium/chrome/browser/resources/privacy_sandbox/privacy_sandbox_dialog_mixin.ts

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

// clang-format off
import type { PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {dedupingMixin} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';

import {PrivacySandboxDialogBrowserProxy, PrivacySandboxPromptAction} from './privacy_sandbox_dialog_browser_proxy.js';
// clang-format on

type Constructor<T> = new (...args: any[]) => T;

export const PrivacySandboxDialogMixin = dedupingMixin(
    <T extends Constructor<PolymerElement>>(superClass: T): T&
    Constructor<PrivacySandboxDialogMixinInterface> => {
      class PrivacySandboxDialogMixin extends superClass {
        wasScrolledToBottom: boolean = true;

        private didStartWithScrollbar_: boolean = false;
        private wasScrolledToBottomResolver_: PromiseResolver<void>;

        static get properties() {
          return {
            wasScrolledToBottom: {
              type: Boolean,
              observer: 'onWasScrolledToBottomChange_',
            },
          };
        }

        onConsentLearnMoreExpandedChanged(
            newValue: boolean, oldValue: boolean) {
          // Check both old and new value to avoid reporting actions when the
          // dialog just was created and oldValue is undefined.
          if (newValue && !oldValue) {
            this.onContentSizeChanging_(/*expanding=*/ true);
            this.promptActionOccurred(
                PrivacySandboxPromptAction.CONSENT_MORE_INFO_OPENED);
          }
          if (!newValue && oldValue) {
            this.onContentSizeChanging_(/*expanding=*/ false);
            this.promptActionOccurred(
                PrivacySandboxPromptAction.CONSENT_MORE_INFO_CLOSED);
          }
        }

        onNoticeLearnMoreExpandedChanged(newValue: boolean, oldValue: boolean) {
          // Check both old and new value to avoid reporting actions when the
          // dialog just was created and oldValue is undefined.
          if (newValue && !oldValue) {
            this.onContentSizeChanging_(/*expanding=*/ true);
            this.promptActionOccurred(
                PrivacySandboxPromptAction.NOTICE_MORE_INFO_OPENED);
          }
          if (!newValue && oldValue) {
            this.onContentSizeChanging_(/*expanding=*/ false);
            this.promptActionOccurred(
                PrivacySandboxPromptAction.NOTICE_MORE_INFO_CLOSED);
          }
        }

        onNoticeOpenSettings() {
          this.promptActionOccurred(
              PrivacySandboxPromptAction.NOTICE_OPEN_SETTINGS);
        }

        onNoticeAcknowledge() {
          this.promptActionOccurred(
              PrivacySandboxPromptAction.NOTICE_ACKNOWLEDGE);
        }

        onConsentMoreClicked() {
          this.onMoreClicked_();
          this.promptActionOccurred(
              PrivacySandboxPromptAction.CONSENT_MORE_BUTTON_CLICKED);
        }

        onNoticeMoreClicked() {
          this.onMoreClicked_();
          this.promptActionOccurred(
              PrivacySandboxPromptAction.NOTICE_MORE_BUTTON_CLICKED);
        }

        onRestrictedNoticeMoreClicked() {
          this.onMoreClicked_();
          this.promptActionOccurred(
              PrivacySandboxPromptAction.RESTRICTED_NOTICE_MORE_BUTTON_CLICKED);
        }

        promptActionOccurred(action: PrivacySandboxPromptAction) {
          PrivacySandboxDialogBrowserProxy.getInstance().promptActionOccurred(
              action);
        }

        private onContentSizeChanging_(expanding: boolean) {
          const scrollable: HTMLElement|null =
              this.shadowRoot!.querySelector('[scrollable]');

          if (expanding) {
            // Always allow the scrollbar to show when the learn more is
            // expanded, but remember whether it was shown when learn more was
            // collapsed.
            scrollable!.classList.toggle('hide-scrollbar', false);
            this.didStartWithScrollbar_ =
                scrollable!.offsetHeight < scrollable!.scrollHeight;
          } else {
            // On collapse, return the scrollbar to the pre-expand state
            // immediately.
            scrollable!.classList.toggle(
                'hide-scrollbar', !this.didStartWithScrollbar_);
          }
        }

        updateScrollableContents() {
          requestAnimationFrame(() => {
            const scrollable =
                this.shadowRoot!.querySelector<HTMLElement>('[scrollable]')!;
            scrollable.classList.toggle(
                'can-scroll',
                scrollable.clientHeight < scrollable.scrollHeight);
          });
        }

        // Checks if #lastTextElement is in the viewport of the [scrollable]
        // element. |wasScrolledToBottom| represents if the content was fully
        // shown at least once.
        //
        // The implementation uses IntersectionObserver which works async. The
        // callback is callback for the initial intersection ration after
        // starting the observer and then whenever a threshold was passed. The
        // method returns a promise which can be used to wait for the initial
        // layout to be ready.
        //
        // Requirements and assumptions:
        //  * #lastTextElement is the last element that has to be shown for the
        // dialog content to be considered fully shown.
        //  *  [scrollable] is the parent of #lastTextElement and it is the
        // main scrollable element.
        //  * .more-content-available is applied to [scrollable] when
        //  |wasScrolledToBottom| is false.
        //  * When computing the intersection, it takes into account the button
        //  container height (64px).
        maybeShowMoreButton(): Promise<void> {
          this.wasScrolledToBottomResolver_ = new PromiseResolver();
          return new Promise<void>(resolve => {
            // Determine if the dialog is scrolled to the bottom (or isn't
            // scrollable at all) and update more overlay accordingly.
            const scrollable: HTMLElement =
                this.shadowRoot!.querySelector('[scrollable]')!;
            // Reset `wasScrolledToBottom` to false to have consistent
            // behaviour.
            this.wasScrolledToBottom = false;

            const buttonRowHeight = 64;
            const lastTextElement =
                scrollable.querySelector('#lastTextElement')!;

            const options = {
              root: scrollable,
              threshold: [1],
              // While "More" button is shown, the content area take up the
              // whole window and button row in shown in-line as part of the
              // content area. Offset the root bounds by button row height to
              // take it into account.
              rootMargin: `0px 0px -${buttonRowHeight}px 0px`,
            };
            const observer = new IntersectionObserver(entries => {
              assert(entries.length === 1);
              // We cannot check for intersectionRatio strictly equal to 1
              // because its value is sometimes reported with ~0.99 values
              // (see crbug.com/1020466): this can lead to a state where
              // the more button is always visible, with unclickable action
              // buttons covered by an overlay (b/299120185).
              this.wasScrolledToBottom = entries[0].intersectionRatio >= 0.99;

              // After the whole text content was visible at least once, stop
              // observing.
              if (this.wasScrolledToBottom) {
                this.wasScrolledToBottomResolver_.resolve();
                observer.disconnect();
              }

              // After the callback was called once, resolve the returned
              // promise to inform the caller that the initial layout is done.
              resolve();
            }, options);
            observer.observe(lastTextElement);
          });
        }

        whenWasScrolledToBottomForTest(): Promise<void> {
          return this.wasScrolledToBottomResolver_.promise;
        }

        private onWasScrolledToBottomChange_() {
          const scrollable: HTMLElement =
              this.shadowRoot!.querySelector('[scrollable]')!;
          scrollable.classList.toggle(
              'more-content-available', !this.wasScrolledToBottom);
        }

        private onMoreClicked_() {
          // Scroll to reveal next visible portion of the content.
          const scrollable: HTMLElement =
              this.shadowRoot!.querySelector('[scrollable]')!;
          scrollable.scrollBy({top: scrollable.clientHeight});
        }
      }

      return PrivacySandboxDialogMixin;
    });

export interface PrivacySandboxDialogMixinInterface {
  wasScrolledToBottom: boolean;

  onConsentLearnMoreExpandedChanged(newValue: boolean, oldValue: boolean): void;
  onNoticeLearnMoreExpandedChanged(newValue: boolean, oldValue: boolean): void;
  onNoticeOpenSettings(): void;
  onNoticeAcknowledge(): void;
  maybeShowMoreButton(): Promise<void>;
  whenWasScrolledToBottomForTest(): Promise<void>;
  promptActionOccurred(action: PrivacySandboxPromptAction): void;
  updateScrollableContents(): void;
}