feat: Allow user generated notifications (#2401)

This commit is contained in:
Dermot Duffy
2026-03-07 20:47:24 -08:00
committed by GitHub
parent 1d81e03b04
commit 24c6b98948
53 changed files with 939 additions and 471 deletions
+2 -2
View File
@@ -8,9 +8,9 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { IconController } from '../components-lib/icon-controller';
import { InternalIcon } from '../config/schema/common/icon.js';
import { HomeAssistant } from '../ha/types';
import iconStyle from '../scss/icon.scss';
import { Icon } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
@customElement('advanced-camera-card-icon')
@@ -19,7 +19,7 @@ export class AdvancedCameraCardIcon extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false, hasChanged: contentsChanged })
public icon?: Icon;
public icon?: InternalIcon;
// Note: This attribute will allow non-active entity state styles (e.g. 'off',
// 'unavailable') to be overriden from outside the icon itself. This is useful
+1 -1
View File
@@ -2,9 +2,9 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.js';
import { Icon } from '../config/schema/common/icon.js';
import { HomeAssistant } from '../ha/types.js';
import controlStyle from '../scss/next-previous-control.scss';
import { Icon } from '../types.js';
import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
@@ -2,17 +2,29 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import overlayMessageStyle from '../scss/overlay-message.scss';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js';
import { actionHandler } from '../action-handler-directive.js';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../config/schema/actions/types.js';
import notificationStyle from '../scss/notification.scss';
import {
getActionConfigGivenAction,
hasAction,
stopEventFromActivatingCardWideActions,
} from '../utils/action.js';
import { arrayify } from '../utils/basic.js';
import { dispatchDismissNotificationEvent } from '../utils/notification.js';
import './icon.js';
@customElement('advanced-camera-card-overlay-message')
export class AdvancedCameraCardOverlayMessage extends LitElement {
@customElement('advanced-camera-card-notification')
export class AdvancedCameraCardNotification extends LitElement {
@property({ attribute: false })
public message: OverlayMessage | null = null;
public notification: Notification | null = null;
private _refMessage: Ref<HTMLElement> = createRef();
private _refNotification: Ref<HTMLElement> = createRef();
public connectedCallback(): void {
super.connectedCallback();
@@ -29,20 +41,20 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
}
protected render(): TemplateResult | void {
if (!this.message) {
if (!this.notification) {
return;
}
const heading = this.message.heading;
const details = this.message.details ?? [];
const text = this.message.text;
const controls = this.message.controls ?? [];
const heading = this.notification.heading;
const details = this.notification.details ?? [];
const text = this.notification.text;
const controls = this.notification.controls ?? [];
return html`
<div class="backdrop" @click=${this._dismiss}></div>
<div
class="message"
${ref(this._refMessage)}
class="notification"
${ref(this._refNotification)}
@animationend=${this._handleAnimationEnd}
>
<div class="details">
@@ -64,60 +76,70 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
`;
}
private _renderControl(control: OverlayMessageControl): TemplateResult {
const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : '';
private _renderControl(control: NotificationControl): TemplateResult {
const severityClass = control.severity ? `severity-${control.severity}` : '';
return html`
<div
class="control ${emphasisClass}"
title=${control.title}
@click=${async () => this._handleControlClick(control)}
class="control ${severityClass}"
title=${control.tooltip ?? ''}
.actionHandler=${actionHandler({
hasHold: hasAction(control.actions?.hold_action),
hasDoubleClick: hasAction(control.actions?.double_tap_action),
})}
@action=${(ev: CustomEvent) => this._handleControlAction(ev, control)}
>
${control.icon
? html`<advanced-camera-card-icon
.icon=${control.icon}
.icon=${{ icon: control.icon }}
></advanced-camera-card-icon>`
: ''}
</div>
`;
}
private async _handleControlClick(control: OverlayMessageControl): Promise<void> {
const result = await control.callback();
if (result === null) {
// null = close the message
private _handleControlAction(
ev: CustomEvent<{ action: string }>,
control: NotificationControl,
): void {
stopEventFromActivatingCardWideActions(ev);
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
if (action) {
dispatchActionExecutionRequest(this, {
actions: arrayify(action),
});
}
if (control.dismiss !== false) {
this._dismiss();
} else {
// Updated message = keep open and refresh
this.message = result;
}
}
private _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult {
const classes = {
detail: true,
heading: isHeading,
[`emphasis-${detail.emphasis}`]: !!detail.emphasis,
[`severity-${detail.severity}`]: !!detail.severity,
};
return html`
<div class="${classMap(classes)}">
${detail.icon
? html`<advanced-camera-card-icon
title=${detail.hint ?? ''}
.icon=${detail.icon}
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
<span title=${detail.text}>${detail.text}</span>
</div>
`;
}
private _dismiss = (): void => {
this._refMessage.value?.classList.add('exiting');
this._refNotification.value?.classList.add('exiting');
};
private _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') {
dispatchDismissOverlayMessageEvent(this);
dispatchDismissNotificationEvent(this);
}
};
@@ -136,12 +158,12 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
};
static get styles(): CSSResultGroup {
return unsafeCSS(overlayMessageStyle);
return unsafeCSS(notificationStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage;
'advanced-camera-card-notification': AdvancedCameraCardNotification;
}
}
+2 -2
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { InternalIcon } from '../../config/schema/common/icon.js';
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.js';
@@ -17,7 +18,6 @@ import { isHassDifferent } from '../../ha/is-hass-different.js';
import { EntityRegistryManager } from '../../ha/registry/entity/types.js';
import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js';
import { createSelectOptionAction } from '../../utils/action.js';
import '../icon.js';
import './index.js';
@@ -37,7 +37,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
private _optionTitles?: Record<string, string>;
private _generatedSubmenuItems?: MenuSubmenuItem[];
private _generatedIcon?: Icon;
private _generatedIcon?: InternalIcon;
protected shouldUpdate(changedProps: PropertyValues): boolean {
// No need to update the submenu unless the select entity has changed.
+10 -6
View File
@@ -8,11 +8,12 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager';
import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { NotificationDetail } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { MetadataField } from '../../types';
import { ViewItem } from '../../view/item';
import '../icon';
@@ -42,7 +43,10 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => {
const renderDetail = (
detail: NotificationDetail,
heading = false,
): TemplateResult => {
return html`<div
class=${classMap({
heading,
@@ -50,12 +54,12 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
>
${detail.icon
? html` <advanced-camera-card-icon
severity=${detail.emphasis}
title=${detail.hint ?? ''}
.icon=${detail.icon}
severity=${ifDefined(detail.severity)}
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
<span title=${detail.text}>${detail.text}</span>
</div>`;
};
+14 -8
View File
@@ -9,24 +9,27 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../../camera-manager/manager';
import { dispatchActionExecutionRequest } from '../../../card-controller/actions/utils/execution-request';
import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
NotificationControlsContext,
} from '../../../components-lib/media/details-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { stopEventFromActivatingCardWideActions } from '../../../utils/action';
import {
createNotificationAction,
stopEventFromActivatingCardWideActions,
} from '../../../utils/action';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../../utils/media-actions';
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import '../../icon.js';
@@ -80,7 +83,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
}
}
private _getControlContext(): OverlayControlsContext {
private _getControlContext(): NotificationControlsContext {
return {
hass: this.hass,
viewItemManager: this.viewItemManager,
@@ -207,10 +210,13 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item);
dispatchShowOverlayMessageEvent(
this,
detailsController.getMessage(this._getControlContext()),
);
dispatchActionExecutionRequest(this, {
actions: [
createNotificationAction(
detailsController.getNotification(this._getControlContext()),
),
],
});
}}
></advanced-camera-card-icon>`
: ''}