From 1829aeadd51807345199b2b67cd6f99cdb2aa841 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sat, 23 May 2026 15:16:55 -0500 Subject: [PATCH] Support custom empty message in profiles list --- package.json | 2 +- src/css/style.css | 13 ----- src/js/components/plugin-profiles-list.js | 4 +- src/js/plugins/pluginRendering.js | 24 +++++---- .../specs/plugins/pluginRendering.test.js | 50 +++++++++++++++++++ 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 03c7b6f6..aead5391 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.14.90", + "version": "0.14.91", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 8c05baa9..ec227cdf 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -7528,11 +7528,6 @@ image-cropper { height: 24px; } -/* .plugin-settings-tab { - padding-top: 16px; - padding-bottom: 16px; -} */ - .plugin-settings-tab h1, .plugin-settings-tab h2, .plugin-settings-tab h3, @@ -7565,10 +7560,6 @@ image-cropper { margin: 8px 0; } -/* .plugin-settings-tab a { - color: var(--text-link-color); -} */ - .plugin-setting-item { display: flex; flex-wrap: wrap; @@ -7579,10 +7570,6 @@ image-cropper { border-bottom: var(--hair) solid var(--generic-border-color); } -/* .plugin-setting-item:first-child { - padding-top: 0; -} */ - .plugin-setting-item:last-child { border-bottom: none; } diff --git a/src/js/components/plugin-profiles-list.js b/src/js/components/plugin-profiles-list.js index 6f0573cb..66b7dfbb 100644 --- a/src/js/components/plugin-profiles-list.js +++ b/src/js/components/plugin-profiles-list.js @@ -4,7 +4,7 @@ import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; class PluginProfilesList extends Component { static get observedAttributes() { - return ["dids"]; + return ["dids", "empty-message"]; } connectedCallback() { @@ -57,12 +57,14 @@ class PluginProfilesList extends Component { render(html`
${this.error}
`, this); return; } + const emptyMessage = this.getAttribute("empty-message"); render( profileFeedTemplate({ profiles: this.profiles, hasMore: false, showEndMessage: false, skeletonCount: this.parseDids().length, + ...(emptyMessage ? { emptyMessage } : {}), }), this, ); diff --git a/src/js/plugins/pluginRendering.js b/src/js/plugins/pluginRendering.js index fd3e637b..4fd36d98 100644 --- a/src/js/plugins/pluginRendering.js +++ b/src/js/plugins/pluginRendering.js @@ -63,8 +63,6 @@ const ALLOWED_ATTRS = [ "for", "id", "href", - "dids", - "icon", ]; function isSafeHref(value) { @@ -77,12 +75,15 @@ function isSafeHref(value) { } } -function isAllowedAttr(name) { - return ( - ALLOWED_ATTRS.includes(name) || - name.startsWith("data-") || - name.startsWith("aria-") - ); +function isAllowedAttr(name, tag) { + if (ALLOWED_ATTRS.includes(name)) return true; + if (name.startsWith("data-") || name.startsWith("aria-")) return true; + if (tag && tag.includes("-")) { + // Allow custom elements observed attributes + const ctor = customElements.get(tag); + if (ctor?.observedAttributes?.includes(name)) return true; + } + return false; } function createVirtualEvent(e) { @@ -175,7 +176,7 @@ export class PluginRenderer { } if (node.attrs) { for (const [name, value] of Object.entries(node.attrs)) { - if (!isAllowedAttr(name)) { + if (!isAllowedAttr(name, tag)) { console.warn( `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, ); @@ -210,14 +211,15 @@ export class PluginRenderer { const oldAttrs = oldNode.attrs ?? {}; const newAttrs = newNode.attrs ?? {}; const isFocused = document.activeElement === element; + const tag = element.localName; for (const name of Object.keys(oldAttrs)) { - if (!(name in newAttrs) && isAllowedAttr(name)) { + if (!(name in newAttrs) && isAllowedAttr(name, tag)) { element.removeAttribute(name); } } for (const [name, value] of Object.entries(newAttrs)) { - if (!isAllowedAttr(name)) { + if (!isAllowedAttr(name, tag)) { console.warn( `[plugins] "${pluginId}" tried to set disallowed attribute "${name}"`, ); diff --git a/tests/unit/specs/plugins/pluginRendering.test.js b/tests/unit/specs/plugins/pluginRendering.test.js index 250db640..94dbfeb2 100644 --- a/tests/unit/specs/plugins/pluginRendering.test.js +++ b/tests/unit/specs/plugins/pluginRendering.test.js @@ -341,6 +341,56 @@ t.describe("PluginRenderer:plugin-icon", (it) => { }); }); +t.describe("PluginRenderer:custom element observedAttributes", (it) => { + it("passes through attrs declared in a custom element's observedAttributes", () => { + // plugin-icon declares observedAttributes = ["icon"] — verifies the + // observedAttributes lookup is what allows `icon` through now that it + // has been removed from the global ALLOWED_ATTRS list. + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const element = renderer.createRoot().render({ + tag: "plugin-icon", + attrs: { icon: "bell" }, + }); + assertEquals(element.getAttribute("icon"), "bell"); + }); + + it("drops custom attrs that aren't in observedAttributes", () => { + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const element = renderer.createRoot().render({ + tag: "plugin-icon", + attrs: { icon: "bell", "secret-mode": "on" }, + }); + assertEquals(element.getAttribute("icon"), "bell"); + assert(!element.hasAttribute("secret-mode")); + }); + + it("does not scope a custom attr from one component onto another", () => { + // `dids` is observed by plugin-profiles-list but not by plugin-icon — + // it should not leak across tags. + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const element = renderer.createRoot().render({ + tag: "plugin-icon", + attrs: { icon: "bell", dids: "did:test:a" }, + }); + assert(!element.hasAttribute("dids")); + }); + + it("removes a custom attr on patch when it's dropped from the new tree", () => { + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const root = renderer.createRoot(); + const element = root.render({ + tag: "plugin-icon", + attrs: { icon: "bell" }, + }); + root.render({ tag: "plugin-icon", attrs: {} }); + assert(!element.hasAttribute("icon")); + }); +}); + t.describe("PluginRenderer:anchor tags", (it) => { it("renders with safe https href and forces target/rel", () => { const { bridge } = makeBridge(); -- 2.51.2