// 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.
import '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import '//resources/cr_elements/cr_checkbox/cr_checkbox.js';
import type {CrActionMenuElement} from '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {AppInfo, ClickEvent} from './app_home.mojom-webui.js';
import {RunOnOsLoginMode} from './app_home.mojom-webui.js';
import {AppHomeUserAction, recordUserAction} from './app_home_utils.js';
import {getCss} from './app_item.css.js';
import {getHtml} from './app_item.html.js';
import {BrowserProxy} from './browser_proxy.js';
import {UserDisplayMode} from './user_display_mode.mojom-webui.js';
export interface AppItemElement {
$: {
menu: CrActionMenuElement,
iconContainer: HTMLElement,
};
}
export class AppItemElement extends CrLitElement {
static get is() {
return 'app-item';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
appInfo: {type: Object},
};
}
appInfo: AppInfo = {
id: '',
startUrl: {url: ''},
name: '',
iconUrl: {url: ''},
mayShowRunOnOsLoginMode: false,
mayToggleRunOnOsLoginMode: false,
runOnOsLoginMode: 0,
isLocallyInstalled: false,
openInWindow: false,
mayUninstall: false,
isDeprecatedApp: false,
storePageUrl: {url: ''},
};
override firstUpdated() {
this.addEventListener('contextmenu', this.handleContextMenu_);
this.addEventListener('click', this.handleClick_);
this.addEventListener('auxclick', this.handleClick_);
}
closeContextMenu() {
if (!this.$.menu.open) {
return;
}
this.$.menu.close();
this.fire_('on-menu-closed', {appItem: this});
}
private handleContextMenu_(e: Event) {
const position = this.getPositionForEvent_(e);
if (this.isValidPosition(position)) {
// Show custom context menu only if it is inside the area of the item that
// triggered it.
this.fire_('on-menu-open-triggered', {
appItem: this,
});
this.$.menu.showAtPosition(position);
recordUserAction(AppHomeUserAction.CONTEXT_MENU_TRIGGERED);
}
e.preventDefault();
e.stopPropagation();
}
private isValidPosition(position: any) {
const rect =
this.shadowRoot!.getElementById(
'objectContainer')!.getBoundingClientRect();
if (!rect) {
return false;
}
return (
position.top >= rect.top && position.top <= rect.bottom &&
position.left >= rect.left && position.left <= rect.right);
}
private getPositionForEvent_(e: Event) {
if (e instanceof MouseEvent) {
return {top: e.clientY, left: e.clientX};
} else {
// Events other than a MouseEvent do not have locations specified, so
// automatically default to the middle of the icon for the context menu to
// show up.
const rect =
this.shadowRoot!.getElementById(
'iconContainer')!.getBoundingClientRect();
if (rect) {
return {
top: rect.top + (rect.height / 2),
left: rect.left + (rect.width / 2),
};
} else {
return {top: 0, left: 0};
}
}
}
private handleClick_(e: MouseEvent) {
// We want to capture left-click `0` and aux-click `1` (aka
// middle-mouse-button click). Other clicks (right-click, etc) should not
// trigger a launch.
if (e.button > 1) {
return;
}
const clickEvent: ClickEvent = {
button: e.button,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
};
if (this.appInfo.isDeprecatedApp) {
recordUserAction(AppHomeUserAction.LAUNCH_DEPRECATED_APP);
} else {
recordUserAction(AppHomeUserAction.LAUNCH_WEB_APP);
}
BrowserProxy.getInstance().handler.launchApp(this.appInfo.id, clickEvent);
e.preventDefault();
e.stopPropagation();
}
private fire_(eventName: string, detail?: any) {
this.dispatchEvent(
new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
}
// The CrActionMenuElement is a modal that does not listen to any other
// events other than mousedown on right click when it is open. This allows
// us to listen to changes on the dom even when the menu is showing.
protected onMenuMousedown_(e: MouseEvent) {
// Actions that are not a right click on a different app are handled
// correctly by the cr-action-menu. Listening to them here causes
// errors.
if (e.button !== 2) {
return;
}
// Do not listen to the mousedown event if not triggered from a
// CrActionMenuElement, i.e. one without a dialog element covering the dom.
if ((e.composedPath()[0] as HTMLElement).tagName !== 'DIALOG') {
return;
}
this.closeContextMenu();
}
// Methods to hide menu items:
protected isWebStoreLinkHidden_() {
return !this.appInfo.storePageUrl;
}
protected isOpenInWindowHidden_() {
return !this.appInfo.isLocallyInstalled || this.appInfo.isDeprecatedApp;
}
protected isLaunchOnStartupDisabled_() {
return !this.appInfo.mayToggleRunOnOsLoginMode;
}
protected isLaunchOnStartupHidden_() {
return !this.appInfo.mayShowRunOnOsLoginMode ||
this.appInfo.isDeprecatedApp;
}
protected isCreateShortcutHidden_() {
return !this.appInfo.isLocallyInstalled || this.appInfo.isDeprecatedApp;
}
protected isInstallLocallyHidden_() {
return this.appInfo.isLocallyInstalled || this.appInfo.isDeprecatedApp;
}
protected isUninstallHidden_() {
return !this.appInfo.isLocallyInstalled;
}
protected isRemoveFromChromeHidden_() {
return this.appInfo.isLocallyInstalled;
}
protected isAppSettingsHidden_() {
return !this.appInfo.isLocallyInstalled;
}
protected isLocallyInstalled_() {
return this.appInfo.isLocallyInstalled;
}
protected isLaunchOnStartUp_() {
return this.appInfo.runOnOsLoginMode !== RunOnOsLoginMode.kNotRun;
}
protected onMenuClick_(event: Event) {
// The way the menu works, it's inside of a dialog which covers the whole
// screen. Because our element uses a click listener on the entire element,
// any clicks anywhere while the context menu is open will then bubble up to
// our launch listener. So this makes sure that we stop those clicks here.
event.stopPropagation();
}
protected openStorePage_() {
if (!this.appInfo.storePageUrl) {
return;
}
window.open(new URL(this.appInfo.storePageUrl.url), '_blank');
this.closeContextMenu();
}
protected onOpenInWindowItemChange_(e: CustomEvent<boolean>) {
const checked = e.detail;
if (!checked) {
BrowserProxy.getInstance().handler.setUserDisplayMode(
this.appInfo.id, UserDisplayMode.kBrowser);
recordUserAction(AppHomeUserAction.OPEN_IN_WINDOW_UNCHECKED);
} else {
BrowserProxy.getInstance().handler.setUserDisplayMode(
this.appInfo.id, UserDisplayMode.kStandalone);
recordUserAction(AppHomeUserAction.OPEN_IN_WINDOW_CHECKED);
}
}
// Changing the app's launch mode.
protected onLaunchOnStartupItemClick_() {
if (this.isLaunchOnStartupDisabled_()) {
return;
}
if (this.isLaunchOnStartUp_()) {
BrowserProxy.getInstance().handler.setRunOnOsLoginMode(
this.appInfo.id, RunOnOsLoginMode.kNotRun);
recordUserAction(AppHomeUserAction.LAUNCH_AT_STARTUP_UNCHECKED);
} else {
BrowserProxy.getInstance().handler.setRunOnOsLoginMode(
this.appInfo.id, RunOnOsLoginMode.kWindowed);
recordUserAction(AppHomeUserAction.LAUNCH_AT_STARTUP_CHECKED);
}
}
protected onCreateShortcutItemClick_() {
if (this.appInfo.id) {
BrowserProxy.getInstance().handler.createAppShortcut(this.appInfo.id);
recordUserAction(AppHomeUserAction.CREATE_SHORTCUT);
}
this.closeContextMenu();
}
protected onInstallLocallyItemClick_() {
if (this.appInfo.id) {
BrowserProxy.getInstance().handler.installAppLocally(this.appInfo.id);
recordUserAction(AppHomeUserAction.INSTALL_APP_LOCALLY);
}
this.closeContextMenu();
}
protected onUninstallItemClick_() {
if (this.appInfo.id) {
BrowserProxy.getInstance().handler.uninstallApp(this.appInfo.id);
recordUserAction(AppHomeUserAction.UNINSTALL);
}
this.closeContextMenu();
}
protected onAppSettingsItemClick_() {
if (this.appInfo.id) {
BrowserProxy.getInstance().handler.showAppSettings(this.appInfo.id);
recordUserAction(AppHomeUserAction.OPEN_APP_SETTINGS);
}
this.closeContextMenu();
}
protected getIconUrl_() {
const url = new URL(this.appInfo.iconUrl.url);
// For web app, the backend serves grayscale image when the app is not
// locally installed automatically and doesn't recognize this query param,
// but we add a query param here to force browser to refetch the image.
if (!this.isLocallyInstalled_()) {
url.searchParams.append('grayscale', 'true');
}
return url;
}
}
declare global {
interface HTMLElementTagNameMap {
'app-item': AppItemElement;
}
}
customElements.define(AppItemElement.is, AppItemElement);