From 5fb839527796aa8502fe571fff1e894883eb59b9 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 16 Jul 2026 00:27:45 -0500 Subject: [PATCH] Add claimed facet types --- impro-plugin/main.js | 9 +- impro-plugin/package.json | 2 +- package.json | 2 +- src/css/style.css | 4 + src/js/components/plugin-rich-text.js | 73 +++++++++-- src/js/plugins/pluginService.js | 12 ++ src/js/richTextHelpers.js | 13 ++ src/js/templates/richText.template.js | 20 +++- .../specs/components/plugin-rich-text.test.js | 113 +++++++++++++++++- .../unit/specs/plugins/pluginService.test.js | 49 ++++++++ .../templates/largePost.template.test.js | 1 + .../templates/postEmbed.template.test.js | 1 + .../templates/smallPost.template.test.js | 1 + 13 files changed, 284 insertions(+), 16 deletions(-) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 92f6e33b..cddbe103 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -316,7 +316,10 @@ export class Plugin { // callback(tokens, context) receives the rich-text token stream for one // post and returns a new token array (or the input unchanged). The host // batches all posts of a render into one call per plugin. - registerRichTextTransform(callback = (tokens) => tokens) { + // + // options.handlesFacetTypes: array of facet feature $type strings this + // transform owns, to prevent render flash of fallback text + registerRichTextTransform(callback = (tokens) => tokens, options = {}) { const handlerId = uuid.create(); callHandlers.set(handlerId, async (batch) => { const results = []; @@ -330,10 +333,14 @@ export class Plugin { } return results; }); + const handlesFacetTypes = Array.isArray(options.handlesFacetTypes) + ? options.handlesFacetTypes.filter((type) => typeof type === "string") + : []; self.postMessage({ type: "register", target: "richTextTransform", handlerId, + handlesFacetTypes, }); } diff --git a/impro-plugin/package.json b/impro-plugin/package.json index f8938406..b3ee8893 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.10", + "version": "0.0.11", "type": "module", "main": "main.js", "license": "0BSD", diff --git a/package.json b/package.json index c218588a..d7d8afab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.176", + "version": "0.17.177", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index cf64023f..9a006f0e 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -383,6 +383,10 @@ plugin-rich-text { white-space: normal; } +.rich-text-facet-pending { + display: none; +} + @media (hover: hover) { .rich-text a:hover { text-decoration: underline; diff --git a/src/js/components/plugin-rich-text.js b/src/js/components/plugin-rich-text.js index 11ba8ffe..d9e0d80b 100644 --- a/src/js/components/plugin-rich-text.js +++ b/src/js/components/plugin-rich-text.js @@ -2,10 +2,12 @@ import { render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { Signal, effect } from "/js/signals.js"; import { shallowEquals } from "/js/utils.js"; -import { tokenizeRichText } from "/js/richTextHelpers.js"; +import { tokenizeRichText, tokensHaveFacetType } from "/js/richTextHelpers.js"; import { richTextTokensTemplate } from "/js/templates/richText.template.js"; class PluginRichText extends Component { + static placeholderFallbackMs = 500; + static get observedAttributes() { return ["truncate-urls"]; } @@ -52,6 +54,10 @@ class PluginRichText extends Component { this.disposeRender?.(); this.disposeRender = null; this._currentRequest = null; + if (this._claimedFacetFallbackTimer) { + clearTimeout(this._claimedFacetFallbackTimer); + this._claimedFacetFallbackTimer = null; + } this.initialized = false; } @@ -63,15 +69,22 @@ class PluginRichText extends Component { const pluginService = this.pluginService; const baseTokens = tokenizeRichText({ text, facets }); - // Render base token immediately - this._renderTokens(baseTokens, truncateUrls); - // Subscribe to the version signal so registering/unregistering a transform will re-render pluginService.$richTextTransformsVersion.get(); + if (this._claimedFacetFallbackTimer) { + clearTimeout(this._claimedFacetFallbackTimer); + this._claimedFacetFallbackTimer = null; + } + // Request transformed tokens from pluginService if (!transformContext) { this._currentRequest = null; + this._renderTokens({ + tokens: baseTokens, + truncateUrls, + placeholderFacetTypes: null, + }); return; } const request = pluginService.transformRichTextTokens(baseTokens, { @@ -80,16 +93,59 @@ class PluginRichText extends Component { source: { text, facets }, }); this._currentRequest = request; + + // Hide facet tokens whose feature $type is claimed by a pending transform, + // so shortcodes don't briefly flash as plaintext before the transform swaps + // them for their rendered form (e.g. bluemoji). + const claimedFacetTypes = pluginService.getClaimedFacetTypes(); + this._renderTokens({ + tokens: baseTokens, + truncateUrls, + placeholderFacetTypes: claimedFacetTypes, + }); + + // Fallback + null-resolve re-render only matter when something was hidden. + const hasClaimedFacets = tokensHaveFacetType(baseTokens, claimedFacetTypes); + + if (hasClaimedFacets) { + this._claimedFacetFallbackTimer = setTimeout(() => { + this._claimedFacetFallbackTimer = null; + if (this._currentRequest !== request || !this.initialized) return; + this._renderTokens({ + tokens: baseTokens, + truncateUrls, + placeholderFacetTypes: null, + }); + }, PluginRichText.placeholderFallbackMs); + } + request.then( (transformed) => { if ( // Ignore results that resolved after a newer render pass this._currentRequest !== request || - !this.initialized || - !transformed + !this.initialized ) return; - this._renderTokens(transformed, truncateUrls); + if (this._claimedFacetFallbackTimer) { + clearTimeout(this._claimedFacetFallbackTimer); + this._claimedFacetFallbackTimer = null; + } + if (!transformed) { + if (hasClaimedFacets) { + this._renderTokens({ + tokens: baseTokens, + truncateUrls, + placeholderFacetTypes: null, + }); + } + return; + } + this._renderTokens({ + tokens: transformed, + truncateUrls, + placeholderFacetTypes: null, + }); }, (error) => { console.error("Rich text transform request failed", error); @@ -97,11 +153,12 @@ class PluginRichText extends Component { ); } - _renderTokens(tokens, truncateUrls) { + _renderTokens({ tokens, truncateUrls, placeholderFacetTypes }) { render( richTextTokensTemplate({ tokens, truncateUrls, + placeholderFacetTypes, renderNodeToken: (token) => this.pluginService.renderRichTextNodeToken(token, this), }), diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 93984134..773f1e86 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -248,6 +248,9 @@ export class PluginService extends ReactiveStore { (plugin, message) => { const entry = { pluginId: plugin.pluginId, + handlesFacetTypes: Array.isArray(message.handlesFacetTypes) + ? message.handlesFacetTypes + : [], invoke: (batch) => plugin.call(message.handlerId, batch), }; this.registries.richTextTransforms.add(entry); @@ -911,6 +914,15 @@ export class PluginService extends ReactiveStore { ); } + getClaimedFacetTypes() { + const types = new Set(); + for (const entry of this.registries.richTextTransforms) { + if (!entry.handlesFacetTypes) continue; + for (const type of entry.handlesFacetTypes) types.add(type); + } + return types; + } + // Results are cached by (uri, surface); requests are batched per render flush async transformRichTextTokens(tokens, context) { if (this.registries.richTextTransforms.size === 0) return null; diff --git a/src/js/richTextHelpers.js b/src/js/richTextHelpers.js index 97b28559..f92f7b1a 100644 --- a/src/js/richTextHelpers.js +++ b/src/js/richTextHelpers.js @@ -46,6 +46,19 @@ export function tokenizeRichText({ text, facets = [] }) { return tokens; } +export function tokensHaveFacetType(tokens, facetTypes) { + if (!facetTypes || facetTypes.size === 0) return false; + for (const token of tokens) { + if (token.type !== "facet") continue; + const features = token.facet.features; + if (!features) continue; + for (const feature of features) { + if (facetTypes.has(feature.$type)) return true; + } + } + return false; +} + export function validateRichTextTokens(tokens) { if (!Array.isArray(tokens)) return false; return tokens.every((token) => { diff --git a/src/js/templates/richText.template.js b/src/js/templates/richText.template.js index 76f09cb7..85b81bbd 100644 --- a/src/js/templates/richText.template.js +++ b/src/js/templates/richText.template.js @@ -57,10 +57,14 @@ function facetTemplate({ facet, wrappedText, truncateUrls }) { } // tokens: ({ type: "text" } / { type: "facet" } / { type: "inline" } / { type: "block" }) +// placeholderFacetTypes: Set of facet feature $types to render as +// invisible-but-space-preserving spans (used while a rich-text transform +// that claims the type is still pending, to avoid flashing plaintext). export function richTextTokensTemplate({ tokens, truncateUrls = false, renderNodeToken = () => null, + placeholderFacetTypes = null, }) { const parts = []; tokens.forEach((token, index) => { @@ -78,7 +82,20 @@ export function richTextTokensTemplate({ parts.push(value); break; } - case "facet": + case "facet": { + if ( + placeholderFacetTypes && + token.facet.features?.some((feature) => + placeholderFacetTypes.has(feature.$type), + ) + ) { + parts.push( + html``, + ); + break; + } parts.push( facetTemplate({ facet: token.facet, @@ -87,6 +104,7 @@ export function richTextTokensTemplate({ }), ); break; + } case "inline": case "block": { const element = renderNodeToken(token) ?? null; diff --git a/tests/unit/specs/components/plugin-rich-text.test.js b/tests/unit/specs/components/plugin-rich-text.test.js index bc025dee..cd249ebf 100644 --- a/tests/unit/specs/components/plugin-rich-text.test.js +++ b/tests/unit/specs/components/plugin-rich-text.test.js @@ -1,24 +1,39 @@ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { Signal } from "/js/signals.js"; import "/js/components/plugin-rich-text.js"; describe("plugin-rich-text", () => { + const originalSetTimeout = globalThis.setTimeout; + beforeEach(() => { + globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0); + }); + afterEach(() => { + globalThis.setTimeout = originalSetTimeout; + }); + async function flushEffects() { // Two ticks: signal changes re-run effects via rAF, which the test env // pins to setTimeout. - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => originalSetTimeout(resolve, 0)); + await new Promise((resolve) => originalSetTimeout(resolve, 0)); } // Stand-in for the pipeline's async API. The element reads // $richTextTransformsVersion inside its render effect, so bumping it // re-fires the effect. - function makePluginService({ result = null } = {}) { + function makePluginService({ + result = null, + claimedFacetTypes = new Set(), + } = {}) { return { $richTextTransformsVersion: new Signal.State(0), calls: [], result, + claimedFacetTypes, + getClaimedFacetTypes() { + return this.claimedFacetTypes; + }, async transformRichTextTokens(tokens, context) { this.calls.push({ tokens, context }); return this.result; @@ -206,6 +221,96 @@ describe("plugin-rich-text", () => { assert.deepEqual(pluginService.calls.length, 1); }); + describe("facet placeholders for claimed types", () => { + const claimedType = "blue.moji.richtext.facet"; + function makeClaimedFacetsPost() { + const shortcode = ":blobcat:"; + const text = `hi ${shortcode}`; + const start = text.indexOf(shortcode); + const facets = [ + { + index: { byteStart: start, byteEnd: start + shortcode.length }, + features: [{ $type: claimedType, did: "did:test", name: "blobcat" }], + }, + ]; + return { text, facets }; + } + + it("hides claimed facet tokens while the transform is pending", () => { + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + }); + const { text, facets } = makeClaimedFacetsPost(); + const element = mount({ pluginService, text, facets }); + const placeholder = element.querySelector(".rich-text-facet-pending"); + assert(placeholder !== null); + assert.deepEqual(placeholder.textContent, ":blobcat:"); + }); + + it("swaps in the transformed rendering when the request resolves", async () => { + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + result: [ + { type: "text", value: "hi " }, + { + type: "inline", + pluginId: "p1", + node: { tag: "img", text: "" }, + }, + ], + }); + const { text, facets } = makeClaimedFacetsPost(); + const element = mount({ pluginService, text, facets }); + await flushEffects(); + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); + assert(element.querySelector("img") !== null); + }); + + it("falls back to the plaintext shortcode after the placeholder timeout", async () => { + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + }); + pluginService.transformRichTextTokens = () => new Promise(() => {}); + const { text, facets } = makeClaimedFacetsPost(); + const element = mount({ pluginService, text, facets }); + assert(element.querySelector(".rich-text-facet-pending") !== null); + await flushEffects(); + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); + const richText = element.querySelector("[data-testid='rich-text']"); + assert.deepEqual(richText.textContent, "hi :blobcat:"); + }); + + it("falls back when a pending transform resolves null", async () => { + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + result: null, + }); + const { text, facets } = makeClaimedFacetsPost(); + const element = mount({ pluginService, text, facets }); + await flushEffects(); + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); + const richText = element.querySelector("[data-testid='rich-text']"); + assert.deepEqual(richText.textContent, "hi :blobcat:"); + }); + + it("does not hide facets whose feature $type is not claimed", () => { + const pluginService = makePluginService({ + claimedFacetTypes: new Set([claimedType]), + }); + const url = "https://example.com"; + const text = `see ${url}`; + const facets = [ + { + index: { byteStart: 4, byteEnd: 4 + url.length }, + features: [{ $type: "app.bsky.richtext.facet#link", uri: url }], + }, + ]; + const element = mount({ pluginService, text, facets }); + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); + assert(element.querySelector("a") !== null); + }); + }); + it("stops rendering after disconnect and resumes with the latest text on reconnect", async () => { const pluginService = makePluginService({ result: null }); const element = mount({ pluginService }); diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index f20d382b..c725236f 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1148,6 +1148,55 @@ describe("getFilteredFeedItems", () => { }); }); +describe("getClaimedFacetTypes", () => { + function makeServiceWithRealBridge() { + const { provider } = makeProvider(); + return new PluginService(provider, null); + } + function registerTransform(service, pluginId, message) { + const handler = + service.pluginBridge._registrationTargets.get("richTextTransform"); + return handler({ pluginId, call: () => {} }, message); + } + + it("is empty when no transforms are registered", () => { + const service = makeServiceWithRealBridge(); + assert.deepEqual([...service.getClaimedFacetTypes()], []); + }); + + it("unions handlesFacetTypes across registered transforms", () => { + const service = makeServiceWithRealBridge(); + registerTransform(service, "alpha", { + handlerId: 1, + handlesFacetTypes: ["blue.moji.richtext.facet", "dev.impro.foo"], + }); + registerTransform(service, "beta", { + handlerId: 2, + handlesFacetTypes: ["dev.impro.foo"], + }); + assert.deepEqual([...service.getClaimedFacetTypes()].sort(), [ + "blue.moji.richtext.facet", + "dev.impro.foo", + ]); + }); + + it("drops entries when a transform unregisters", () => { + const service = makeServiceWithRealBridge(); + const dispose = registerTransform(service, "alpha", { + handlerId: 1, + handlesFacetTypes: ["blue.moji.richtext.facet"], + }); + dispose(); + assert.deepEqual([...service.getClaimedFacetTypes()], []); + }); + + it("tolerates a transform registered without handlesFacetTypes", () => { + const service = makeServiceWithRealBridge(); + registerTransform(service, "alpha", { handlerId: 1 }); + assert.deepEqual([...service.getClaimedFacetTypes()], []); + }); +}); + describe("slot registry", () => { // These tests exercise the registration target wired by _setupRegistries, // so they need the real PluginBridge instead of the makeService stub. diff --git a/tests/unit/specs/templates/largePost.template.test.js b/tests/unit/specs/templates/largePost.template.test.js index 22dafdda..445e645e 100644 --- a/tests/unit/specs/templates/largePost.template.test.js +++ b/tests/unit/specs/templates/largePost.template.test.js @@ -23,6 +23,7 @@ const pluginService = { $richTextTransformsVersion: { get: () => 0 }, transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, + getClaimedFacetTypes: () => new Set(), }; const baseProps = { diff --git a/tests/unit/specs/templates/postEmbed.template.test.js b/tests/unit/specs/templates/postEmbed.template.test.js index c622eb6b..be40d5a7 100644 --- a/tests/unit/specs/templates/postEmbed.template.test.js +++ b/tests/unit/specs/templates/postEmbed.template.test.js @@ -11,6 +11,7 @@ const pluginService = { $richTextTransformsVersion: { get: () => 0 }, transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, + getClaimedFacetTypes: () => new Set(), }; describe("postEmbedTemplate - images", () => { diff --git a/tests/unit/specs/templates/smallPost.template.test.js b/tests/unit/specs/templates/smallPost.template.test.js index bc303355..3b0d672f 100644 --- a/tests/unit/specs/templates/smallPost.template.test.js +++ b/tests/unit/specs/templates/smallPost.template.test.js @@ -23,6 +23,7 @@ const pluginService = { $richTextTransformsVersion: { get: () => 0 }, transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, + getClaimedFacetTypes: () => new Set(), }; const baseProps = { -- 2.51.2