cant seem to edit my response, but this is an updated Lit.js component that allows for filtering and querystring filtering, so you can create a link with the notification_id in it so clicking on a notification from the app will take you to the dashboard and filter by the correct notification id:
import {
LitElement,
html,
css
} from "https://unpkg.com/[email protected]/lit-element.js?module";
import {
marked
} from "https://unpkg.com/[email protected]/lib/marked.esm.js?module";
import DOMPurify from "https://unpkg.com/[email protected]/dist/purify.es.mjs?module";
class PersistentNotificationsCard extends LitElement {
static properties = {
hass: {
type: Object
},
_notifications: {
state: true
},
_filtered: {
state: true
},
_filters: {
state: true
},
_copiedId: {
state: true
},
};
constructor() {
super();
this._notifications = [];
this._filtered = [];
this._filters = {
search: "",
id: ""
};
this._refreshInterval = null;
this._debounceTimer = null;
this._copiedId = null;
}
setConfig(config) {
this.config = config || {};
}
connectedCallback() {
super.connectedCallback();
this._loadFiltersFromURL();
this._loadNotifications();
this._refreshInterval = setInterval(() => this._loadNotifications(), 15000);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._refreshInterval) clearInterval(this._refreshInterval);
}
async _loadNotifications() {
if (!this.hass?.connection) return;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "persistent_notification/get",
});
result.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const parsed = result.map((n) => ({
...n,
html_message: DOMPurify.sanitize(marked.parse(n.message || "")),
}));
this._notifications = parsed;
this._applyFilters();
} catch (err) {
console.error("Error fetching persistent notifications:", err);
}
}
async _dismiss(notification_id) {
if (!this.hass?.connection) return;
try {
await this.hass.callService("persistent_notification", "dismiss", {
notification_id,
});
// Immediately remove the notification from the local state for a faster UI update
this._notifications = this._notifications.filter(
(n) => n.notification_id !== notification_id
);
this._applyFilters();
// A full reload will happen on the next interval, but we should force one quickly to ensure HA state is reflected
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => this._loadNotifications(), 500);
} catch (err) {
console.error("Error dismissing notification:", err);
}
}
// New function to dismiss all currently filtered notifications
async _dismissAll() {
if (!confirm(`Are you sure you want to dismiss all ${this._filtered.length} visible notifications?`)) {
return;
}
const notificationsToDismiss = this._filtered.map(n => n.notification_id);
// Use Promise.all to dismiss them concurrently for speed
const dismissalPromises = notificationsToDismiss.map(id =>
this.hass.callService("persistent_notification", "dismiss", {
notification_id: id,
})
);
try {
await Promise.all(dismissalPromises);
// After all dismissals are requested, force a load to refresh the list
this._loadNotifications();
} catch (err) {
console.error("Error dismissing all notifications:", err);
// Even if some fail, try to refresh the list to show partial success
this._loadNotifications();
}
}
async _copyId(id) {
try {
await navigator.clipboard.writeText(id);
this._copiedId = id;
// Reset after 3 seconds
setTimeout(() => (this._copiedId = null), 3000);
} catch (err) {
console.error("Clipboard copy failed:", err);
}
}
_loadFiltersFromURL() {
const params = new URLSearchParams(window.location.search);
const search = params.get("search") || "";
const id = params.get("id") || "";
this._filters = {
search,
id
};
}
_updateQueryString() {
const params = new URLSearchParams();
if (this._filters.search) params.set("search", this._filters.search);
if (this._filters.id) params.set("id", this._filters.id);
const newURL =
window.location.pathname + (params.toString() ? `?${params.toString()}` : "");
window.history.replaceState({}, "", newURL);
}
_handleFilterChange(e, key) {
this._filters = { ...this._filters,
[key]: e.target.value
};
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._applyFilters();
this._updateQueryString();
}, 500);
}
_applyFilters() {
const search = this._filters.search.toLowerCase();
const id = this._filters.id.toLowerCase();
this._filtered = this._notifications.filter((n) => {
const matchSearch =
!search ||
(n.title && n.title.toLowerCase().includes(search)) ||
(n.message && n.message.toLowerCase().includes(search));
const matchId = !id || n.notification_id.toLowerCase().includes(id);
return matchSearch && matchId;
});
}
_clearFilters() {
this._filters = {
search: "",
id: ""
};
window.history.replaceState({}, "", window.location.pathname);
this._applyFilters();
}
static styles = css`
:host {
display: block;
}
.filters-container {
padding: 20px;
background: var(--card-background-color);
border-radius: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
.filters-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.clear-all-row {
margin-top: 10px; /* Space below filter inputs */
display: flex;
justify-content: flex-end; /* Align the button to the right */
}
input[type="text"] {
flex: 1;
min-width: 140px;
padding: 6px 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.clear-btn {
background: white;
color: black;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
margin-right: 30px;
}
.clear-btn:hover {
background: #ccc;
}
.dismiss-all-btn {
background: var(--error-color);
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
}
.dismiss-all-btn:hover {
background: #c62828;
}
.notification {
background: var(--card-background-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 14px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
}
.header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-start;
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.meta {
text-align: right;
}
.timestamp,
.notif-id {
color: var(--secondary-text-color);
font-size: 0.9em;
line-height: 1.4;
}
.notif-id-wrapper {
position: relative; /* Establish positioning context for .copied-text */
display: inline-block; /* Only take necessary width */
line-height: 1.4; /* Match parent line-height */
}
.notif-id-btn {
background: none;
border: none;
color: var(--secondary-text-color);
font-size: 0.9em;
text-decoration: underline;
cursor: pointer;
padding: 0;
margin-right: 4px; /* Slight space from ID label */
line-height: inherit; /* Inherit line height */
}
.notif-id-btn:hover {
color: var(--primary-color);
}
.copied-text {
position: absolute; /* Absolute position relative to .notif-id-wrapper */
top: 100%; /* Position below the button/link */
right: 0; /* Align to the right of the wrapper */
white-space: nowrap;
color: var(--primary-color); /* Make it stand out a little */
font-size: 0.8em; /* Slightly smaller */
opacity: 0;
transition: opacity 0.3s ease-in-out;
pointer-events: none; /* Prevents text from being clickable/interfering */
}
.copied-text.visible {
opacity: 1;
}
.message {
margin-top: 10px;
line-height: 1.5;
}
.message img {
max-width: 100%; /* Default for smaller screens */
border-radius: 6px;
margin-top: 8px;
display: block;
}
@media (min-width: 600px) { /* <--- THIS WAS ADDED BACK */
.message img {
max-width: 50%; /* 50% width on screens 600px and wider */
}
}
.message a {
color: var(--primary-color);
text-decoration: underline;
}
button.dismiss-btn {
margin-top: 10px;
background: var(--primary-color);
border: none;
color: white;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
}
button.dismiss-btn:hover {
background: var(--primary-color-dark, #0277bd);
}
`;
render() {
// Determine if the "Clear All" button should be enabled
const filteredCount = this._filtered.length;
const isClearAllDisabled = filteredCount === 0;
const clearAllLabel = `Dismiss All (${filteredCount})`;
return html `
<div class="filters-container">
<div class="filters-row">
<input
type="text"
placeholder="Search title/message..."
.value=${this._filters.search}
@input=${(e) => this._handleFilterChange(e, "search")}
/>
<input
type="text"
placeholder="Filter by ID..."
.value=${this._filters.id}
@input=${(e) => this._handleFilterChange(e, "id")}
/>
</div>
<div class="clear-all-row">
<button class="clear-btn" @click=${this._clearFilters}>Clear Filters</button>
<button
class="dismiss-all-btn"
@click=${this._dismissAll}
?disabled=${isClearAllDisabled}
style=${isClearAllDisabled ? 'opacity: 0.5; cursor: not-allowed;' : ''}
>
${clearAllLabel}
</button>
</div>
</div>
${filteredCount === 0
? html`<div style="padding:12px; color: var(--secondary-text-color);">
No notifications found.
</div>`
: html`
${this._filtered.map(
(n) => html`
<div class="notification">
<div class="header">
<div class="title">
${n.title || html`<span style="opacity:.7">(no title)</span>`}
</div>
<div class="meta">
<div class="timestamp">
${new Date(n.created_at).toLocaleString()}
</div>
<div class="notif-id">
ID:
<span class="notif-id-wrapper">
<button
class="notif-id-btn"
@click=${() => this._copyId(n.notification_id)}
>
${n.notification_id}
</button>
<div
class="copied-text ${this._copiedId === n.notification_id ? 'visible' : ''}"
>
Copied
</div>
</span>
</div>
</div>
</div>
<div class="message" .innerHTML=${n.html_message}></div>
<button class="dismiss-btn" @click=${() => this._dismiss(n.notification_id)}>
Dismiss
</button>
</div>
`
)}
`}
`;
}
getCardSize() {
return Math.max(1, this._filtered.length);
}
}
customElements.define("persistent-notifications-card", PersistentNotificationsCard);