blob: 6299d6f8b838731638f989360a30b32168ac36d0 [file] [log] [blame]
// 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 '//resources/cr_elements/cr_button/cr_button.js';
import '//resources/cr_elements/cr_hidden_style.css.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/icons.html.js';
import '//resources/cr_elements/md_select.css.js';
import {ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import {CrButtonElement} from '//resources/cr_elements/cr_button/cr_button.js';
import {CrFeedbackOption} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrScrollableMixin} from '//resources/cr_elements/cr_scrollable_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 {Debouncer, microTask, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './app.html.js';
import {CloseReason, ComposeDialogCallbackRouter, ComposeResponse, ComposeStatus, ConfigurableParams, Length, StyleModifiers, Tone, UserFeedback} from './compose.mojom-webui.js';
import {ComposeApiProxy, ComposeApiProxyImpl} from './compose_api_proxy.js';
import {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?: Length;
selectedTone?: Tone;
}
export interface ComposeAppElement {
$: {
body: HTMLElement,
cancelEditButton: CrButtonElement,
closeButton: HTMLElement,
closeButtonConsent: HTMLElement,
editTextarea: ComposeTextareaElement,
errorFooter: HTMLElement,
insertButton: CrButtonElement,
loading: HTMLElement,
undoButton: CrButtonElement,
refreshButton: HTMLElement,
resultContainer: HTMLElement,
submitButton: CrButtonElement,
submitEditButton: CrButtonElement,
textarea: ComposeTextareaElement,
lengthMenu: HTMLSelectElement,
toneMenu: HTMLSelectElement,
};
}
const ComposeAppElementBase = I18nMixin(CrScrollableMixin(PolymerElement));
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_',
},
feedbackState_: {
type: String,
value: CrFeedbackOption.UNSPECIFIED,
},
input_: {
type: String,
observer: 'onInputChanged_',
},
isEditingSubmittedInput_: {
type: Boolean,
reflectToAttribute: true,
value: false,
observer: 'onIsEditingSubmittedInputChanged_',
},
isEditSubmitEnabled_: {
type: Boolean,
value: false,
},
isSubmitEnabled_: {
type: Boolean,
value: false,
},
loading_: {
type: Boolean,
value: false,
},
response_: {
type: Object,
value: null,
},
selectedLength_: {
type: Number,
value: Length.kUnset,
},
selectedTone_: {
type: Number,
value: Tone.kUnset,
},
submitted_: {
type: Boolean,
value: false,
},
undoEnabled_: {
type: Boolean,
value: false,
},
lengthOptions_: {
type: Array,
value: () => {
return [
{
value: Length.kUnset,
label: loadTimeData.getString('lengthMenuTitle'),
hidden: true,
},
{
value: Length.kShorter,
label: loadTimeData.getString('shorterOption'),
},
{
value: Length.kLonger,
label: loadTimeData.getString('longerOption'),
},
];
},
},
toneOptions_: {
type: Array,
value: () => {
return [
{
value: Tone.kUnset,
label: loadTimeData.getString('toneMenuTitle'),
hidden: true,
},
{
value: Tone.kCasual,
label: loadTimeData.getString('casualToneOption'),
},
{
value: Tone.kFormal,
label: loadTimeData.getString('formalToneOption'),
},
];
},
},
};
}
static get observers() {
return [
'debounceSaveComposeAppState_(input_, isEditingSubmittedInput_, ' +
'editedInput_)',
];
}
private apiProxy_: ComposeApiProxy = ComposeApiProxyImpl.getInstance();
private eventTracker_: EventTracker = new EventTracker();
private router_: ComposeDialogCallbackRouter = this.apiProxy_.getRouter();
private editedInput_: string;
private feedbackState_: CrFeedbackOption;
private input_: string;
private inputParams_: ConfigurableParams;
private isEditingSubmittedInput_: boolean;
private isEditSubmitEnabled_: boolean;
private isSubmitEnabled_: boolean;
private loading_: boolean;
private response_: ComposeResponse|undefined;
private saveAppStateDebouncer_: Debouncer;
private selectedLength_: Length;
private selectedTone_: Tone;
private submitted_: boolean;
private undoEnabled_: boolean;
private userHasModifiedState_: boolean = false;
constructor() {
super();
ColorChangeUpdater.forDocument().start();
this.getInitialState_();
this.router_.responseReceived.addListener((response: ComposeResponse) => {
this.composeResponseReceived_(response);
});
}
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;
if (initialState.initialInput) {
this.input_ = initialState.initialInput;
}
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);
}
if (composeState.webuiState) {
const appState: ComposeAppState = JSON.parse(composeState.webuiState);
this.input_ = appState.input;
this.selectedLength_ = appState.selectedLength ?? Length.kUnset;
this.selectedTone_ = appState.selectedTone ?? Tone.kUnset;
if (appState.isEditingSubmittedInput) {
this.isEditingSubmittedInput_ = appState.isEditingSubmittedInput;
this.editedInput_ = appState.editedInput!;
}
}
// Wait for one timeout to flush Polymer tasks, then wait for the next
// render.
setTimeout(() => {
requestAnimationFrame(() => this.apiProxy_.showUi());
});
});
}
private onCancelEditClick_() {
this.isEditingSubmittedInput_ = false;
}
private onClose_() {
this.apiProxy_.closeUi(CloseReason.kCloseButton);
}
private onEditedInputChanged_() {
this.userHasModifiedState_ = true;
this.isEditSubmitEnabled_ = this.$.editTextarea.validate();
}
private onEditClick_() {
this.editedInput_ = this.input_;
this.isEditingSubmittedInput_ = true;
}
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_(/*style=*/ {});
}
private onSubmit_() {
if (!this.$.textarea.validate()) {
return;
}
this.submitted_ = true;
this.compose_();
}
private onSubmitEdit_() {
if (!this.$.editTextarea.validate()) {
return;
}
this.isEditingSubmittedInput_ = false;
this.input_ = this.editedInput_;
this.selectedLength_ = Length.kUnset;
this.selectedTone_ = Tone.kUnset;
this.compose_();
}
private onAccept_() {
this.apiProxy_.acceptComposeResult().then((success: boolean) => {
if (success) {
this.apiProxy_.closeUi(CloseReason.kInsertButton);
}
});
}
private onInputChanged_() {
this.userHasModifiedState_ = true;
this.isSubmitEnabled_ = this.$.textarea.validate();
}
private onLengthChanged_() {
this.selectedLength_ = Number(this.$.lengthMenu.value) as Length;
this.rewrite_(/*style=*/ {length: this.selectedLength_});
}
private onToneChanged_() {
this.selectedTone_ = Number(this.$.toneMenu.value) as Tone;
this.rewrite_(/*style=*/ {tone: this.selectedTone_});
}
private onFileBugClick_(e: Event) {
e.preventDefault();
this.apiProxy_.openBugReportingLink();
}
private onConsentTopTextClick_(e: Event) {
e.preventDefault();
// The "settings" link is embedded into the string used here as it may need
// to be localized as part of the sentence. However, such embedded links do
// not function in WebUI. Handle the event by using this parent event
// listener to target the link and instruct the browser to open the
// corresponding settings page.
if ((e.target as HTMLElement).tagName === 'A') {
this.apiProxy_.openComposeSettings();
}
}
private compose_() {
assert(this.$.textarea.validate());
assert(this.submitted_);
this.loading_ = true;
this.response_ = undefined;
this.saveComposeAppState_(); // Ensure state is saved before compose call.
this.apiProxy_.compose(this.input_);
}
private rewrite_(style: StyleModifiers) {
assert(this.$.textarea.validate());
assert(this.submitted_);
this.loading_ = true;
this.response_ = undefined;
this.saveComposeAppState_(); // Ensure state is saved before compose call.
this.apiProxy_.rewrite(style);
}
private composeResponseReceived_(response: ComposeResponse) {
this.response_ = response;
this.loading_ = false;
this.undoEnabled_ = response.undoAvailable;
this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
this.requestUpdateScroll();
}
private hasSuccessfulResponse_(): boolean {
return this.response_?.status === ComposeStatus.kOk;
}
private hasFailedResponse_(): boolean {
if (!this.response_) {
return false;
}
return this.response_.status !== ComposeStatus.kOk;
}
private failedResponseErrorText_(): string {
switch (this.response_?.status) {
case ComposeStatus.kNotSuccessful:
return this.i18n('errorRequestNotSuccessful');
case ComposeStatus.kTryAgain:
return this.i18n('errorTryAgain');
case ComposeStatus.kTryAgainLater:
return this.i18n('errorTryAgainLater');
case ComposeStatus.kPermissionDenied:
return this.i18n('errorPermissionDenied');
case ComposeStatus.kError:
case ComposeStatus.kMisconfiguration:
default:
return this.i18n('errorGeneric');
}
}
private saveComposeAppState_() {
if (this.saveAppStateDebouncer_?.isActive()) {
this.saveAppStateDebouncer_.flush();
return;
}
if (!this.userHasModifiedState_) {
return;
}
const state: ComposeAppState = {input: this.input_};
if (this.selectedLength_ !== Length.kUnset) {
state.selectedLength = this.selectedLength_;
}
if (this.selectedTone_ !== Tone.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_() {
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;
}
// Restore state to the state returned by Undo.
this.response_ = state.response;
this.undoEnabled_ = Boolean(state.response?.undoAvailable);
this.feedbackState_ = userFeedbackToFeedbackOption(state.feedback);
if (state.webuiState) {
const appState: ComposeAppState = JSON.parse(state.webuiState);
this.input_ = appState.input;
this.selectedLength_ = appState.selectedLength ?? Length.kUnset;
this.selectedTone_ = appState.selectedTone ?? Tone.kUnset;
}
} catch (error) {
// Error (e.g., disconnected mojo pipe) from a rejected Promise.
// Previously, we received a true `undo_available` field in either
// RequestInitialState(), ComposeResponseReceived(), or a previous Undo().
// So we think it is possible to undo, but the Promise failed.
// Allow the user to try again. Leave the undo button enabled.
// TODO(b/301368162) Ask UX how to handle the edge case of multiple fails.
}
}
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);
this.apiProxy_.openBugReportingLink();
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);