Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Add claimed facet types

Grace Kind (Jul 16, 2026, 12:27 AM -0500) 5fb83952 025c920d

+284 -16
+8 -1
impro-plugin/main.js
··· 316 316 // callback(tokens, context) receives the rich-text token stream for one 317 317 // post and returns a new token array (or the input unchanged). The host 318 318 // batches all posts of a render into one call per plugin. 319 - registerRichTextTransform(callback = (tokens) => tokens) { 319 + // 320 + // options.handlesFacetTypes: array of facet feature $type strings this 321 + // transform owns, to prevent render flash of fallback text 322 + registerRichTextTransform(callback = (tokens) => tokens, options = {}) { 320 323 const handlerId = uuid.create(); 321 324 callHandlers.set(handlerId, async (batch) => { 322 325 const results = []; ··· 330 333 } 331 334 return results; 332 335 }); 336 + const handlesFacetTypes = Array.isArray(options.handlesFacetTypes) 337 + ? options.handlesFacetTypes.filter((type) => typeof type === "string") 338 + : []; 333 339 self.postMessage({ 334 340 type: "register", 335 341 target: "richTextTransform", 336 342 handlerId, 343 + handlesFacetTypes, 337 344 }); 338 345 } 339 346
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.10", 3 + "version": "0.0.11", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.176", 3 + "version": "0.17.177", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+4
src/css/style.css
··· 383 383 white-space: normal; 384 384 } 385 385 386 + .rich-text-facet-pending { 387 + display: none; 388 + } 389 + 386 390 @media (hover: hover) { 387 391 .rich-text a:hover { 388 392 text-decoration: underline;
+65 -8
src/js/components/plugin-rich-text.js
··· 2 2 import { Component } from "/js/components/component.js"; 3 3 import { Signal, effect } from "/js/signals.js"; 4 4 import { shallowEquals } from "/js/utils.js"; 5 - import { tokenizeRichText } from "/js/richTextHelpers.js"; 5 + import { tokenizeRichText, tokensHaveFacetType } from "/js/richTextHelpers.js"; 6 6 import { richTextTokensTemplate } from "/js/templates/richText.template.js"; 7 7 8 8 class PluginRichText extends Component { 9 + static placeholderFallbackMs = 500; 10 + 9 11 static get observedAttributes() { 10 12 return ["truncate-urls"]; 11 13 } ··· 52 54 this.disposeRender?.(); 53 55 this.disposeRender = null; 54 56 this._currentRequest = null; 57 + if (this._claimedFacetFallbackTimer) { 58 + clearTimeout(this._claimedFacetFallbackTimer); 59 + this._claimedFacetFallbackTimer = null; 60 + } 55 61 this.initialized = false; 56 62 } 57 63 ··· 63 69 const pluginService = this.pluginService; 64 70 const baseTokens = tokenizeRichText({ text, facets }); 65 71 66 - // Render base token immediately 67 - this._renderTokens(baseTokens, truncateUrls); 68 - 69 72 // Subscribe to the version signal so registering/unregistering a transform will re-render 70 73 pluginService.$richTextTransformsVersion.get(); 71 74 75 + if (this._claimedFacetFallbackTimer) { 76 + clearTimeout(this._claimedFacetFallbackTimer); 77 + this._claimedFacetFallbackTimer = null; 78 + } 79 + 72 80 // Request transformed tokens from pluginService 73 81 if (!transformContext) { 74 82 this._currentRequest = null; 83 + this._renderTokens({ 84 + tokens: baseTokens, 85 + truncateUrls, 86 + placeholderFacetTypes: null, 87 + }); 75 88 return; 76 89 } 77 90 const request = pluginService.transformRichTextTokens(baseTokens, { ··· 80 93 source: { text, facets }, 81 94 }); 82 95 this._currentRequest = request; 96 + 97 + // Hide facet tokens whose feature $type is claimed by a pending transform, 98 + // so shortcodes don't briefly flash as plaintext before the transform swaps 99 + // them for their rendered form (e.g. bluemoji). 100 + const claimedFacetTypes = pluginService.getClaimedFacetTypes(); 101 + this._renderTokens({ 102 + tokens: baseTokens, 103 + truncateUrls, 104 + placeholderFacetTypes: claimedFacetTypes, 105 + }); 106 + 107 + // Fallback + null-resolve re-render only matter when something was hidden. 108 + const hasClaimedFacets = tokensHaveFacetType(baseTokens, claimedFacetTypes); 109 + 110 + if (hasClaimedFacets) { 111 + this._claimedFacetFallbackTimer = setTimeout(() => { 112 + this._claimedFacetFallbackTimer = null; 113 + if (this._currentRequest !== request || !this.initialized) return; 114 + this._renderTokens({ 115 + tokens: baseTokens, 116 + truncateUrls, 117 + placeholderFacetTypes: null, 118 + }); 119 + }, PluginRichText.placeholderFallbackMs); 120 + } 121 + 83 122 request.then( 84 123 (transformed) => { 85 124 if ( 86 125 // Ignore results that resolved after a newer render pass 87 126 this._currentRequest !== request || 88 - !this.initialized || 89 - !transformed 127 + !this.initialized 90 128 ) 91 129 return; 92 - this._renderTokens(transformed, truncateUrls); 130 + if (this._claimedFacetFallbackTimer) { 131 + clearTimeout(this._claimedFacetFallbackTimer); 132 + this._claimedFacetFallbackTimer = null; 133 + } 134 + if (!transformed) { 135 + if (hasClaimedFacets) { 136 + this._renderTokens({ 137 + tokens: baseTokens, 138 + truncateUrls, 139 + placeholderFacetTypes: null, 140 + }); 141 + } 142 + return; 143 + } 144 + this._renderTokens({ 145 + tokens: transformed, 146 + truncateUrls, 147 + placeholderFacetTypes: null, 148 + }); 93 149 }, 94 150 (error) => { 95 151 console.error("Rich text transform request failed", error); ··· 97 153 ); 98 154 } 99 155 100 - _renderTokens(tokens, truncateUrls) { 156 + _renderTokens({ tokens, truncateUrls, placeholderFacetTypes }) { 101 157 render( 102 158 richTextTokensTemplate({ 103 159 tokens, 104 160 truncateUrls, 161 + placeholderFacetTypes, 105 162 renderNodeToken: (token) => 106 163 this.pluginService.renderRichTextNodeToken(token, this), 107 164 }),
+12
src/js/plugins/pluginService.js
··· 248 248 (plugin, message) => { 249 249 const entry = { 250 250 pluginId: plugin.pluginId, 251 + handlesFacetTypes: Array.isArray(message.handlesFacetTypes) 252 + ? message.handlesFacetTypes 253 + : [], 251 254 invoke: (batch) => plugin.call(message.handlerId, batch), 252 255 }; 253 256 this.registries.richTextTransforms.add(entry); ··· 909 912 this.$richTextTransformsVersion.set( 910 913 this.$richTextTransformsVersion.get() + 1, 911 914 ); 915 + } 916 + 917 + getClaimedFacetTypes() { 918 + const types = new Set(); 919 + for (const entry of this.registries.richTextTransforms) { 920 + if (!entry.handlesFacetTypes) continue; 921 + for (const type of entry.handlesFacetTypes) types.add(type); 922 + } 923 + return types; 912 924 } 913 925 914 926 // Results are cached by (uri, surface); requests are batched per render flush
+13
src/js/richTextHelpers.js
··· 46 46 return tokens; 47 47 } 48 48 49 + export function tokensHaveFacetType(tokens, facetTypes) { 50 + if (!facetTypes || facetTypes.size === 0) return false; 51 + for (const token of tokens) { 52 + if (token.type !== "facet") continue; 53 + const features = token.facet.features; 54 + if (!features) continue; 55 + for (const feature of features) { 56 + if (facetTypes.has(feature.$type)) return true; 57 + } 58 + } 59 + return false; 60 + } 61 + 49 62 export function validateRichTextTokens(tokens) { 50 63 if (!Array.isArray(tokens)) return false; 51 64 return tokens.every((token) => {
+19 -1
src/js/templates/richText.template.js
··· 57 57 } 58 58 59 59 // tokens: ({ type: "text" } / { type: "facet" } / { type: "inline" } / { type: "block" }) 60 + // placeholderFacetTypes: Set of facet feature $types to render as 61 + // invisible-but-space-preserving spans (used while a rich-text transform 62 + // that claims the type is still pending, to avoid flashing plaintext). 60 63 export function richTextTokensTemplate({ 61 64 tokens, 62 65 truncateUrls = false, 63 66 renderNodeToken = () => null, 67 + placeholderFacetTypes = null, 64 68 }) { 65 69 const parts = []; 66 70 tokens.forEach((token, index) => { ··· 78 82 parts.push(value); 79 83 break; 80 84 } 81 - case "facet": 85 + case "facet": { 86 + if ( 87 + placeholderFacetTypes && 88 + token.facet.features?.some((feature) => 89 + placeholderFacetTypes.has(feature.$type), 90 + ) 91 + ) { 92 + parts.push( 93 + html`<span class="rich-text-facet-pending" aria-hidden="true" 94 + >${token.text}</span 95 + >`, 96 + ); 97 + break; 98 + } 82 99 parts.push( 83 100 facetTemplate({ 84 101 facet: token.facet, ··· 87 104 }), 88 105 ); 89 106 break; 107 + } 90 108 case "inline": 91 109 case "block": { 92 110 const element = renderNodeToken(token) ?? null;
+109 -4
tests/unit/specs/components/plugin-rich-text.test.js
··· 1 - import { describe, it, beforeEach } from "node:test"; 1 + import { describe, it, beforeEach, afterEach } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { Signal } from "/js/signals.js"; 4 4 import "/js/components/plugin-rich-text.js"; 5 5 6 6 describe("plugin-rich-text", () => { 7 + const originalSetTimeout = globalThis.setTimeout; 8 + beforeEach(() => { 9 + globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0); 10 + }); 11 + afterEach(() => { 12 + globalThis.setTimeout = originalSetTimeout; 13 + }); 14 + 7 15 async function flushEffects() { 8 16 // Two ticks: signal changes re-run effects via rAF, which the test env 9 17 // pins to setTimeout. 10 - await new Promise((resolve) => setTimeout(resolve, 0)); 11 - await new Promise((resolve) => setTimeout(resolve, 0)); 18 + await new Promise((resolve) => originalSetTimeout(resolve, 0)); 19 + await new Promise((resolve) => originalSetTimeout(resolve, 0)); 12 20 } 13 21 14 22 // Stand-in for the pipeline's async API. The element reads 15 23 // $richTextTransformsVersion inside its render effect, so bumping it 16 24 // re-fires the effect. 17 - function makePluginService({ result = null } = {}) { 25 + function makePluginService({ 26 + result = null, 27 + claimedFacetTypes = new Set(), 28 + } = {}) { 18 29 return { 19 30 $richTextTransformsVersion: new Signal.State(0), 20 31 calls: [], 21 32 result, 33 + claimedFacetTypes, 34 + getClaimedFacetTypes() { 35 + return this.claimedFacetTypes; 36 + }, 22 37 async transformRichTextTokens(tokens, context) { 23 38 this.calls.push({ tokens, context }); 24 39 return this.result; ··· 204 219 await flushEffects(); 205 220 206 221 assert.deepEqual(pluginService.calls.length, 1); 222 + }); 223 + 224 + describe("facet placeholders for claimed types", () => { 225 + const claimedType = "blue.moji.richtext.facet"; 226 + function makeClaimedFacetsPost() { 227 + const shortcode = ":blobcat:"; 228 + const text = `hi ${shortcode}`; 229 + const start = text.indexOf(shortcode); 230 + const facets = [ 231 + { 232 + index: { byteStart: start, byteEnd: start + shortcode.length }, 233 + features: [{ $type: claimedType, did: "did:test", name: "blobcat" }], 234 + }, 235 + ]; 236 + return { text, facets }; 237 + } 238 + 239 + it("hides claimed facet tokens while the transform is pending", () => { 240 + const pluginService = makePluginService({ 241 + claimedFacetTypes: new Set([claimedType]), 242 + }); 243 + const { text, facets } = makeClaimedFacetsPost(); 244 + const element = mount({ pluginService, text, facets }); 245 + const placeholder = element.querySelector(".rich-text-facet-pending"); 246 + assert(placeholder !== null); 247 + assert.deepEqual(placeholder.textContent, ":blobcat:"); 248 + }); 249 + 250 + it("swaps in the transformed rendering when the request resolves", async () => { 251 + const pluginService = makePluginService({ 252 + claimedFacetTypes: new Set([claimedType]), 253 + result: [ 254 + { type: "text", value: "hi " }, 255 + { 256 + type: "inline", 257 + pluginId: "p1", 258 + node: { tag: "img", text: "" }, 259 + }, 260 + ], 261 + }); 262 + const { text, facets } = makeClaimedFacetsPost(); 263 + const element = mount({ pluginService, text, facets }); 264 + await flushEffects(); 265 + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); 266 + assert(element.querySelector("img") !== null); 267 + }); 268 + 269 + it("falls back to the plaintext shortcode after the placeholder timeout", async () => { 270 + const pluginService = makePluginService({ 271 + claimedFacetTypes: new Set([claimedType]), 272 + }); 273 + pluginService.transformRichTextTokens = () => new Promise(() => {}); 274 + const { text, facets } = makeClaimedFacetsPost(); 275 + const element = mount({ pluginService, text, facets }); 276 + assert(element.querySelector(".rich-text-facet-pending") !== null); 277 + await flushEffects(); 278 + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); 279 + const richText = element.querySelector("[data-testid='rich-text']"); 280 + assert.deepEqual(richText.textContent, "hi :blobcat:"); 281 + }); 282 + 283 + it("falls back when a pending transform resolves null", async () => { 284 + const pluginService = makePluginService({ 285 + claimedFacetTypes: new Set([claimedType]), 286 + result: null, 287 + }); 288 + const { text, facets } = makeClaimedFacetsPost(); 289 + const element = mount({ pluginService, text, facets }); 290 + await flushEffects(); 291 + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); 292 + const richText = element.querySelector("[data-testid='rich-text']"); 293 + assert.deepEqual(richText.textContent, "hi :blobcat:"); 294 + }); 295 + 296 + it("does not hide facets whose feature $type is not claimed", () => { 297 + const pluginService = makePluginService({ 298 + claimedFacetTypes: new Set([claimedType]), 299 + }); 300 + const url = "https://example.com"; 301 + const text = `see ${url}`; 302 + const facets = [ 303 + { 304 + index: { byteStart: 4, byteEnd: 4 + url.length }, 305 + features: [{ $type: "app.bsky.richtext.facet#link", uri: url }], 306 + }, 307 + ]; 308 + const element = mount({ pluginService, text, facets }); 309 + assert.deepEqual(element.querySelector(".rich-text-facet-pending"), null); 310 + assert(element.querySelector("a") !== null); 311 + }); 207 312 }); 208 313 209 314 it("stops rendering after disconnect and resumes with the latest text on reconnect", async () => {
+49
tests/unit/specs/plugins/pluginService.test.js
··· 1148 1148 }); 1149 1149 }); 1150 1150 1151 + describe("getClaimedFacetTypes", () => { 1152 + function makeServiceWithRealBridge() { 1153 + const { provider } = makeProvider(); 1154 + return new PluginService(provider, null); 1155 + } 1156 + function registerTransform(service, pluginId, message) { 1157 + const handler = 1158 + service.pluginBridge._registrationTargets.get("richTextTransform"); 1159 + return handler({ pluginId, call: () => {} }, message); 1160 + } 1161 + 1162 + it("is empty when no transforms are registered", () => { 1163 + const service = makeServiceWithRealBridge(); 1164 + assert.deepEqual([...service.getClaimedFacetTypes()], []); 1165 + }); 1166 + 1167 + it("unions handlesFacetTypes across registered transforms", () => { 1168 + const service = makeServiceWithRealBridge(); 1169 + registerTransform(service, "alpha", { 1170 + handlerId: 1, 1171 + handlesFacetTypes: ["blue.moji.richtext.facet", "dev.impro.foo"], 1172 + }); 1173 + registerTransform(service, "beta", { 1174 + handlerId: 2, 1175 + handlesFacetTypes: ["dev.impro.foo"], 1176 + }); 1177 + assert.deepEqual([...service.getClaimedFacetTypes()].sort(), [ 1178 + "blue.moji.richtext.facet", 1179 + "dev.impro.foo", 1180 + ]); 1181 + }); 1182 + 1183 + it("drops entries when a transform unregisters", () => { 1184 + const service = makeServiceWithRealBridge(); 1185 + const dispose = registerTransform(service, "alpha", { 1186 + handlerId: 1, 1187 + handlesFacetTypes: ["blue.moji.richtext.facet"], 1188 + }); 1189 + dispose(); 1190 + assert.deepEqual([...service.getClaimedFacetTypes()], []); 1191 + }); 1192 + 1193 + it("tolerates a transform registered without handlesFacetTypes", () => { 1194 + const service = makeServiceWithRealBridge(); 1195 + registerTransform(service, "alpha", { handlerId: 1 }); 1196 + assert.deepEqual([...service.getClaimedFacetTypes()], []); 1197 + }); 1198 + }); 1199 + 1151 1200 describe("slot registry", () => { 1152 1201 // These tests exercise the registration target wired by _setupRegistries, 1153 1202 // so they need the real PluginBridge instead of the makeService stub.
+1
tests/unit/specs/templates/largePost.template.test.js
··· 23 23 $richTextTransformsVersion: { get: () => 0 }, 24 24 transformRichTextTokens: async () => null, 25 25 renderRichTextNodeToken: () => null, 26 + getClaimedFacetTypes: () => new Set(), 26 27 }; 27 28 28 29 const baseProps = {
+1
tests/unit/specs/templates/postEmbed.template.test.js
··· 11 11 $richTextTransformsVersion: { get: () => 0 }, 12 12 transformRichTextTokens: async () => null, 13 13 renderRichTextNodeToken: () => null, 14 + getClaimedFacetTypes: () => new Set(), 14 15 }; 15 16 16 17 describe("postEmbedTemplate - images", () => {
+1
tests/unit/specs/templates/smallPost.template.test.js
··· 23 23 $richTextTransformsVersion: { get: () => 0 }, 24 24 transformRichTextTokens: async () => null, 25 25 renderRichTextNodeToken: () => null, 26 + getClaimedFacetTypes: () => new Set(), 26 27 }; 27 28 28 29 const baseProps = {