chromium/chrome/browser/resources/chromeos/set_time_dialog/set_time_dialog.ts

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

/**
 * @fileoverview
 * 'set-time-dialog' handles a dialog to check and set system time. It can also
 * include a timezone dropdown if timezoneId is provided.
 *
 * 'set-time-dialog' uses the system time to populate the controls initially and
 * update them as the system time or timezone changes, and notifies Chrome
 * when the user changes the time or timezone.
 */

import 'chrome://resources/ash/common/cr_elements/cros_color_overrides.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_page_host_style.css.js';
import 'chrome://resources/ash/common/cr_elements/md_select.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_style.css.js';

import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assert, assertInstanceof} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SetTimeBrowserProxy, SetTimeBrowserProxyImpl} from './set_time_browser_proxy.js';
import {getTemplate} from './set_time_dialog.html.js';

type TimezoneList = Array<[id: string, name: string]>;

interface TimezoneListItem {
  id: string;
  name: string;
  selected: boolean;
}

function getTimezoneItems(): TimezoneListItem[] {
  const currentTimezoneId = loadTimeData.getString('currentTimezoneId');
  const timezoneList = loadTimeData.getValue('timezoneList') as TimezoneList;
  return timezoneList.map(
      tz => ({id: tz[0], name: tz[1], selected: tz[0] === currentTimezoneId}));
}

/**
 * Builds date and time strings suitable for the values of HTML date and
 * time elements.
 * @param date The date object to represent.
 * @return An object containing 2 properties:
 *   Date is an RFC 3339 formatted date
 *   Time is an HH:MM formatted time.
 */
function dateToHtmlValues(date: Date): {date: string, time: string} {
  // Get the current time and subtract the timezone offset, so the
  // JSON string is in local time.
  const localDate = new Date(date);
  localDate.setMinutes(date.getMinutes() - date.getTimezoneOffset());
  return {
    date: localDate.toISOString().slice(0, 10),
    time: localDate.toISOString().slice(11, 16),
  };
}

/**
 * @return Minimum date for the date picker in RFC 3339 format.
 */
function getMinDate(): string {
  // Start with the build date because we can't trust the clock. The build time
  // doesn't include a timezone, so subtract 1 day to get a safe minimum date.
  let minDate = new Date(loadTimeData.getValue('buildTime'));
  minDate.setDate(minDate.getDate() - 1);
  // Make sure the ostensible date is in range.
  const now = new Date();
  if (now < minDate) {
    minDate = now;
  }
  // Convert to string for date input min attribute.
  return dateToHtmlValues(minDate).date;
}

/**
 * @return Maximum date for the date picker in RFC 3339 format.
 */
function getMaxDate(): string {
  // Set the max date to the build date plus 20 years.
  let maxDate = new Date(loadTimeData.getValue('buildTime'));
  maxDate.setFullYear(maxDate.getFullYear() + 20);
  // Make sure the ostensible date is in range.
  const now = new Date();
  if (now > maxDate) {
    maxDate = now;
  }
  // Convert to string for date input max attribute.
  return dateToHtmlValues(maxDate).date;
}

/**
 * Returns the current time converted to the timezone of the give `timezoneId`.
 */
function getDateInTimezone(timezoneId: string): Date {
  return new Date(new Date()
                      .toLocaleString('en-US', {timeZone: timezoneId})
                      .replace('\u202f', ' '));
}

/**
 * Returns the time difference (in milliseconds) between two timezones.
 */
function getTimezoneDelta(
    firstTimezoneId: string, secondTimezoneId: string): number {
  return getDateInTimezone(firstTimezoneId).getTime() -
      getDateInTimezone(secondTimezoneId).getTime();
}

interface SetTimeDialogElement {
  $: {
    dateInput: HTMLInputElement,
    dialog: CrDialogElement,
    timeInput: HTMLInputElement,
  };
}

const SetTimeDialogBase = WebUiListenerMixin(PolymerElement);

class SetTimeDialogElement extends SetTimeDialogBase {
  static get is() {
    return 'set-time-dialog' as const;
  }

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

  static get properties() {
    return {
      /**
       * Items to populate the timezone select.
       */
      timezoneItems_: {
        type: Array,
        readOnly: true,
        value: getTimezoneItems,
      },

      /**
       * Whether the timezone select element is visible.
       */
      isTimezoneVisible_: {
        type: Boolean,
        readOnly: true,
        value() {
          return loadTimeData.getBoolean('showTimezone');
        },
      },

      /**
       * The minimum date allowed in the date picker.
       */
      minDate_: {
        type: String,
        readOnly: true,
        value: getMinDate,
      },

      /**
       * The maximum date allowed in the date picker.
       */
      maxDate_: {
        type: String,
        readOnly: true,
        value: getMaxDate,
      },

      selectedTimezone_: {
        type: String,
        value() {
          return loadTimeData.getString('currentTimezoneId');
        },
      },
    };
  }

  private browserProxy_: SetTimeBrowserProxy =
      SetTimeBrowserProxyImpl.getInstance();
  private readonly isTimezoneVisible_: boolean;
  private readonly maxDate_: string;
  private readonly minDate_: string;
  private prevValues_:
      {dateInput: string, timeInput: string} = {dateInput: '', timeInput: ''};
  private selectedTimezone_: string;
  private readonly timezoneItems_: TimezoneListItem[];
  /** ID of the timeout used to refresh the current time. */
  private timeTimeoutId_: number|null = null;

  override connectedCallback(): void {
    super.connectedCallback();

    // Register listeners for updates from C++ code.
    this.addWebUiListener(
        'system-clock-updated', this.updateTime_.bind(this, new Date()));
    this.addWebUiListener(
        'system-timezone-changed', this.setTimezone_.bind(this));
    this.addWebUiListener('validation-complete', this.saveAndClose_.bind(this));

    this.browserProxy_.sendPageReady();

    this.$.dialog.showModal();
  }

  override ready(): void {
    super.ready();
    this.updateTime_(new Date());
  }

  private getInputTime_(): Date {
    // Midnight of the current day in GMT.
    const date = this.$.dateInput.valueAsDate;
    assert(date);

    // Add hours and minutes as set on the time input field.
    date.setMilliseconds(this.$.timeInput.valueAsNumber);
    // Add seconds from the system time, since the input fields only allow
    // setting hour and minute.
    date.setSeconds(date.getSeconds() + new Date().getSeconds());
    // Add timezone offset to get real time.
    date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
    return date;
  }

  /**
   * @return Seconds since epoch representing the date on the dialog inputs.
   */
  private getInputTimeSinceEpoch_(): number {
    const now = this.getInputTime_();

    if (this.isTimezoneVisible_) {
      // Add timezone offset to get real time. This is only necessary when the
      // timezone was updated, which is only possible when the dropdown is
      // visible.
      const timezoneDelta = getTimezoneDelta(
          loadTimeData.getString('currentTimezoneId'), this.selectedTimezone_);
      now.setMilliseconds(now.getMilliseconds() + timezoneDelta);
    }

    return Math.floor(now.getTime() / 1000);
  }

  private setTimezone_(timezoneId: string): void {
    if (this.isTimezoneVisible_) {
      const timezoneSelect =
          this.shadowRoot!.querySelector<HTMLSelectElement>('#timezoneSelect');
      assert(timezoneSelect);
      assert(timezoneSelect.childElementCount > 0);
      timezoneSelect.value = timezoneId;
    }

    const now = this.getInputTime_();
    const timezoneDelta = getTimezoneDelta(timezoneId, this.selectedTimezone_);
    now.setMilliseconds(now.getMilliseconds() + timezoneDelta);

    this.selectedTimezone_ = timezoneId;
    this.updateTime_(now);
  }

  /**
   * Updates the date/time controls time.
   * Called initially, then called again once a minute.
   * @param newTime Time used to update the date/time controls.
   */
  private updateTime_(newTime: Date): void {
    // Only update time controls if neither is focused.
    if (document.activeElement!.id !== 'dateInput' &&
        document.activeElement!.id !== 'timeInput') {
      const htmlValues = dateToHtmlValues(newTime);
      this.prevValues_.dateInput = this.$.dateInput.value = htmlValues.date;
      this.prevValues_.timeInput = this.$.timeInput.value = htmlValues.time;
    }

    if (this.timeTimeoutId_) {
      window.clearTimeout(this.timeTimeoutId_);
    }

    // Start timer to update these inputs every minute.
    const secondsRemaining = 60 - newTime.getSeconds();
    const nextTime =
        new Date(newTime.setSeconds(newTime.getSeconds() + secondsRemaining));
    this.timeTimeoutId_ = window.setTimeout(
        this.updateTime_.bind(this, nextTime), secondsRemaining * 1000);
  }

  /**
   * Sets the system time from the UI.
   */
  private applyTime_(): void {
    this.browserProxy_.setTimeInSeconds(this.getInputTimeSinceEpoch_());
  }

  /**
   * Called when focus is lost on date/time controls.
   */
  private onInputBlur_(e: Event): void {
    const inputEl = e.target;
    assertInstanceof(inputEl, HTMLInputElement);

    const valueKey = inputEl.type === 'date' ? 'dateInput' : 'timeInput';
    if (inputEl.value && inputEl.validity.valid) {
      // Make this the new fallback time in case of future invalid input.
      this.prevValues_[valueKey] = inputEl.value;
    } else {
      // Restore previous value.
      inputEl.value = this.prevValues_[valueKey];
    }

    // Schedule periodic updates with the new time.
    this.updateTime_(this.getInputTime_());
  }

  private onTimezoneChange_(e: Event): void {
    const selectEl = e.currentTarget;
    assertInstanceof(selectEl, HTMLSelectElement);
    this.setTimezone_(selectEl.value);
  }

  /**
   * Called when the done button is clicked. Child accounts need parental
   * approval to change time, which requires an extra step after the button is
   * clicked. This method notifies the dialog delegate to start the approval
   * step, once the approval is granted the 'validation-complete' event is
   * triggered invoking `saveAndClose_`. For regular accounts, this step is
   * skipped and `saveAndClose_` is called immediately after the button click.
   */
  private onDoneClick_(): void {
    this.browserProxy_.doneClicked(this.getInputTimeSinceEpoch_());
  }

  private saveAndClose_(): void {
    this.applyTime_();
    // Timezone change should only be applied when the UI displays timezone
    // setting. Otherwise `selectedTimezone_` will be empty/invalid.
    if (this.isTimezoneVisible_) {
      this.browserProxy_.setTimezone(this.selectedTimezone_);
    }
    this.browserProxy_.dialogClose();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [SetTimeDialogElement.is]: SetTimeDialogElement;
  }
}

customElements.define(SetTimeDialogElement.is, SetTimeDialogElement);