// 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 './icons.html.js';
import './strings.m.js';
import './textarea.js';
import './result_text.js';
import '//resources/cr_elements/cr_button/cr_button.js';
import '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_loading_gradient/cr_loading_gradient.js';
import '//resources/cr_elements/cr_shared_vars.css.js';
import '//resources/cr_elements/icons.html.js';
import '//resources/cr_elements/md_select.css.js';
import '//resources/polymer/v3_0/iron-icon/iron-icon.js';
import {ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import type {CrA11yAnnouncerElement} from '//resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {getInstance as getAnnouncerInstance} from '//resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import type {CrButtonElement} from '//resources/cr_elements/cr_button/cr_button.js';
import type {CrFeedbackButtonsElement} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrFeedbackOption} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrScrollObserverMixin} from '//resources/cr_elements/cr_scroll_observer_mixin.js';
import {I18nMixin} from '//resources/cr_elements/i18n_mixin.js';
import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {isMac} from '//resources/js/platform.js';
import {Debouncer, microTask, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {ComposeAppAnimator} from './animations/app_animator.js';
import {getTemplate} from './app.html.js';
import type {ComposeResponse, ComposeState, ComposeUntrustedDialogCallbackRouter, ConfigurableParams, PartialComposeResponse} from './compose.mojom-webui.js';
import {CloseReason, StyleModifier, UserFeedback} from './compose.mojom-webui.js';
import type {ComposeApiProxy} from './compose_api_proxy.js';
import {ComposeApiProxyImpl} from './compose_api_proxy.js';
import {ComposeStatus} from './compose_enums.mojom-webui.js';
import type {ComposeResultTextElement, TextInput} from './result_text.js';
import type {ComposeTextareaElement} from './textarea.js';
// Struct with ComposeAppElement's properties that need to be saved to return
// the element to a specific state.
export interface ComposeAppState {
editedInput?: string;
input: string;
isEditingSubmittedInput?: boolean;
selectedLength?: StyleModifier;
selectedTone?: StyleModifier;
}
export interface ComposeAppElement {
$: {
firstRunDialog: HTMLElement,
firstRunFooter: HTMLElement,
firstRunOkButton: CrButtonElement,
freMsbbDialog: HTMLElement,
appDialog: HTMLElement,
body: HTMLElement,
bodyAndFooter: HTMLElement,
cancelEditButton: CrButtonElement,
closeButton: HTMLElement,
firstRunCloseButton: HTMLElement,
closeButtonMSBB: HTMLElement,
editTextarea: ComposeTextareaElement,
errorFooter: HTMLElement,
errorGoBackButton: CrButtonElement,
acceptButton: CrButtonElement,
loading: HTMLElement,
undoButton: CrButtonElement,
undoButtonRefined: CrButtonElement,
redoButton: CrButtonElement,
refreshButton: HTMLElement,
resultContainer: HTMLElement,
resultTextContainer: HTMLElement,
resultFooter: HTMLElement,
submitButton: CrButtonElement,
submitEditButton: CrButtonElement,
submitFooter: HTMLElement,
onDeviceUsedFooter: HTMLElement,
textarea: ComposeTextareaElement,
lengthMenu: HTMLSelectElement,
toneMenu: HTMLSelectElement,
modifierMenu: HTMLSelectElement,
resultText: ComposeResultTextElement,
feedbackButtons: CrFeedbackButtonsElement,
};
}
/**
* Delay required for screen readers to read out consecutive messages while
* focus is being moved between elements.
*/
export const TIMEOUT_MS: number = 700;
const ComposeAppElementBase = I18nMixin(CrScrollObserverMixin(PolymerElement));
// Enumerates trigger points of compose or regenerate calls.
// Used to mark where a compose call was made so focus
// can be restored to the respective element afterwards.
enum TriggerElement {
SUBMIT_INPUT, // For initial input or editing input.
TONE,
LENGTH,
MODIFIER,
REFRESH,
UNDO,
REDO,
}
export class ComposeAppElement extends ComposeAppElementBase {
static get is() {
return 'compose-app';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
editedInput_: {
type: String,
observer: 'onEditedInputChanged_',
},
enableAnimations: {
type: Boolean,
value: loadTimeData.getBoolean('enableAnimations'),
reflectToAttribute: true,
},
enableUiRefinements: {
type: Boolean,
value: loadTimeData.getBoolean('enableRefinedUi'),
reflectToAttribute: true,
},
feedbackState_: {
type: String,
value: CrFeedbackOption.UNSPECIFIED,
},
input_: {
type: String,
observer: 'onInputChanged_',
},
isEditingSubmittedInput_: {
type: Boolean,
reflectToAttribute: true,
value: false,
observer: 'onIsEditingSubmittedInputChanged_',
},
isEditingResultText_: {
type: Boolean,
reflectToAttribute: true,
value: false,
},
isEditSubmitEnabled_: {
type: Boolean,
value: true,
},
isSubmitEnabled_: {
type: Boolean,
value: true,
},
loading_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
loadingIndicatorShown_: {
type: Boolean,
reflectToAttribute: true,
computed: 'isLoadingIndicatorShown_(loading_, hasOutput_)',
},
response_: {
type: Object,
value: null,
},
partialResponse_: {
type: Object,
value: null,
},
selectedLength_: {
type: Number,
value: StyleModifier.kUnset,
},
selectedTone_: {
type: Number,
value: StyleModifier.kUnset,
},
showMainAppDialog_: {
type: Boolean,
value: false,
},
submitted_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
undoEnabled_: {
type: Boolean,
value: false,
},
redoEnabled_: {
type: Boolean,
value: false,
},
feedbackEnabled_: {
type: Boolean,
value: true,
},
responseText_: {
type: String,
computed: 'getResponseText_(response_, partialResponse_)',
},
outputComplete_: {
type: Boolean,
},
hasOutput_: {
type: Boolean,
},
displayedText_: {
type: String,
},
lengthOptions_: {
type: Array,
value: () => {
return [
{
value: StyleModifier.kUnset,
label: loadTimeData.getString('lengthMenuTitle'),
isDefault: true,
},
{
value: StyleModifier.kShorter,
label: loadTimeData.getString('shorterOption'),
},
{
value: StyleModifier.kLonger,
label: loadTimeData.getString('longerOption'),
},
];
},
},
toneOptions_: {
type: Array,
value: () => {
return [
{
value: StyleModifier.kUnset,
label: loadTimeData.getString('toneMenuTitle'),
isDefault: true,
},
{
value: StyleModifier.kCasual,
label: loadTimeData.getString('casualToneOption'),
},
{
value: StyleModifier.kFormal,
label: loadTimeData.getString('formalToneOption'),
},
];
},
},
modifierOptions_: {
type: Array,
value: () => {
return [
{
value: StyleModifier.kUnset,
label: loadTimeData.getString('modifierMenuTitle'),
isDefault: true,
},
{
value: StyleModifier.kLonger,
label: loadTimeData.getString('longerOption'),
},
{
value: StyleModifier.kShorter,
label: loadTimeData.getString('shorterOption'),
},
{
value: StyleModifier.kFormal,
label: loadTimeData.getString('formalToneOption'),
},
{
value: StyleModifier.kCasual,
label: loadTimeData.getString('casualToneOption'),
},
{
value: StyleModifier.kRetry,
label: loadTimeData.getString('retryOption'),
},
];
},
},
};
}
static get observers() {
return [
'debounceSaveComposeAppState_(input_, isEditingSubmittedInput_, ' +
'editedInput_)',
'debounceUpdateResultComplete_(outputComplete_, response_)',
];
}
enableAnimations: boolean;
enableUiRefinements: boolean;
private animator_: ComposeAppAnimator;
private apiProxy_: ComposeApiProxy = ComposeApiProxyImpl.getInstance();
private eventTracker_: EventTracker = new EventTracker();
private router_: ComposeUntrustedDialogCallbackRouter =
this.apiProxy_.getRouter();
private showFirstRunDialog_: boolean;
private showMainAppDialog_: boolean;
private showMSBBDialog_: boolean;
private shouldShowMSBBDialog_: boolean;
private editedInput_: string;
private feedbackState_: CrFeedbackOption;
private input_: string;
private inputParams_: ConfigurableParams;
private isEditingSubmittedInput_: boolean;
private isEditingResultText_: boolean;
private isEditSubmitEnabled_: boolean;
private isSubmitEnabled_: boolean;
private loading_: boolean;
private response_: ComposeResponse|null;
private partialResponse_: PartialComposeResponse|undefined;
private saveAppStateDebouncer_: Debouncer;
private scrollCheckDebouncer_: Debouncer;
private updateResultCompleteDebouncer_: Debouncer;
private selectedLength_: StyleModifier;
private selectedTone_: StyleModifier;
private textSelected_: boolean;
private submitted_: boolean;
private undoEnabled_: boolean;
private redoEnabled_: boolean;
private feedbackEnabled_: boolean;
private userHasModifiedState_: boolean = false;
private lastTriggerElement_: TriggerElement;
private outputComplete_: boolean = true;
private hasOutput_: boolean = false;
private displayedText_: string;
private responseText_: string;
private userResponseText_: string|undefined;
constructor() {
super();
ColorChangeUpdater.forDocument().start();
this.animator_ = new ComposeAppAnimator(
this, loadTimeData.getBoolean('enableAnimations'));
this.enableUiRefinements = loadTimeData.getBoolean('enableRefinedUi');
this.getInitialState_();
this.router_.responseReceived.addListener((response: ComposeResponse) => {
this.composeResponseReceived_(response);
});
this.router_.partialResponseReceived.addListener(
(partialResponse: PartialComposeResponse) => {
this.partialComposeResponseReceived_(partialResponse);
});
}
// Overridden from CrScrollObserverMixin in order to change the scrolling
// container based on the UI Refinements flag.
override getContainer(): HTMLElement {
return this.enableUiRefinements ? this.$.resultTextContainer : this.$.body;
}
private getResponseText_(): TextInput {
if (this.userResponseText_ !== undefined) {
return {
text: this.userResponseText_,
isPartial: false,
streamingEnabled: false,
};
} else if (this.response_) {
return {
text: this.response_.status === ComposeStatus.kOk ?
this.response_.result.trim() :
'',
isPartial: false,
streamingEnabled: this.partialResponse_ !== undefined,
};
} else if (this.partialResponse_) {
return {
text: this.partialResponse_?.result.trim(),
isPartial: true,
streamingEnabled: true,
};
} else {
return {text: '', isPartial: false, streamingEnabled: false};
}
}
override connectedCallback() {
super.connectedCallback();
this.eventTracker_.add(document, 'visibilitychange', () => {
if (document.visibilityState !== 'visible') {
// Ensure app state is saved when hiding the dialog.
this.saveComposeAppState_();
}
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this.eventTracker_.removeAll();
}
private debounceSaveComposeAppState_() {
this.saveAppStateDebouncer_ = Debouncer.debounce(
this.saveAppStateDebouncer_, microTask,
() => this.saveComposeAppState_());
}
private getInitialState_() {
this.apiProxy_.requestInitialState().then(initialState => {
this.inputParams_ = initialState.configurableParams;
// The dialog can initially be in one of three view states. Completion of
// the FRE causes the dialog to show the MSBB state if MSBB is not
// enabled, and the main app state otherwise.
this.showFirstRunDialog_ = !initialState.freComplete;
this.showMSBBDialog_ =
initialState.freComplete && !initialState.msbbState;
this.shouldShowMSBBDialog_ = !initialState.msbbState;
this.showMainAppDialog_ =
initialState.freComplete && initialState.msbbState;
if (initialState.initialInput) {
this.input_ = initialState.initialInput;
}
this.textSelected_ = initialState.textSelected;
this.partialResponse_ = undefined;
const composeState = initialState.composeState;
this.feedbackState_ = userFeedbackToFeedbackOption(composeState.feedback);
this.loading_ = composeState.hasPendingRequest;
this.submitted_ =
composeState.hasPendingRequest || Boolean(composeState.response);
if (!composeState.hasPendingRequest) {
// If there is a pending request, the existing response is outdated.
this.response_ = composeState.response;
this.undoEnabled_ = Boolean(this.response_?.undoAvailable);
this.redoEnabled_ = Boolean(this.response_?.redoAvailable);
this.feedbackEnabled_ = Boolean(!this.response_?.providedByUser);
}
if (composeState.webuiState) {
const appState: ComposeAppState = JSON.parse(composeState.webuiState);
this.input_ = appState.input;
this.selectedLength_ = appState.selectedLength ?? StyleModifier.kUnset;
this.selectedTone_ = appState.selectedTone ?? StyleModifier.kUnset;
if (appState.isEditingSubmittedInput) {
this.isEditingSubmittedInput_ = appState.isEditingSubmittedInput;
this.editedInput_ = appState.editedInput!;
}
}
if (this.showFirstRunDialog_) {
this.animator_.transitionToFirstRun();
} else {
this.animator_.transitionInDialog();
}
// Wait for one timeout to flush Polymer tasks, then wait for the next
// render.
setTimeout(() => {
requestAnimationFrame(() => this.apiProxy_.showUi());
});
});
}
private getTrimmedResult_(): string|undefined {
return this.response_?.result.trim();
}
private getTrimmedPartialResult_(): string|undefined {
return this.partialResponse_?.result.trim();
}
private onFirstRunOkButtonClick_() {
this.apiProxy_.completeFirstRun();
if (this.shouldShowMSBBDialog_) {
this.showMSBBDialog_ = true;
} else {
this.showMainAppDialog_ = true;
this.animator_.transitionToInput();
}
this.showFirstRunDialog_ = false;
}
private onFirstRunBottomTextClick_(e: Event) {
e.preventDefault();
// Embedded links do not work in WebUI so handle in the parent event
// listener.
if ((e.target as HTMLElement).tagName === 'A') {
this.apiProxy_.openComposeLearnMorePage();
}
}
private onCancelEditClick_() {
const fullBodyHeight = this.$.body.offsetHeight;
const resultContainerHeight = this.$.resultContainer.offsetHeight;
this.isEditingSubmittedInput_ = false;
this.$.textarea.focusEditButton();
this.animator_.transitionFromEditingToResult(resultContainerHeight);
this.$.textarea.transitionToResult(fullBodyHeight);
this.$.editTextarea.transitionToResult(fullBodyHeight);
this.apiProxy_.logCancelEdit();
}
private onClose_(e: Event) {
switch ((e.target as HTMLElement).id) {
case 'firstRunCloseButton': {
this.apiProxy_.closeUi(CloseReason.kFirstRunCloseButton);
break;
}
case 'closeButtonMSBB': {
this.apiProxy_.closeUi(CloseReason.kMSBBCloseButton);
break;
}
case 'closeButton': {
this.apiProxy_.closeUi(CloseReason.kCloseButton);
break;
}
}
}
private onEditedInputChanged_() {
this.userHasModifiedState_ = true;
if (!this.isEditSubmitEnabled_) {
this.isEditSubmitEnabled_ = this.$.editTextarea.validate();
}
}
private onEditClick_() {
const fullBodyHeight = this.$.body.offsetHeight;
const resultContainerHeight = this.$.resultContainer.offsetHeight;
this.editedInput_ = this.input_;
this.isEditingSubmittedInput_ = true;
this.animator_.transitionFromResultToEditing(resultContainerHeight);
this.$.textarea.transitionToEditing(fullBodyHeight);
this.$.editTextarea.transitionToEditing(fullBodyHeight);
this.apiProxy_.logEditInput();
}
private onIsEditingSubmittedInputChanged_() {
if (this.isEditingSubmittedInput_) {
// When switching to editing the submitted input, manually move focus
// to the input.
this.$.editTextarea.focusInput();
}
}
private onRefresh_() {
this.rewrite_(StyleModifier.kRetry);
this.lastTriggerElement_ = TriggerElement.REFRESH;
}
private onSubmit_() {
this.isSubmitEnabled_ = this.$.textarea.validate();
if (!this.isSubmitEnabled_) {
this.$.textarea.focusInput();
return;
}
this.$.textarea.scrollInputToTop();
const bodyHeight = this.$.body.offsetHeight;
const footerHeight = this.$.submitFooter.offsetHeight;
this.submitted_ = true;
this.animator_.transitionOutSubmitFooter(bodyHeight, footerHeight);
this.$.textarea.transitionToReadonly();
this.compose_();
this.lastTriggerElement_ = TriggerElement.SUBMIT_INPUT;
}
private onSubmitEdit_() {
this.isEditSubmitEnabled_ = this.$.editTextarea.validate();
if (!this.isEditSubmitEnabled_) {
this.$.editTextarea.focusInput();
return;
}
const bodyHeight = this.$.bodyAndFooter.offsetHeight;
const editTextareaHeight = this.$.editTextarea.offsetHeight;
this.isEditingSubmittedInput_ = false;
this.input_ = this.editedInput_;
this.selectedLength_ = StyleModifier.kUnset;
this.selectedTone_ = StyleModifier.kUnset;
this.animator_.transitionFromEditingToLoading(bodyHeight);
this.$.textarea.transitionToReadonly(editTextareaHeight);
this.$.editTextarea.transitionToReadonly(editTextareaHeight);
this.compose_(true);
this.lastTriggerElement_ = TriggerElement.SUBMIT_INPUT;
}
private onAccept_() {
this.apiProxy_.acceptComposeResult().then((success: boolean) => {
if (success) {
this.apiProxy_.closeUi(CloseReason.kInsertButton);
}
});
}
private onInputChanged_() {
this.userHasModifiedState_ = true;
if (!this.isSubmitEnabled_) {
this.isSubmitEnabled_ = this.$.textarea.validate();
}
}
private onLengthChanged_() {
this.selectedLength_ = Number(this.$.lengthMenu.value) as StyleModifier;
this.rewrite_(this.selectedLength_);
this.lastTriggerElement_ = TriggerElement.LENGTH;
}
private onToneChanged_() {
this.selectedTone_ = Number(this.$.toneMenu.value) as StyleModifier;
this.rewrite_(this.selectedTone_);
this.lastTriggerElement_ = TriggerElement.TONE;
}
private onModifierChanged_() {
const selectedModifier =
Number(this.$.modifierMenu.value) as StyleModifier;
this.rewrite_(selectedModifier);
this.lastTriggerElement_ = TriggerElement.MODIFIER;
// Immediately clear the selection after triggering a rewrite. A selected
// index of 0 corresponds to the default value, which is disabled and cannot
// be selected in the dialog.
this.$.modifierMenu.selectedIndex = 0;
}
private openModifierMenuOnKeyDown_(e: KeyboardEvent) {
// On Windows and Linux, ArrowDown and ArrowUp key events directly change
// the menu selection, which fires the `select` on-change event without
// showing what selection was made.
// MacOS keyboard controls opens the dropdown menu on ArrowUp/Down and thus
// does not need to override behaviour.
if (isMac) {
return;
}
// Override keyboard controls for ArrowUp/Down to open the `select` menu.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this.$.modifierMenu.showPicker();
}
}
private onFooterClick_(e: Event) {
if ((e.target as HTMLElement).tagName !== 'A') {
// Do nothing if a link is not clicked.
return;
}
e.preventDefault();
// The "File a bug", "survey", and "sign in" links are embedded into the
// string. Embedded links do not work in WebUI so handle each click in the
// parent event listener.
switch ((e.target as HTMLElement).id) {
case 'bugLink':
this.apiProxy_.openBugReportingLink();
break;
case 'surveyLink':
this.apiProxy_.openFeedbackSurveyLink();
break;
case 'signInLink':
this.apiProxy_.openSignInPage();
break;
default:
this.apiProxy_.openComposeLearnMorePage();
}
}
private onMsbbSettingsClick_(e: Event) {
e.preventDefault();
// Instruct the browser to open the corresponding settings page.
this.apiProxy_.openComposeSettings();
}
private compose_(inputEdited: boolean = false) {
assert(this.$.textarea.validate());
assert(this.submitted_);
// <if expr="is_macosx">
// For VoiceOver, the screen reader on Mac, to read consecutive alerts the
// contents must change between announcements. To satisfy this, new results
// are announced by alternating between this "loading" message and the
// "updated" message. This is also done to announce updates for the undo
// and redo functions.
this.screenReaderAnnounce_(this.i18n('resultLoadingA11yMessage'));
// </if>
this.$.body.scrollTop = 0;
this.loading_ = true;
this.animator_.transitionInLoading();
this.userResponseText_ = undefined;
this.response_ = null;
this.partialResponse_ = undefined;
this.feedbackEnabled_ = true;
this.saveComposeAppState_(); // Ensure state is saved before compose call.
this.apiProxy_.compose(this.input_, inputEdited);
}
private rewrite_(style: StyleModifier) {
assert(this.$.textarea.validate());
assert(this.submitted_);
// <if expr="is_macosx">
this.screenReaderAnnounce_(this.i18n('resultLoadingA11yMessage'));
// </if>
const bodyHeight = this.$.body.offsetHeight;
const resultHeight = this.$.resultContainer.offsetHeight;
this.$.body.scrollTop = 0;
this.loading_ = true;
this.userResponseText_ = undefined;
this.response_ = null;
this.partialResponse_ = undefined;
this.feedbackEnabled_ = true;
this.saveComposeAppState_(); // Ensure state is saved before compose call.
this.apiProxy_.rewrite(style);
this.animator_.transitionFromResultToLoading(bodyHeight, resultHeight);
}
private debounceUpdateResultComplete_() {
this.updateResultCompleteDebouncer_ = Debouncer.debounce(
this.updateResultCompleteDebouncer_, microTask, () => {
return this.updateResultComplete_();
});
}
private updateResultComplete_() {
if (!this.response_) {
return;
}
if (this.response_.status === ComposeStatus.kOk) {
// Don't process OK status until outputComplete_ is true.
if (!this.outputComplete_) {
return;
}
}
this.userResponseText_ = undefined;
const loadingHeight = this.$.loading.offsetHeight;
this.loading_ = false;
this.undoEnabled_ = this.response_.undoAvailable;
this.$.textarea.transitionToEditable();
if (!this.partialResponse_) {
if (this.response_.status === ComposeStatus.kOk) {
this.animator_.transitionFromLoadingToCompleteResult(loadingHeight);
}
} else {
if (this.outputComplete_ && this.response_.status === ComposeStatus.kOk) {
this.animator_.transitionFromPartialToCompleteResult();
}
}
switch (this.lastTriggerElement_) {
case TriggerElement.SUBMIT_INPUT:
this.$.textarea.focusEditButton();
break;
case TriggerElement.REFRESH:
this.$.refreshButton.focus({preventScroll: true});
break;
case TriggerElement.LENGTH:
this.$.lengthMenu.focus({preventScroll: true});
break;
case TriggerElement.TONE:
this.$.toneMenu.focus({preventScroll: true});
break;
case TriggerElement.MODIFIER:
this.$.modifierMenu.focus({ preventScroll: true });
break;
case TriggerElement.UNDO:
if (this.enableUiRefinements) {
this.$.undoButtonRefined.focus();
} else {
this.$.undoButton.focus();
}
break;
case TriggerElement.REDO:
this.$.redoButton.focus();
break;
}
this.screenReaderAnnounce_(
this.i18n('resultUpdatedA11yMessage'), TIMEOUT_MS);
}
private composeResponseReceived_(response: ComposeResponse) {
this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
this.response_ = response;
this.redoEnabled_ = false;
this.feedbackEnabled_ = true;
}
private partialComposeResponseReceived_(partialResponse:
PartialComposeResponse) {
assert(!this.response_);
this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
this.partialResponse_ = partialResponse;
}
private isLoadingIndicatorShown_(): boolean {
return this.loading_ && !this.hasOutput_;
}
// Elements related to results should be hidden when the output is empty, but
// not if the results are in an edited state. The latter corresponds with
// feedback being disabled.
private hideResults_(): boolean {
return !this.hasOutput_ && this.feedbackEnabled_;
}
private hasSuccessfulResponse_(): boolean {
return this.response_?.status === ComposeStatus.kOk;
}
private hasPartialResponse_(): boolean {
return Boolean(this.partialResponse_);
}
private hasPartialOrCompleteResponse_(): boolean {
return Boolean(this.partialResponse_) || this.hasSuccessfulResponse_();
}
private hasFailedResponse_(): boolean {
if (!this.response_) {
return false;
}
return this.response_.status !== ComposeStatus.kOk;
}
private hasErrorWithLink_(): boolean {
return this.hasUnsupportedLanguageResponse_() ||
this.hasPermissionDeniedResponse_();
}
private hasUnsupportedLanguageResponse_(): boolean {
if (!this.response_) {
return false;
}
return this.response_.status === ComposeStatus.kUnsupportedLanguage;
}
private hasPermissionDeniedResponse_(): boolean {
if (!this.response_) {
return false;
}
return this.response_.status === ComposeStatus.kPermissionDenied;
}
private onDeviceEvaluationUsed_(): boolean {
return Boolean(this.response_?.onDeviceEvaluationUsed);
}
private showOnDeviceDogfoodFooter_(): boolean {
return Boolean(this.response_?.onDeviceEvaluationUsed) &&
loadTimeData.getBoolean('enableOnDeviceDogfoodFooter');
}
private undoButtonIcon_(): string {
return this.enableUiRefinements ? 'compose:undo' : 'compose:mvpUndo';
}
private acceptButtonText_(): string {
return this.textSelected_ ? this.i18n('replaceButton') :
this.i18n('insertButton');
}
private failedResponseErrorText_(): string {
switch (this.response_?.status) {
case ComposeStatus.kFiltered:
return this.i18n('errorFiltered');
case ComposeStatus.kRequestThrottled:
return this.i18n('errorRequestThrottled');
case ComposeStatus.kOffline:
return this.i18n('errorOffline');
case ComposeStatus.kRequestTimeout:
return this.i18n('errorTryAgainLater');
case ComposeStatus.kClientError:
case ComposeStatus.kMisconfiguration:
case ComposeStatus.kServerError:
case ComposeStatus.kInvalidRequest:
case ComposeStatus.kRetryableError:
case ComposeStatus.kNonRetryableError:
case ComposeStatus.kDisabled:
case ComposeStatus.kCancelled:
case ComposeStatus.kNoResponse:
default:
return this.i18n('errorTryAgain');
}
}
private isBackFromErrorAvailable_(): boolean {
// True when the current response is a filtering error and resulted from
// applying a modifier.
return Boolean(
this.response_?.status === ComposeStatus.kFiltered &&
this.response_?.triggeredFromModifier);
}
private onResultEdit_(e: CustomEvent<string>) {
this.userResponseText_ = e.detail;
this.apiProxy_.editResult(this.userResponseText_).then(isEdited => {
if (isEdited) {
this.undoEnabled_ = true;
this.redoEnabled_ = false;
this.feedbackEnabled_ = false;
this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
}
});
}
private onSetResultFocus_(e: CustomEvent<boolean>) {
this.isEditingResultText_ = e.detail;
}
private saveComposeAppState_() {
if (this.saveAppStateDebouncer_?.isActive()) {
this.saveAppStateDebouncer_.flush();
return;
}
if (!this.userHasModifiedState_) {
return;
}
const state: ComposeAppState = {input: this.input_};
if (this.selectedLength_ !== StyleModifier.kUnset) {
state.selectedLength = this.selectedLength_;
}
if (this.selectedTone_ !== StyleModifier.kUnset) {
state.selectedTone = this.selectedTone_;
}
if (this.isEditingSubmittedInput_) {
state.isEditingSubmittedInput = this.isEditingSubmittedInput_;
state.editedInput = this.editedInput_;
}
this.apiProxy_.saveWebuiState(JSON.stringify(state));
}
private async onUndoClick_() {
// <if expr="is_macosx">
this.screenReaderAnnounce_(this.i18n('undoResultA11yMessage'));
// </if>
try {
const state = await this.apiProxy_.undo();
if (state == null) {
// Attempted to undo when there are no compose states available to undo.
// Ensure undo is disabled since it is not possible.
this.undoEnabled_ = false;
return;
}
this.updateWithNewState_(state);
// If UI Refinements is enabled, then focus is moved from the undo button
// to the redo button if undo is disabled in the new state. Otherwise, the
// undo button always keeps focus.
if (this.undoEnabled_ || !this.enableUiRefinements) {
this.lastTriggerElement_ = TriggerElement.UNDO;
} else {
this.lastTriggerElement_ = TriggerElement.REDO;
}
} catch (error) {
// Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
// user to try again as there should be a valid state to restore.
// TODO(b/301368162): Ask UX how to handle the edge case of multiple
// fails.
}
}
private async onErrorGoBackButton_() {
try {
const state = await this.apiProxy_.recoverFromErrorState();
// This button should only be enabled following application of a modifier,
// which ensures a previous state to revert to.
assert(state);
this.updateWithNewState_(state);
} catch (error) {
// Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
// user to try again as there should be a valid state to restore.
// TODO(b/301368162): Ask UX how to handle the edge case of multiple
// fails.
}
}
private async onRedoClick_() {
// <if expr="is_macosx">
this.screenReaderAnnounce_(this.i18n('redoResultA11yMessage'));
// </if>
try {
const state = await this.apiProxy_.redo();
if (state == null) {
// Attempted to redo when there are no compose states available to redo.
// Ensure redo is disabled since it is not possible.
this.redoEnabled_ = false;
return;
}
this.updateWithNewState_(state);
// If redo is disabled, then give focus to the undo button by default.
if (this.redoEnabled_) {
this.lastTriggerElement_ = TriggerElement.REDO;
} else {
this.lastTriggerElement_ = TriggerElement.UNDO;
}
} catch (error) {
// Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
// user to try again as there should be a valid state to restore.
// TODO(b/301368162): Ask UX how to handle the edge case of multiple
// fails.
}
}
private updateWithNewState_(state: ComposeState) {
// Restore the dialog to the given state.
this.feedbackEnabled_ = !(state.response?.providedByUser);
this.userResponseText_ =
this.feedbackEnabled_ ? undefined : state.response?.result;
this.response_ = state.response;
this.partialResponse_ = undefined;
this.undoEnabled_ = Boolean(state.response?.undoAvailable);
this.redoEnabled_ = Boolean(state.response?.redoAvailable);
this.feedbackState_ = userFeedbackToFeedbackOption(state.feedback);
if (state.webuiState) {
const appState: ComposeAppState = JSON.parse(state.webuiState);
this.input_ = appState.input;
// TODO(b/333985071): Remove modifier tracking when ComposeUiRefinement
// flag is removed.
this.selectedLength_ = appState.selectedLength ?? StyleModifier.kUnset;
this.selectedTone_ = appState.selectedTone ?? StyleModifier.kUnset;
}
}
private screenReaderAnnounce_(message: string, wait: number = 0) {
setTimeout(() => {
const announcer = getAnnouncerInstance() as CrA11yAnnouncerElement;
announcer.announce(message, wait);
});
}
private onFeedbackSelectedOptionChanged_(
e: CustomEvent<{value: CrFeedbackOption}>) {
this.feedbackState_ = e.detail.value;
switch (e.detail.value) {
case CrFeedbackOption.UNSPECIFIED:
this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackUnspecified);
return;
case CrFeedbackOption.THUMBS_UP:
this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackPositive);
return;
case CrFeedbackOption.THUMBS_DOWN:
this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackNegative);
return;
}
}
}
function userFeedbackToFeedbackOption(userFeedback: UserFeedback):
CrFeedbackOption {
switch (userFeedback) {
case UserFeedback.kUserFeedbackUnspecified:
return CrFeedbackOption.UNSPECIFIED;
case UserFeedback.kUserFeedbackPositive:
return CrFeedbackOption.THUMBS_UP;
case UserFeedback.kUserFeedbackNegative:
return CrFeedbackOption.THUMBS_DOWN;
}
}
declare global {
interface HTMLElementTagNameMap {
'compose-app': ComposeAppElement;
}
}
customElements.define(ComposeAppElement.is, ComposeAppElement);