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 preview mode for plugins

Grace Kind (Jul 15, 2026, 11:22 PM -0500) dd930205 b0668b26

+262 -18
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.174", 3 + "version": "0.17.175", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+118
src/css/style.css
··· 10014 10014 .subscribe-list-button { 10015 10015 margin-right: 12px; 10016 10016 } 10017 + 10018 + .plugin-preview-banner { 10019 + position: fixed; 10020 + left: 0; 10021 + right: 0; 10022 + bottom: calc(var(--footer-height) + var(--safe-area-inset-bottom)); 10023 + z-index: calc(var(--footer-z-index) + 1); 10024 + display: flex; 10025 + align-items: center; 10026 + gap: 12px; 10027 + padding: 12px 16px; 10028 + border-top: 1px solid 10029 + color-mix(in srgb, var(--highlight-color) 35%, transparent); 10030 + background: var(--highlight-color-tint); 10031 + } 10032 + 10033 + @media (min-width: 1200px) { 10034 + .plugin-preview-banner { 10035 + right: 24px; 10036 + left: auto; 10037 + bottom: 24px; 10038 + max-width: min(360px, calc(100vw - 48px)); 10039 + padding: 12px 14px; 10040 + display: grid; 10041 + grid-template-columns: auto 1fr; 10042 + grid-template-areas: 10043 + "icon body" 10044 + ". button"; 10045 + align-items: center; 10046 + gap: 8px 12px; 10047 + border: 1px solid 10048 + color-mix(in srgb, var(--highlight-color) 35%, transparent); 10049 + border-radius: var(--card-border-radius); 10050 + box-shadow: 0 4px 16px rgb(0 0 0 / 15%); 10051 + } 10052 + 10053 + .plugin-preview-banner-icon { 10054 + grid-area: icon; 10055 + } 10056 + 10057 + .plugin-preview-banner-body { 10058 + grid-area: body; 10059 + } 10060 + 10061 + .plugin-preview-banner-exit { 10062 + grid-area: button; 10063 + justify-self: end; 10064 + } 10065 + } 10066 + 10067 + .plugin-preview-banner-icon { 10068 + display: flex; 10069 + align-items: center; 10070 + justify-content: center; 10071 + flex: 0 0 32px; 10072 + height: 32px; 10073 + width: 32px; 10074 + border-radius: 999px; 10075 + background: color-mix(in srgb, var(--highlight-color) 20%, transparent); 10076 + color: var(--highlight-color); 10077 + } 10078 + 10079 + .plugin-preview-banner-icon .icon { 10080 + width: 18px; 10081 + height: 18px; 10082 + } 10083 + 10084 + .plugin-preview-banner-body { 10085 + flex: 1; 10086 + min-width: 0; 10087 + display: flex; 10088 + flex-direction: column; 10089 + gap: 2px; 10090 + } 10091 + 10092 + .plugin-preview-banner-title { 10093 + font-weight: 600; 10094 + font-size: 14px; 10095 + color: var(--text-color); 10096 + letter-spacing: 0.01em; 10097 + } 10098 + 10099 + .plugin-preview-banner-subtitle { 10100 + font-size: 13px; 10101 + color: var(--text-color-muted); 10102 + line-height: 1.4; 10103 + } 10104 + 10105 + .plugin-preview-banner-link { 10106 + color: var(--highlight-color); 10107 + font-weight: 500; 10108 + text-decoration: none; 10109 + } 10110 + 10111 + .plugin-preview-banner-exit { 10112 + flex: 0 0 auto; 10113 + padding: 8px 14px; 10114 + border: 1px solid color-mix(in srgb, var(--highlight-color) 35%, transparent); 10115 + border-radius: 999px; 10116 + background: transparent; 10117 + color: var(--highlight-color); 10118 + font-size: 13px; 10119 + font-weight: 600; 10120 + white-space: nowrap; 10121 + cursor: pointer; 10122 + } 10123 + 10124 + @media (hover: hover) { 10125 + .plugin-preview-banner-exit:hover { 10126 + background: color-mix(in srgb, var(--highlight-color) 12%, transparent); 10127 + } 10128 + } 10129 + 10130 + @media (hover: hover) { 10131 + .plugin-preview-banner-link:hover { 10132 + text-decoration: underline; 10133 + } 10134 + }
+3 -6
src/js/dataLayer/preferencesProvider.js
··· 28 28 } 29 29 30 30 async updatePreferences(preferences) { 31 - await this.api.updatePreferences(preferences.obj); 32 - this._setPreferences(preferences); 33 - } 34 - 35 - async savePreferences(preferences) { 36 - await this.api.updatePreferences(preferences.obj); 31 + if (preferences.persist) { 32 + await this.api.updatePreferences(preferences.obj); 33 + } 37 34 this._setPreferences(preferences); 38 35 } 39 36
+57
src/js/mainLayout.js
··· 3 3 import { auth } from "/js/auth.js"; 4 4 import { sidebarTemplate } from "/js/templates/sidebar.template.js"; 5 5 import { footerTemplate } from "/js/templates/footer.template.js"; 6 + import { eyeIconTemplate } from "/js/templates/icons/eyeIcon.template.js"; 7 + import { 8 + getPluginPreviewIdsFromQueryParam, 9 + PLUGIN_PREVIEW_QUERY_PARAM, 10 + } from "/js/plugins/pluginService.js"; 11 + 12 + function exitPluginPreview() { 13 + const url = new URL(window.location.href); 14 + url.searchParams.delete(PLUGIN_PREVIEW_QUERY_PARAM); 15 + window.location.assign(url.toString()); 16 + } 17 + 18 + function pluginPreviewBannerTemplate({ plugins }) { 19 + if (plugins.length === 0) return null; 20 + const links = []; 21 + plugins.forEach((plugin, index) => { 22 + if (index > 0) links.push(", "); 23 + links.push( 24 + html`<a 25 + class="plugin-preview-banner-link" 26 + href="/plugins/community/${encodeURIComponent(plugin.id)}" 27 + >${plugin.name}</a 28 + >`, 29 + ); 30 + }); 31 + return html` 32 + <div class="plugin-preview-banner" data-testid="plugin-preview-banner"> 33 + <div class="plugin-preview-banner-icon">${eyeIconTemplate()}</div> 34 + <div class="plugin-preview-banner-body"> 35 + <div class="plugin-preview-banner-title">Plugin preview mode</div> 36 + <div class="plugin-preview-banner-subtitle"> 37 + You are currently previewing ${links}. Changes you make to the page 38 + won't be saved. 39 + </div> 40 + </div> 41 + <button 42 + class="plugin-preview-banner-exit" 43 + data-testid="plugin-preview-banner-exit" 44 + @click=${exitPluginPreview} 45 + > 46 + Exit preview 47 + </button> 48 + </div> 49 + `; 50 + } 6 51 import { Layout } from "/js/router.js"; 7 52 import "/js/components/animated-sidebar.js"; 8 53 ··· 16 61 children, 17 62 onClickComposeButton, 18 63 pluginService, 64 + previewingPlugins = [], 19 65 onLongPressProfile = null, 20 66 groupChatLinkService, 21 67 }) { ··· 46 92 </div> 47 93 <div class="view-column-right"></div> 48 94 </div> 95 + ${pluginPreviewBannerTemplate({ plugins: previewingPlugins })} 49 96 ${footerTemplate({ 50 97 isAuthenticated, 51 98 currentUser, ··· 105 152 } 106 153 }; 107 154 155 + const previewIds = getPluginPreviewIdsFromQueryParam(); 108 156 this.#disposeEffect = effect(() => { 109 157 const currentRoute = router.$currentRoute.get(); 110 158 const layoutOptions = currentRoute?.options?.layoutOptions ?? {}; ··· 113 161 typeof layoutOptions.activeNavItem === "function" 114 162 ? layoutOptions.activeNavItem(currentRoute.params) 115 163 : (layoutOptions.activeNavItem ?? null); 164 + const previewingPlugins = 165 + previewIds.length > 0 166 + ? pluginService.$pluginsInfo 167 + .get() 168 + .filter( 169 + (plugin) => previewIds.includes(plugin.id) && plugin.loaded, 170 + ) 171 + : []; 116 172 render( 117 173 mainLayoutTemplate({ 118 174 isAuthenticated, ··· 127 183 onClickComposeButton: () => 128 184 postComposerService.composePost({ currentUser }), 129 185 pluginService, 186 + previewingPlugins, 130 187 onLongPressProfile, 131 188 groupChatLinkService, 132 189 }),
+3 -3
src/js/plugins/pluginPreferencesManager.js
··· 22 22 const preferences = this.preferencesProvider 23 23 .requirePreferences() 24 24 .setInstalledPlugins(plugins); 25 - await this.preferencesProvider.savePreferences(preferences); 25 + await this.preferencesProvider.updatePreferences(preferences); 26 26 } 27 27 28 28 async addInstalledPlugin(plugin) { ··· 91 91 const preferences = this.preferencesProvider 92 92 .requirePreferences() 93 93 .setPluginSettings(pluginId, data); 94 - await this.preferencesProvider.savePreferences(preferences); 94 + await this.preferencesProvider.updatePreferences(preferences); 95 95 } 96 96 97 97 async clearSettingsForPlugin(pluginId) { 98 98 const preferences = this.preferencesProvider 99 99 .requirePreferences() 100 100 .clearPluginSettings(pluginId); 101 - await this.preferencesProvider.savePreferences(preferences); 101 + await this.preferencesProvider.updatePreferences(preferences); 102 102 } 103 103 }
+66
src/js/plugins/pluginService.js
··· 32 32 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 33 33 34 34 const DISABLE_PLUGINS_QUERY_PARAM = "disable-plugins"; 35 + export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; 35 36 36 37 export function arePluginsDisabledByQueryParam() { 37 38 const params = new URLSearchParams(window.location.search); 38 39 return params.has(DISABLE_PLUGINS_QUERY_PARAM); 40 + } 41 + 42 + export function getPluginPreviewIdsFromQueryParam() { 43 + const params = new URLSearchParams(window.location.search); 44 + const values = params.getAll(PLUGIN_PREVIEW_QUERY_PARAM); 45 + const ids = values 46 + .flatMap((value) => value.split(",")) 47 + .map((id) => id.trim()) 48 + .filter(Boolean); 49 + return [...new Set(ids)]; 39 50 } 40 51 41 52 export function parseGithubRepoUrl(input) { ··· 379 390 await this.prefManager.setPluginsDisabled(enabledPluginIds); 380 391 return; 381 392 } 393 + const previewPluginIds = getPluginPreviewIdsFromQueryParam(); 394 + if (previewPluginIds.length > 0 && !this.session) { 395 + // Serial to avoid racing on preferences 396 + for (const previewPluginId of previewPluginIds) { 397 + await this._installPreviewPlugin(previewPluginId); 398 + } 399 + } 382 400 const enabledPlugins = this.prefManager.$enabledPlugins 383 401 .get() 384 402 .filter( ··· 403 421 // plugins keep their cached assets on re-enable 404 422 const installedPlugins = this.prefManager.$installedPlugins.get(); 405 423 await this._reconcileCache(installedPlugins); 424 + } 425 + 426 + async _installPreviewPlugin(pluginId) { 427 + const listing = 428 + (await this.remoteRegistry.getListing(pluginId).catch(() => null)) ?? 429 + (this.localRegistry 430 + ? await this.localRegistry.getListing(pluginId).catch(() => null) 431 + : null); 432 + if (!listing) { 433 + showToast(`Plugin "${pluginId}" not found`, { 434 + style: "error", 435 + timeout: 5000, 436 + }); 437 + return; 438 + } 439 + let manifest = null; 440 + try { 441 + manifest = await this.sourceProvider.getLiveManifest( 442 + pluginId, 443 + listing.repo, 444 + ); 445 + } catch (e) { 446 + console.error("Failed to fetch manifest for preview", e); 447 + showToast(`Failed to load plugin "${pluginId}"`, { 448 + style: "error", 449 + timeout: 5000, 450 + }); 451 + return; 452 + } 453 + const permissions = getPermissionsFromManifest(manifest); 454 + if (!isEmptyPermissions(permissions)) { 455 + showToast( 456 + `"${manifest.name}" can't be previewed because it requires additional permissions.`, 457 + { style: "error", timeout: 5000 }, 458 + ); 459 + return; 460 + } 461 + const { name, version, author, description } = manifest; 462 + await this.prefManager.addInstalledPlugin({ 463 + id: pluginId, 464 + name, 465 + version, 466 + author, 467 + description, 468 + repo: listing.repo, 469 + enabled: true, 470 + permissions, 471 + }); 406 472 } 407 473 408 474 async checkForUpdates() {
+6 -2
src/js/preferences.js
··· 91 91 } 92 92 93 93 export class Preferences { 94 - constructor(obj, labelerDefs) { 94 + constructor(obj, labelerDefs, { persist = true } = {}) { 95 95 this.obj = obj; 96 96 this.labelerDefs = labelerDefs; 97 + this.persist = persist; 97 98 } 98 99 99 100 // Note, these methods return a new Preferences object, instead of mutating the existing one. ··· 488 489 } 489 490 490 491 clone() { 491 - return new Preferences(deepClone(this.obj), deepClone(this.labelerDefs)); 492 + return new Preferences(deepClone(this.obj), deepClone(this.labelerDefs), { 493 + persist: this.persist, 494 + }); 492 495 } 493 496 494 497 getPluginSettings(pluginId) { ··· 639 642 }, 640 643 ], 641 644 [], 645 + { persist: false }, 642 646 ); 643 647 } 644 648 }
+2 -2
tests/unit/specs/plugins/pluginPreferencesManager.test.js
··· 50 50 provider: { 51 51 $preferences, 52 52 requirePreferences: () => preferences, 53 - savePreferences: async (prefs) => { 53 + updatePreferences: async (prefs) => { 54 54 saveCalls.push(prefs); 55 55 $preferences.set(prefs); 56 56 }, ··· 69 69 ]); 70 70 }); 71 71 72 - it("setInstalledPlugins persists via savePreferences", async () => { 72 + it("setInstalledPlugins persists via updatePreferences", async () => { 73 73 const { provider, saveCalls, state } = makeProvider(); 74 74 const manager = new PluginPreferencesManager(provider); 75 75 await manager.setInstalledPlugins([{ id: "a", enabled: true }]);
+6 -4
tests/unit/specs/plugins/pluginService.test.js
··· 41 41 provider: { 42 42 $preferences, 43 43 requirePreferences: () => preferences, 44 - savePreferences: async (saved) => { 44 + updatePreferences: async (saved) => { 45 45 $preferences.set(saved); 46 46 }, 47 47 }, ··· 529 529 }; 530 530 let saveCalls = 0; 531 531 const originalSave = 532 - service.prefManager.preferencesProvider.savePreferences; 533 - service.prefManager.preferencesProvider.savePreferences = async (prefs) => { 532 + service.prefManager.preferencesProvider.updatePreferences; 533 + service.prefManager.preferencesProvider.updatePreferences = async ( 534 + prefs, 535 + ) => { 534 536 saveCalls++; 535 537 return originalSave(prefs); 536 538 }; ··· 560 562 return { loadedPlugins: entries, erroredPlugins: [] }; 561 563 }; 562 564 let saveCalls = 0; 563 - service.prefManager.preferencesProvider.savePreferences = async () => { 565 + service.prefManager.preferencesProvider.updatePreferences = async () => { 564 566 saveCalls++; 565 567 }; 566 568 window.history.replaceState({}, "", "http://localhost/?disable-plugins");