diff --git a/package.json b/package.json index 04c77369..1b31d19c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.174", + "version": "0.18.175", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/views/settings/notifications.view.js b/src/js/views/settings/notifications.view.js index 7f81c702..88799b20 100644 --- a/src/js/views/settings/notifications.view.js +++ b/src/js/views/settings/notifications.view.js @@ -63,7 +63,7 @@ class SettingsNotificationsView extends View { const isDenied = permissionState === "denied"; let description = - "Get notified when you have new activity while Impro is open in a tab or window."; + "Get notified of new activity while Impro is open in a tab or window."; if (!isSupported) { description = "Not supported on this device."; } else if (isDenied) { -- knotmirror 0.0.1 From ad2073f8025d7378333d1d7bf5525b3f49937b9a Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 16:30:30 -0500 Subject: [PATCH 02/25] Update skeleton styles --- package.json | 2 +- src/css/style.css | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1b31d19c..4722464f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.175", + "version": "0.18.176", "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 31c1547b..e2b6c029 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -767,7 +767,6 @@ input[type="color"]::-moz-color-swatch { width: var(--avatar-size-post); height: var(--avatar-size-post); border-radius: var(--avatar-border-radius); - background-color: var(--skeleton-color); } .skeleton-content { @@ -784,21 +783,18 @@ input[type="color"]::-moz-color-swatch { width: 100%; height: 10px; border-radius: var(--skeleton-border-radius); - background-color: var(--skeleton-color); } .skeleton-line-short { width: 10em; height: 12px; border-radius: var(--skeleton-border-radius); - background-color: var(--skeleton-color); } .skeleton-line-shorter { width: 7em; height: 12px; border-radius: var(--skeleton-border-radius); - background-color: var(--skeleton-color); } .skeleton-actions { @@ -821,8 +817,7 @@ input[type="color"]::-moz-color-swatch { .skeleton-action .skeleton-action-icon { width: 18px; height: 18px; - color: var(--post-action-icon-color); - opacity: 0.4; + color: var(--skeleton-color); display: flex; align-items: center; justify-content: center; @@ -2041,6 +2036,10 @@ container-link { gap: 6px; } +.no-unauthenticated-skeleton-text .skeleton-line { + background-color: var(--skeleton-color); +} + .no-unauthenticated-message-large { font-size: 18px; } @@ -6178,14 +6177,12 @@ button.profile-list-item-button.is-disabled { width: 30px; height: 30px; border-radius: var(--list-avatar-border-radius); - background-color: var(--skeleton-color); } .feeds-list-item-skeleton-title { height: 14px; width: 45%; border-radius: var(--skeleton-border-radius); - background-color: var(--skeleton-color); margin-bottom: 6px; margin-top: 2px; } @@ -6194,7 +6191,6 @@ button.profile-list-item-button.is-disabled { height: 12px; width: 30%; border-radius: var(--skeleton-border-radius); - background-color: var(--skeleton-color); } .post-composer.bottom-sheet { -- knotmirror 0.0.1 From 900159149043f9a5e33c387d35eba6559bd14965 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 16:38:16 -0500 Subject: [PATCH 03/25] Update readme links --- README.md | 4 +++- plugins.md | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9493ff7d..a2496b8c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Impro is: - **Web-first**: Impro was built from scratch for the web, with no heavy cross-platform frameworks or libraries. - **Familiar by default:** Out of the box, Impro offers the same feature set as the main Bluesky client. -- **Extensible by design:** If you want additional functionality, you can use [Community Plugins](/plugins.md) to customize your experience. +- **Extensible by design:** If you want additional functionality, you can use [Community Plugins](https://impro.social/plugins/community) to customize your experience. - **Better about blocking**: Unlike the main client's "nuclear block", thread context is preserved for non-blocked users. ## Issues and feature requests @@ -34,6 +34,8 @@ The icons are from the [Majesticons](https://github.com/halfmage/majesticons) fr ## Development +_Interested in developing a plugin for Impro? See our [plugin development guide](/plugins.md)._ + Impro is a single-page app built with [Eleventy](https://www.11ty.dev/). To run the development server, run: ```bash diff --git a/plugins.md b/plugins.md index 5056eef9..5ecd0b00 100644 --- a/plugins.md +++ b/plugins.md @@ -24,6 +24,8 @@ To publish a plugin version, tag a commit with the version number (e.g. "0.1.0", ## API surface +Autogenerated documentation for the plugin SDK can be found [here](/impro-plugin/docs/docs.md). + Plugins are currently in **beta** as the API surface is being expanded. However, here are some basic guidelines about what plugins can do: ### Plugins CAN: -- knotmirror 0.0.1 From 85805030218ce8a739ff64ccf432cfd74fec6d9e Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 16:45:08 -0500 Subject: [PATCH 04/25] Allow authorization header for plugin requests --- package.json | 2 +- src/js/plugins/pluginRequests.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4722464f..cc613c0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.176", + "version": "0.18.177", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/plugins/pluginRequests.js b/src/js/plugins/pluginRequests.js index 208d7d17..fc83645e 100644 --- a/src/js/plugins/pluginRequests.js +++ b/src/js/plugins/pluginRequests.js @@ -2,7 +2,7 @@ import { isFetchAllowed } from "/js/plugins/pluginPermissions.js"; const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]; -const FORBIDDEN_HEADERS = ["authorization", "cookie"]; +const FORBIDDEN_HEADERS = ["cookie"]; const MAX_BODY_CHARS = 1_000_000; export const MAX_RESPONSE_BYTES = 100_000_000; -- knotmirror 0.0.1 From 8db381eda2d863c17fe9ac10dac0f06668dc5046 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 16:51:45 -0500 Subject: [PATCH 05/25] Support http for localhost plugin requests --- package.json | 2 +- src/js/plugins/pluginPermissions.js | 50 +++++++++++++++++-- .../specs/plugins/pluginPermissions.test.js | 48 ++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index cc613c0d..e5dd42a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.177", + "version": "0.18.178", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index 7c9b8e94..0b3ddbfd 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -62,7 +62,10 @@ export function isFetchAllowed(url, permissions) { } catch { return false; } - if (parsedUrl.protocol !== "https:") return false; + if (parsedUrl.protocol !== "https:") { + if (parsedUrl.protocol !== "http:") return false; + if (!isLoopbackHost(parsedUrl.hostname)) return false; + } return (permissions.fetch ?? []).some((pattern) => matchesPattern(parsedUrl, pattern), ); @@ -73,6 +76,8 @@ export function isFetchAllowed(url, permissions) { // https://example.com/path/* — exact host, path prefix // https://*.example.com/* — example.com and any subdomain // https://example.com/* — exact host, any path +// https://example.com:8443/* — exact port; without one, any port +// http://localhost:11434/* — http is loopback-only function matchesPattern(parsedUrl, pattern) { let parsedPattern = null; @@ -83,8 +88,10 @@ function matchesPattern(parsedUrl, pattern) { console.warn(`invalid permission: ${pattern}`); return false; } - const { host, path } = parsedPattern; + const { scheme, host, port, path } = parsedPattern; + if (scheme !== parsedUrl.protocol.slice(0, -1)) return false; if (!hostMatches(parsedUrl.hostname, host)) return false; + if (port !== null && port !== effectivePort(parsedUrl)) return false; if (!pathMatches(parsedUrl.pathname, path)) return false; return true; } @@ -94,15 +101,48 @@ function parsePattern(pattern) { const schemeSep = pattern.indexOf("://"); if (schemeSep === -1) throw new Error("no protocol found"); const scheme = pattern.slice(0, schemeSep); - if (scheme !== "https") throw new Error("https required"); + if (scheme !== "https" && scheme !== "http") { + throw new Error("https required"); + } const rest = pattern.slice(schemeSep + 3); const pathStart = rest.indexOf("/"); - const host = ( + const authority = ( pathStart === -1 ? rest : rest.slice(0, pathStart) ).toLowerCase(); const path = pathStart === -1 ? "/*" : rest.slice(pathStart); + const { host, port } = splitHostPort(authority); if (!host) throw new Error("no host found"); - return { host, path }; + if (scheme === "http" && !isLoopbackHost(host)) { + throw new Error("http is only allowed for loopback hosts"); + } + return { scheme, host, port, path }; +} + +function splitHostPort(authority) { + const portSep = authority.startsWith("[") + ? authority.indexOf(":", authority.indexOf("]")) + : authority.lastIndexOf(":"); + if (portSep === -1) return { host: authority, port: null }; + const port = authority.slice(portSep + 1); + if (!/^\d+$/.test(port)) throw new Error("invalid port"); + return { host: authority.slice(0, portSep), port }; +} + +function effectivePort(parsedUrl) { + if (parsedUrl.port) return parsedUrl.port; + return parsedUrl.protocol === "https:" ? "443" : "80"; +} + +// Mirrors the "potentially trustworthy origin" hosts browsers exempt from +// mixed-content blocking. Note Safari does not honor the exemption, so http +// loopback fetches fail there when the app itself is served over https. +function isLoopbackHost(host) { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + if (normalized === "::1") return true; + return /^127(?:\.\d{1,3}){3}$/.test(normalized); } function hostMatches(actualHost, patternHost) { diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js index d7223142..904cf119 100644 --- a/tests/unit/specs/plugins/pluginPermissions.test.js +++ b/tests/unit/specs/plugins/pluginPermissions.test.js @@ -188,6 +188,54 @@ describe("isFetchAllowed", () => { assert(!isFetchAllowed("http://example.com/", permissions)); }); + it("rejects http patterns for non-loopback hosts", () => { + const permissions = { fetch: ["http://example.com/*"] }; + assert(!isFetchAllowed("http://example.com/", permissions)); + }); + + it("allows http for loopback hosts", () => { + const permissions = { + fetch: [ + "http://localhost:11434/*", + "http://127.0.0.1:1234/*", + "http://[::1]:8080/*", + "http://ollama.localhost/*", + ], + }; + assert(isFetchAllowed("http://localhost:11434/api/generate", permissions)); + assert(isFetchAllowed("http://127.0.0.1:1234/v1/chat", permissions)); + assert(isFetchAllowed("http://[::1]:8080/v1/chat", permissions)); + assert(isFetchAllowed("http://ollama.localhost/api", permissions)); + }); + + it("does not let an https pattern authorize an http url", () => { + const permissions = { fetch: ["https://localhost/*"] }; + assert(!isFetchAllowed("http://localhost/", permissions)); + }); + + it("does not let an http loopback pattern authorize a remote host", () => { + const permissions = { fetch: ["http://localhost/*"] }; + assert(!isFetchAllowed("http://localhost.evil.com/", permissions)); + assert(!isFetchAllowed("http://notlocalhost/", permissions)); + }); + + it("enforces the port when the pattern specifies one", () => { + const permissions = { fetch: ["http://localhost:11434/*"] }; + assert(!isFetchAllowed("http://localhost:1234/api", permissions)); + assert(!isFetchAllowed("http://localhost/api", permissions)); + }); + + it("matches any port when the pattern omits one", () => { + const permissions = { fetch: ["https://example.com/*"] }; + assert(isFetchAllowed("https://example.com/foo", permissions)); + assert(isFetchAllowed("https://example.com:8443/foo", permissions)); + }); + + it("treats a default port in the url as matching an explicit pattern port", () => { + const permissions = { fetch: ["https://example.com:443/*"] }; + assert(isFetchAllowed("https://example.com/foo", permissions)); + }); + it("denies everything when the fetch key is missing", () => { assert(!isFetchAllowed("https://example.com/", {})); }); -- knotmirror 0.0.1 From 622187712a4908f6ff0a8dbe4d876b8c712d1f4b Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 18:42:32 -0500 Subject: [PATCH 06/25] Allow plugins to request fetch permissions at runtime --- impro-plugin/docs/docs.md | 48 +++- impro-plugin/main.d.ts | 29 +- impro-plugin/main.js | 39 ++- src/css/style.css | 56 ++++ src/js/plugins/pluginModal.js | 36 ++- src/js/plugins/pluginPermissions.js | 52 +++- src/js/plugins/pluginPreferencesManager.js | 23 ++ src/js/plugins/pluginService.js | 96 ++++++- src/js/views/installedPlugins.view.js | 3 +- src/js/views/pluginSettings.view.js | 88 +++++- .../specs/views/installedPlugins.view.test.js | 31 ++- .../specs/views/pluginSettings.view.test.js | 117 ++++++++ tests/e2e/testPlugins.js | 18 ++ tests/unit/specs/plugins/pluginModal.test.js | 39 +++ .../specs/plugins/pluginPermissions.test.js | 169 +++++++++++ .../unit/specs/plugins/pluginService.test.js | 262 ++++++++++++++++++ 16 files changed, 1069 insertions(+), 37 deletions(-) diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 40bbd91a..77f1fecf 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -2699,8 +2699,9 @@ One token in a rich-text stream — `text`, `facet`, `inline`, or `block`. > **fetch**(`url`, `init?`): `Promise`\<[`PluginResponse`](#pluginresponse)\> -Proxied fetch through the host. Requires the `"networkRequest"` permission -scope and the target URL must be covered by the plugin's manifest allowlist. +Proxied fetch through the host. The target URL must match one of the +`permissions.fetch` URL patterns declared in the plugin's manifest, which +the user grants at install. `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or `[name, value]` iterable), and a string `body`. Resolves to a [PluginResponse](#pluginresponse). @@ -2733,3 +2734,46 @@ Convenience wrapper that returns a new [FlattenedTokens](#flattenedtokens) for ` #### Returns [`FlattenedTokens`](#flattenedtokens) + +*** + +### getUserGrantedFetchOrigins() + +> **getUserGrantedFetchOrigins**(): `Promise`\<`string`[]\> + +The origins the user has granted this plugin, as fetch patterns +(`https://example.com/*`). Manifest-declared permissions are not included. + +#### Returns + +`Promise`\<`string`[]\> + +*** + +### requestFetchPermission() + +> **requestFetchPermission**(`url`): `Promise`\<`boolean`\> + +Asks the user to grant this plugin network access to `url`'s origin — for +endpoints the plugin can't know in advance, such as a user-supplied API +provider. Requires `permissions.userFetch` in the manifest. + +Grants are origin-scoped (scheme, host, and port; the path is ignored) and +persist until the user revokes them in the app's plugin settings. Resolves +`true` without prompting if the origin is already permitted, so it is safe +to call before every request. Resolves `false` if the user declines, if +`url` can't be an origin, or if a prompt for this plugin is already open. + +Any credential for the endpoint is the plugin's to hold — use +[Plugin.saveLocalData](#savelocaldata) rather than [Plugin.saveData](#savedata), which +syncs through the user's account preferences. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `url` | `string` | + +#### Returns + +`Promise`\<`boolean`\> diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index d2713ca4..54d48cb1 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -1,6 +1,7 @@ /** - * Proxied fetch through the host. Requires the `"networkRequest"` permission - * scope and the target URL must be covered by the plugin's manifest allowlist. + * Proxied fetch through the host. The target URL must match one of the + * `permissions.fetch` URL patterns declared in the plugin's manifest, which + * the user grants at install. * `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or * `[name, value]` iterable), and a string `body`. Resolves to a * {@link PluginResponse}. @@ -9,6 +10,30 @@ * @returns {Promise} */ export function fetch(url: string, init?: PluginFetchInit): Promise; +/** + * Asks the user to grant this plugin network access to `url`'s origin — for + * endpoints the plugin can't know in advance, such as a user-supplied API + * provider. Requires `permissions.userFetch` in the manifest. + * + * Grants are origin-scoped (scheme, host, and port; the path is ignored) and + * persist until the user revokes them in the app's plugin settings. Resolves + * `true` without prompting if the origin is already permitted, so it is safe + * to call before every request. Resolves `false` if the user declines, if + * `url` can't be an origin, or if a prompt for this plugin is already open. + * + * Any credential for the endpoint is the plugin's to hold — use + * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which + * syncs through the user's account preferences. + * @param {string} url + * @returns {Promise} + */ +export function requestFetchPermission(url: string): Promise; +/** + * The origins the user has granted this plugin, as fetch patterns + * (`https://example.com/*`). Manifest-declared permissions are not included. + * @returns {Promise} + */ +export function getUserGrantedFetchOrigins(): Promise; /** * Convenience wrapper that returns a new {@link FlattenedTokens} for `tokens`. * @param {RichTextToken[]} tokens diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 84e43327..7a4b7214 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -539,8 +539,9 @@ export class App { } /** - * Proxied fetch through the host. Requires the `"networkRequest"` permission - * scope and the target URL must be covered by the plugin's manifest allowlist. + * Proxied fetch through the host. The target URL must match one of the + * `permissions.fetch` URL patterns declared in the plugin's manifest, which + * the user grants at install. * `init` accepts `method`, `headers` (plain object, `Headers`, `Map`, or * `[name, value]` iterable), and a string `body`. Resolves to a * {@link PluginResponse}. @@ -555,6 +556,40 @@ export async function fetch(url, init = {}) { return new PluginResponse(result); } +/** + * Asks the user to grant this plugin network access to `url`'s origin — for + * endpoints the plugin can't know in advance, such as a user-supplied API + * provider. Requires `permissions.userFetch` in the manifest. + * + * Grants are origin-scoped (scheme, host, and port; the path is ignored) and + * persist until the user revokes them in the app's plugin settings. Resolves + * `true` without prompting if the origin is already permitted, so it is safe + * to call before every request. Resolves `false` if the user declines, if + * `url` can't be an origin, or if a prompt for this plugin is already open. + * + * Any credential for the endpoint is the plugin's to hold — use + * {@link Plugin.saveLocalData} rather than {@link Plugin.saveData}, which + * syncs through the user's account preferences. + * @param {string} url + * @returns {Promise} + */ +export async function requestFetchPermission(url) { + return /** @type {boolean} */ ( + await hostCall("requestFetchPermission", { url }) + ); +} + +/** + * The origins the user has granted this plugin, as fetch patterns + * (`https://example.com/*`). Manifest-declared permissions are not included. + * @returns {Promise} + */ +export async function getUserGrantedFetchOrigins() { + return /** @type {string[]} */ ( + await hostCall("getUserGrantedFetchOrigins", {}) + ); +} + /** * @param {unknown} value * @returns {value is { forEach: (callback: (value: string, name: string) => void) => void }} diff --git a/src/css/style.css b/src/css/style.css index e2b6c029..c98b8bcb 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -7350,6 +7350,13 @@ context-menu-item-group > context-menu-item > button { word-break: break-all; } +.permission-prompt-note { + display: block; + margin-top: 12px; + font-size: 13px; + color: var(--text-color-muted); +} + @media (hover: hover) { .modal-dialog-message a:hover { text-decoration: underline; @@ -10638,6 +10645,55 @@ rendered-markdown ul:has(> li > input[type="checkbox"]) { color: var(--text-color); } +.plugin-system-settings { + display: block; + margin-top: 16px; +} + +.plugin-system-settings-bar { + padding: 8px 16px; + background-color: var(--background-color-secondary); + border-top: 1px solid var(--generic-border-color); + border-bottom: 1px solid var(--generic-border-color); + color: var(--text-color-muted); +} + +.plugin-system-settings-body { + padding: 16px; +} + +.plugin-network-access-title { + font-size: 15px; + font-weight: 600; +} + +.plugin-network-access-description { + margin-top: 4px; + font-size: 13px; + color: var(--text-color-muted); +} + +.plugin-network-access-list { + margin-top: 12px; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.plugin-network-access-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.plugin-network-access-item code { + font-family: var(--monospace-font, monospace); + font-size: 13px; + word-break: break-all; +} + .plugin-content { --plugin-prose-inset: 0px; } diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index 89aff693..be29de55 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -118,13 +118,15 @@ const ACTION_LABELS = { 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', }; -function permissionsSectionTemplate({ title, items }) { +function permissionsSectionTemplate({ title, items = [] }) { return html`
${title}
-
    - ${items.map((item) => html`
  • ${item}
  • `)} -
+ ${items.length > 0 + ? html`
    + ${items.map((item) => html`
  • ${item}
  • `)} +
` + : ""}
`; } @@ -140,6 +142,13 @@ function permissionsListTemplate({ permissions }) { }), ); } + if (permissions.userFetch) { + sections.push( + permissionsSectionTemplate({ + title: "Send network requests to user-specified domains", + }), + ); + } const actionScopes = permissions.actions ?? []; if (actionScopes.length > 0) { sections.push( @@ -169,6 +178,25 @@ export async function showPluginInstallPermissionsModal({ ); } +export async function showPluginFetchPermissionModal({ pluginName, origin }) { + const name = pluginName ?? "This plugin"; + return confirmModal( + html` + ${name} wants permission to: +
+ ${permissionsSectionTemplate({ + title: "Send network requests to:", + items: [html`${origin}`], + })} +
`, + { + title: "Allow network access?", + confirmButtonText: "Allow", + }, + ); +} + export async function showPluginUpdatePermissionsModal({ pluginName, pluginVersion, diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index 0b3ddbfd..771bfaa8 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -17,6 +17,7 @@ export function parsePermissions(permissions) { ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } + if (permissions.userFetch === true) parsed.userFetch = true; if (permissions.actions) { const actionsArray = Array.isArray(permissions.actions) ? permissions.actions @@ -39,8 +40,17 @@ export function diffPermissions(current, next) { const diff = {}; let hasAny = false; for (const key of Object.keys(next)) { - const have = new Set(current[key] ?? []); - const added = (next[key] ?? []).filter((entry) => !have.has(entry)); + const nextValue = next[key]; + // Scope flags (userFetch) are booleans, not pattern lists + if (!Array.isArray(nextValue)) { + if (nextValue && !current[key]) { + diff[key] = nextValue; + hasAny = true; + } + continue; + } + const have = new Set(Array.isArray(current[key]) ? current[key] : []); + const added = nextValue.filter((entry) => !have.has(entry)); if (added.length > 0) { diff[key] = added; hasAny = true; @@ -50,11 +60,45 @@ export function diffPermissions(current, next) { } export function isEmptyPermissions(obj) { - return Object.values(obj).every( - (entries) => !Array.isArray(entries) || entries.length === 0, + return Object.values(obj).every((value) => + Array.isArray(value) ? value.length === 0 : !value, ); } +export function isUserFetchAllowed(permissions) { + return permissions.userFetch === true; +} + +// Canonicalizes a plugin-supplied URL into an origin-scoped fetch pattern, +// or null if it can't be one. User grants cover a whole origin: path is +// discarded, port kept. +export function normalizeFetchOrigin(url) { + let parsedUrl = null; + try { + parsedUrl = new URL(url); + } catch { + return null; + } + if (parsedUrl.username || parsedUrl.password) return null; + const host = parsedUrl.hostname.toLowerCase(); + if (!host || host.includes("*")) return null; + if (parsedUrl.protocol === "http:") { + if (!isLoopbackHost(host)) return null; + } else if (parsedUrl.protocol !== "https:") { + return null; + } + const port = parsedUrl.port ? `:${parsedUrl.port}` : ""; + return `${parsedUrl.protocol}//${host}${port}/*`; +} + +// Sanitizes stored user-granted origins into canonical fetch patterns. The +// installed-plugins list lives in the user's preferences record, we need to +// sanitize before using it. +export function parseUserGrantedFetchOrigins(origins) { + if (!Array.isArray(origins)) return []; + return unique(origins.map(normalizeFetchOrigin).filter(Boolean)); +} + export function isFetchAllowed(url, permissions) { let parsedUrl = null; try { diff --git a/src/js/plugins/pluginPreferencesManager.js b/src/js/plugins/pluginPreferencesManager.js index 63a8829c..770aac7f 100644 --- a/src/js/plugins/pluginPreferencesManager.js +++ b/src/js/plugins/pluginPreferencesManager.js @@ -1,4 +1,5 @@ import { Signal, ReactiveStore, ComputedMap } from "/js/signals.js"; +import { parseUserGrantedFetchOrigins } from "/js/plugins/pluginPermissions.js"; // Handles persisting plugin settings in user preferences export class PluginPreferencesManager extends ReactiveStore { @@ -81,6 +82,28 @@ export class PluginPreferencesManager extends ReactiveStore { })); } + // User-granted fetch origins are kept apart from the manifest permissions. + async addUserGrantedFetchOrigin(pluginId, origin) { + await this.updateInstalledPlugin(pluginId, (entry) => { + const granted = parseUserGrantedFetchOrigins( + entry.userGrantedFetchOrigins, + ); + if (granted.includes(origin)) { + return { ...entry, userGrantedFetchOrigins: granted }; + } + return { ...entry, userGrantedFetchOrigins: [...granted, origin] }; + }); + } + + async removeUserGrantedFetchOrigin(pluginId, origin) { + await this.updateInstalledPlugin(pluginId, (entry) => ({ + ...entry, + userGrantedFetchOrigins: parseUserGrantedFetchOrigins( + entry.userGrantedFetchOrigins, + ).filter((granted) => granted !== origin), + })); + } + readSettingsForPlugin(pluginId) { return this.preferencesProvider .requirePreferences() diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 8dc4416b..7263f2e4 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -5,6 +5,7 @@ import { hidePluginModal, showPluginInstallPermissionsModal, showPluginUpdatePermissionsModal, + showPluginFetchPermissionModal, } from "/js/plugins/pluginModal.js"; import { showPluginToast, hidePluginToast, showToast } from "/js/toasts.js"; import { PluginRenderer } from "/js/plugins/pluginRendering.js"; @@ -31,6 +32,9 @@ import { diffPermissions, isEmptyPermissions, isActionAllowed, + isUserFetchAllowed, + parseUserGrantedFetchOrigins, + normalizeFetchOrigin, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; @@ -48,6 +52,17 @@ function requireHostMethodArg(method, name, value) { export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; +function getGrantedOriginsForPlugin(entry) { + return parseUserGrantedFetchOrigins(entry?.userGrantedFetchOrigins); +} + +// Whether the plugin's settings page has a host-owned section to render. +// Keyed off the same normalization the section uses, so the settings link +// can't point at an empty section. +function hasSystemSettings(entry) { + return getGrantedOriginsForPlugin(entry).length > 0; +} + // Page id must also be a valid URL segment const PAGE_ID_PATTERN = /^[a-z0-9-]+$/; @@ -173,6 +188,7 @@ export class PluginService extends ReactiveStore { author: entry.author, enabled: entry.enabled, hasSettings: this.$settingTabs.get(entry.id) !== null, + hasSystemSettings: hasSystemSettings(entry), })); }); this.$settingTabs = new SignalMap(); @@ -205,6 +221,7 @@ export class PluginService extends ReactiveStore { ? new PluginLocalDataStore(session.did) : new PluginMemoryDataStore(); this.isPreviewMode = false; + this._pendingFetchPermissionRequests = new Set(); this._dataLayer = dataLayer; this._hiddenFeedItemsStore = hiddenFeedItemsStore; this._setupRegistries(); @@ -521,6 +538,15 @@ export class PluginService extends ReactiveStore { return pluginFetch(permissions, url, init); }); + this.pluginBridge.addHostMethod( + "requestFetchPermission", + (plugin, { url }) => this._requestFetchPermission(plugin.pluginId, url), + ); + + this.pluginBridge.addHostMethod("getUserGrantedFetchOrigins", (plugin) => + this.getUserGrantedFetchOrigins(plugin.pluginId), + ); + this.pluginBridge.addHostMethod("getPost", async (plugin, { uri }) => { try { return await this._dataLayer.declarative.ensurePost(uri); @@ -671,7 +697,50 @@ export class PluginService extends ReactiveStore { _getPermissionsForPlugin(pluginId) { const entry = this.prefManager.$installedPlugin.get(pluginId); - return parsePermissions(entry?.permissions ?? {}); + const permissions = parsePermissions(entry?.permissions ?? {}); + if (!isUserFetchAllowed(permissions)) return permissions; + const granted = this.getUserGrantedFetchOrigins(pluginId); + if (granted.length === 0) return permissions; + return { + ...permissions, + fetch: [...(permissions.fetch ?? []), ...granted], + }; + } + + async _requestFetchPermission(pluginId, url) { + const entry = this.prefManager.$installedPlugin.get(pluginId); + if (!isUserFetchAllowed(parsePermissions(entry?.permissions ?? {}))) { + throw new Error(`"${pluginId}" does not have the "userFetch" permission`); + } + const origin = normalizeFetchOrigin(url); + if (!origin) return false; + if (this._getPermissionsForPlugin(pluginId).fetch?.includes(origin)) { + return true; + } + if (this._pendingFetchPermissionRequests.has(pluginId)) return false; + this._pendingFetchPermissionRequests.add(pluginId); + let granted = false; + try { + granted = await showPluginFetchPermissionModal({ + pluginName: entry?.name ?? null, + origin, + }); + } finally { + this._pendingFetchPermissionRequests.delete(pluginId); + } + if (!granted) return false; + await this.prefManager.addUserGrantedFetchOrigin(pluginId, origin); + return true; + } + + async revokeUserGrantedFetchOrigin(pluginId, origin) { + await this.prefManager.removeUserGrantedFetchOrigin(pluginId, origin); + } + + getUserGrantedFetchOrigins(pluginId) { + return getGrantedOriginsForPlugin( + this.prefManager.$installedPlugin.get(pluginId), + ); } _requireActionPermission(plugin, action) { @@ -1022,14 +1091,23 @@ export class PluginService extends ReactiveStore { if (!accepted) throw new PermissionsDeclinedError(); } const { name, version, author, description } = liveManifest; - await this.prefManager.updateInstalledPlugin(pluginId, (entry) => ({ - ...entry, - name, - version, - author, - description, - permissions, - })); + const keepUserGrantedFetch = isUserFetchAllowed(permissions); + await this.prefManager.updateInstalledPlugin(pluginId, (entry) => { + const next = { + ...entry, + name, + version, + author, + description, + permissions, + }; + // Drop stored grants when the new manifest no longer requests + // userFetch, so a later manifest that re-adds the scope starts fresh. + if (!keepUserGrantedFetch && next.userGrantedFetchOrigins) { + delete next.userGrantedFetchOrigins; + } + return next; + }); await this.pluginBridge.reloadPlugin( pluginId, version, diff --git a/src/js/views/installedPlugins.view.js b/src/js/views/installedPlugins.view.js index d5f572b1..510bbcea 100644 --- a/src/js/views/installedPlugins.view.js +++ b/src/js/views/installedPlugins.view.js @@ -304,7 +304,8 @@ class InstalledPluginsView extends View { : "Update"} ` : ""} - ${plugin.enabled && plugin.hasSettings + ${plugin.enabled && + (plugin.hasSettings || plugin.hasSystemSettings) ? html` +
+ +
+ `; +} + +function systemSettingsTemplate({ origins, onRevoke }) { + return html`
+
System settings
+
+

Network access

+

+ You granted this plugin permission to send requests to these addresses. +

+
    + ${origins.map( + (origin) => + html`
  • + ${origin} + +
  • `, + )} +
+
+
`; +} class PluginSettingsView extends View { async render({ root, router, layout, params, context: { pluginService } }) { @@ -74,25 +123,40 @@ class PluginSettingsView extends View { ${pluginLoadError.message ?? "This plugin failed to load."}

`; } + const grantedOrigins = + pluginService.getUserGrantedFetchOrigins(pluginId); if (!settingTab) { if (pluginLoading) { return html`
`; } - return html`

- This plugin has no settings. -

`; + if (grantedOrigins.length === 0) { + return html`

+ This plugin has no settings. +

`; + } } - return html`
- -
`; + return html`${settingTab + ? pluginOwnedSettingsTemplate({ pluginService, settingTab }) + : ""} + ${grantedOrigins.length > 0 + ? systemSettingsTemplate({ + origins: grantedOrigins, + onRevoke: (origin) => + pluginService + .revokeUserGrantedFetchOrigin(pluginId, origin) + .catch((e) => { + console.error(e); + showToast("Failed to revoke access", { + style: "error", + }); + }), + }) + : ""}`; })()} `, diff --git a/tests/e2e/specs/views/installedPlugins.view.test.js b/tests/e2e/specs/views/installedPlugins.view.test.js index 758ee692..10b6b3cb 100644 --- a/tests/e2e/specs/views/installedPlugins.view.test.js +++ b/tests/e2e/specs/views/installedPlugins.view.test.js @@ -1,7 +1,11 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { TEST_PLUGIN_ID, TEST_PLUGIN_MANIFEST } from "../../testPlugins.js"; +import { + TEST_PLUGIN_ID, + TEST_PLUGIN_MANIFEST, + getNoSettingsPluginSource, +} from "../../testPlugins.js"; function seedInstalled(mockServer) { mockServer.installedPlugins = [{ ...TEST_PLUGIN_MANIFEST, enabled: false }]; @@ -94,6 +98,31 @@ test.describe("Installed plugins view", () => { }); }); + test("shows the Settings link for a plugin whose only settings are system-owned", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getNoSettingsPluginSource(); + await mockServer.setup(page); + await login(page); + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + userGrantedFetchOrigins: ["https://api.example.com/*"], + }, + ]; + + await page.goto("/plugins/installed"); + const sampleItem = page.locator(".plugin-list-item", { + hasText: "Test Plugin", + }); + await expect(sampleItem.locator(".plugin-settings-link")).toBeVisible({ + timeout: 10000, + }); + }); + test("uninstall button removes the plugin after confirmation", async ({ page, }) => { diff --git a/tests/e2e/specs/views/pluginSettings.view.test.js b/tests/e2e/specs/views/pluginSettings.view.test.js index f5b4811d..34514598 100644 --- a/tests/e2e/specs/views/pluginSettings.view.test.js +++ b/tests/e2e/specs/views/pluginSettings.view.test.js @@ -12,6 +12,8 @@ import { getFailingPluginSource, getNoSettingsPluginSource, getTestPluginSource, + getFetchPermissionPluginSource, + REQUESTED_FETCH_ORIGIN, } from "../../testPlugins.js"; const PLUGIN_ID = TEST_PLUGIN_ID; @@ -337,6 +339,121 @@ test.describe("Plugin settings view", () => { ).toBeVisible({ timeout: 10000 }); }); + test.describe("User-granted fetch origins", () => { + function seedPromptingPlugin(mockServer) { + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + }, + ]; + } + + test("grants an origin from the prompt and lists it in settings", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="fetch-permission-prompt"]'), + ).toBeVisible({ timeout: 10000 }); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + const networkAccess = page.locator( + '[data-testid="plugin-network-access"]', + ); + await expect(networkAccess).toBeVisible({ timeout: 10000 }); + await expect(networkAccess).toContainText(REQUESTED_FETCH_ORIGIN); + }); + + test("revoking removes the origin", async ({ page }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await page + .locator('[data-testid="modal-confirm-button"]') + .click({ timeout: 10000 }); + const networkAccess = page.locator( + '[data-testid="plugin-network-access"]', + ); + await expect(networkAccess).toBeVisible({ timeout: 10000 }); + + await networkAccess + .locator('[data-testid="revoke-fetch-origin"]') + .click(); + await expect(networkAccess).toBeHidden({ timeout: 10000 }); + }); + + test("renders plugin-owned and system-owned settings as separate sections", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getTestPluginSource(); + await mockServer.setup(page); + await login(page); + mockServer.installedPlugins = [ + { + ...TEST_PLUGIN_MANIFEST, + enabled: true, + permissions: { userFetch: true }, + userGrantedFetchOrigins: [REQUESTED_FETCH_ORIGIN], + }, + ]; + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="plugin-owned-settings"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-system-settings"]'), + ).toBeVisible(); + }); + + test("omits the system section when nothing is granted", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getTestPluginSource(); + await mockServer.setup(page); + await login(page); + seedEnabled(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await expect( + page.locator('[data-testid="plugin-owned-settings"]'), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-system-settings"]'), + ).toBeHidden(); + }); + + test("declining leaves nothing granted", async ({ page }) => { + const mockServer = new MockServer(); + mockServer.localPluginSource = getFetchPermissionPluginSource(); + await mockServer.setup(page); + await login(page); + seedPromptingPlugin(mockServer); + + await page.goto(`/plugin/${PLUGIN_ID}/settings`); + await page + .locator('[data-testid="modal-cancel-button"]') + .click({ timeout: 10000 }); + await expect( + page.locator('[data-testid="plugin-network-access"]'), + ).toBeHidden(); + }); + }); + test.describe("Logged-out behavior", () => { test("redirects to /login when not authenticated", async ({ page }) => { await page.goto(`/plugin/${PLUGIN_ID}/settings`); diff --git a/tests/e2e/testPlugins.js b/tests/e2e/testPlugins.js index c92b6299..1d6bcacd 100644 --- a/tests/e2e/testPlugins.js +++ b/tests/e2e/testPlugins.js @@ -256,6 +256,20 @@ class TestPlugin extends Plugin { TestPlugin.register(); `; +// Asks for a user-granted fetch origin as soon as it loads, so the host's +// permission prompt appears without needing plugin-rendered UI to trigger it. +export const REQUESTED_FETCH_ORIGIN = "https://api.example.com/*"; + +const FETCH_PERMISSION_PLUGIN_BODY = /* js */ ` +class TestPlugin extends Plugin { + async onload() { + await requestFetchPermission("https://api.example.com/v1/chat"); + } +} + +TestPlugin.register(); +`; + // A plugin that seeds the composer with a signature string on every open // (post and reply). Used by composer-init e2e tests. const POST_COMPOSER_INIT_PLUGIN_BODY = /* js */ ` @@ -348,6 +362,10 @@ export function getNoSettingsPluginSource() { return getWorkerSource() + "\n" + NO_SETTINGS_PLUGIN_BODY; } +export function getFetchPermissionPluginSource() { + return getWorkerSource() + "\n" + FETCH_PERMISSION_PLUGIN_BODY; +} + export function getPostComposerInitPluginSource() { return getWorkerSource() + "\n" + POST_COMPOSER_INIT_PLUGIN_BODY; } diff --git a/tests/unit/specs/plugins/pluginModal.test.js b/tests/unit/specs/plugins/pluginModal.test.js index 61b9afec..3b90267d 100644 --- a/tests/unit/specs/plugins/pluginModal.test.js +++ b/tests/unit/specs/plugins/pluginModal.test.js @@ -4,8 +4,11 @@ import { showPluginModal as _showPluginModal, updatePluginModal as _updatePluginModal, hidePluginModal, + showPluginInstallPermissionsModal, + showPluginFetchPermissionModal, } from "/js/plugins/pluginModal.js"; import { PluginRenderer } from "/js/plugins/pluginRendering.js"; +import { respondToConfirm, waitFor } from "../../testHelpers.js"; function showPluginModal(opts) { const pluginRenderer = new PluginRenderer(null, opts.pluginId); @@ -442,3 +445,39 @@ describe("hidePluginModal", () => { assert.deepEqual(onDismiss.mock.callCount(), 0); }); }); + +describe("permission prompts", () => { + it("describes a userFetch scope in the install prompt", async () => { + const prompting = showPluginInstallPermissionsModal({ + pluginName: "Alpha", + permissions: { userFetch: true }, + }); + await waitFor(() => + document.querySelector('[data-testid="permission-prompt"]'), + ); + const prompt = document.querySelector('[data-testid="permission-prompt"]'); + // A userFetch-only manifest must not render an empty permission list + assert(prompt.querySelector(".permission-prompt-section")); + await respondToConfirm(false); + await prompting; + }); + + it("renders the requested origin in the fetch prompt", async () => { + const prompting = showPluginFetchPermissionModal({ + pluginName: "Alpha", + origin: "https://api.example.com/*", + }); + await waitFor(() => + document.querySelector('[data-testid="fetch-permission-prompt"]'), + ); + const prompt = document.querySelector( + '[data-testid="fetch-permission-prompt"]', + ); + assert.equal( + prompt.querySelector("code").textContent, + "https://api.example.com/*", + ); + await respondToConfirm(false); + assert.equal(await prompting, false); + }); +}); diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js index 904cf119..afc20c8b 100644 --- a/tests/unit/specs/plugins/pluginPermissions.test.js +++ b/tests/unit/specs/plugins/pluginPermissions.test.js @@ -6,6 +6,9 @@ import { isEmptyPermissions, isFetchAllowed, isActionAllowed, + isUserFetchAllowed, + normalizeFetchOrigin, + parseUserGrantedFetchOrigins, } from "/js/plugins/pluginPermissions.js"; describe("parsePermissions", () => { @@ -137,6 +140,38 @@ describe("diffPermissions", () => { }); }); +describe("diffPermissions (boolean scopes)", () => { + it("reports a newly declared userFetch", () => { + assert.deepEqual(diffPermissions({}, { userFetch: true }), { + userFetch: true, + }); + }); + + it("does not report a userFetch that was already granted", () => { + assert.equal( + diffPermissions({ userFetch: true }, { userFetch: true }), + null, + ); + }); + + it("diffs patterns alongside a boolean scope", () => { + assert.deepEqual( + diffPermissions( + { fetch: ["https://a.com/*"] }, + { fetch: ["https://a.com/*", "https://b.com/*"], userFetch: true }, + ), + { fetch: ["https://b.com/*"], userFetch: true }, + ); + }); + + it("survives a stored value whose shape doesn't match the manifest", () => { + assert.deepEqual( + diffPermissions({ fetch: true }, { fetch: ["https://a.com/*"] }), + { fetch: ["https://a.com/*"] }, + ); + }); +}); + describe("isEmptyPermissions (missing-key shape)", () => { it("returns true for an empty object", () => { assert(isEmptyPermissions({})); @@ -151,6 +186,140 @@ describe("isEmptyPermissions", () => { it("returns false when any array is non-empty", () => { assert(!isEmptyPermissions({ fetch: ["https://a.com/*"] })); }); + + // Load-bearing: this is what keeps a prompting plugin out of preview + // installs, and userFetch is a boolean rather than an array. + it("returns false for a userFetch grant", () => { + assert(!isEmptyPermissions({ userFetch: true })); + }); + + it("returns true for a falsy userFetch", () => { + assert(isEmptyPermissions({ userFetch: false })); + }); +}); + +describe("parsePermissions (userFetch)", () => { + it("keeps a literal true", () => { + assert.deepEqual(parsePermissions({ userFetch: true }), { + userFetch: true, + }); + }); + + it("drops truthy non-boolean values", () => { + assert.deepEqual(parsePermissions({ userFetch: "yes" }), {}); + assert.deepEqual(parsePermissions({ userFetch: 1 }), {}); + assert.deepEqual(parsePermissions({ userFetch: false }), {}); + }); +}); + +describe("isUserFetchAllowed", () => { + it("requires the parsed flag", () => { + assert(isUserFetchAllowed({ userFetch: true })); + assert(!isUserFetchAllowed({})); + assert(!isUserFetchAllowed({ fetch: ["https://a.com/*"] })); + }); +}); + +describe("parseUserGrantedFetchOrigins", () => { + it("normalizes and de-duplicates stored origins", () => { + assert.deepEqual( + parseUserGrantedFetchOrigins([ + "https://api.example.com/v1", + "https://api.example.com/v2", + "http://localhost:11434/api", + ]), + ["https://api.example.com/*", "http://localhost:11434/*"], + ); + }); + + // The preferences record is writable by anything holding the account's + // credentials, so stored values are untrusted input + it("drops anything that isn't a normalizable origin", () => { + assert.deepEqual( + parseUserGrantedFetchOrigins([ + "https://*/*", + "http://evil.example.com/*", + "not a url", + 42, + null, + ]), + [], + ); + }); + + it("returns an empty array for a non-array value", () => { + assert.deepEqual(parseUserGrantedFetchOrigins(undefined), []); + assert.deepEqual(parseUserGrantedFetchOrigins("https://a.com/*"), []); + assert.deepEqual(parseUserGrantedFetchOrigins(null), []); + }); +}); + +describe("normalizeFetchOrigin", () => { + it("discards the path and keeps the origin", () => { + assert.equal( + normalizeFetchOrigin("https://api.example.com/v1/chat?key=1#x"), + "https://api.example.com/*", + ); + }); + + it("preserves an explicit port", () => { + assert.equal( + normalizeFetchOrigin("http://localhost:11434/api/generate"), + "http://localhost:11434/*", + ); + }); + + it("drops a default port, matching URL normalization", () => { + assert.equal( + normalizeFetchOrigin("https://example.com:443/foo"), + "https://example.com/*", + ); + }); + + it("lowercases the host", () => { + assert.equal( + normalizeFetchOrigin("https://API.Example.COM/foo"), + "https://api.example.com/*", + ); + }); + + it("allows http only for loopback", () => { + assert.equal( + normalizeFetchOrigin("http://127.0.0.1:8080/"), + "http://127.0.0.1:8080/*", + ); + assert.equal( + normalizeFetchOrigin("http://[::1]:8080/"), + "http://[::1]:8080/*", + ); + assert.equal(normalizeFetchOrigin("http://example.com/"), null); + }); + + it("rejects embedded credentials", () => { + assert.equal(normalizeFetchOrigin("https://user:pass@example.com/"), null); + assert.equal(normalizeFetchOrigin("https://user@example.com/"), null); + }); + + it("rejects non-http(s) schemes", () => { + assert.equal(normalizeFetchOrigin("ftp://example.com/"), null); + assert.equal(normalizeFetchOrigin("javascript:alert(1)"), null); + assert.equal(normalizeFetchOrigin("data:text/plain,hi"), null); + }); + + it("rejects unparseable input", () => { + assert.equal(normalizeFetchOrigin("not a url"), null); + assert.equal(normalizeFetchOrigin(""), null); + assert.equal(normalizeFetchOrigin(null), null); + }); + + it("produces a pattern that isFetchAllowed accepts for that origin only", () => { + const permissions = { + fetch: [normalizeFetchOrigin("https://api.example.com/v1")], + }; + assert(isFetchAllowed("https://api.example.com/other", permissions)); + assert(!isFetchAllowed("https://evil.example.com/", permissions)); + assert(!isFetchAllowed("http://api.example.com/", permissions)); + }); }); describe("isFetchAllowed", () => { diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 951725d9..4f34b34b 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -9,6 +9,7 @@ import { EventEmitter } from "/js/eventEmitter.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; import { Constellation } from "/js/constellation.js"; import { respondToConfirm } from "../../testHelpers.js"; +import { isFetchAllowed } from "/js/plugins/pluginPermissions.js"; function emptyDataLayer() { const dataLayer = new EventEmitter(); @@ -822,6 +823,44 @@ describe("$pluginsInfo", () => { assert.deepEqual(info[0].id, "alpha"); }); + it("flags hasSystemSettings from granted fetch origins", () => { + const { service, state } = makeService({}); + state.installedPlugins = [ + { id: "alpha", name: "Alpha", version: "1.0.0", enabled: true }, + { + id: "beta", + name: "Beta", + version: "1.0.0", + enabled: true, + userGrantedFetchOrigins: ["https://api.example.com/*"], + }, + ]; + const info = service.$pluginsInfo.get(); + assert.equal( + info.find((plugin) => plugin.id === "alpha").hasSystemSettings, + false, + ); + assert.equal( + info.find((plugin) => plugin.id === "beta").hasSystemSettings, + true, + ); + }); + + it("does not flag hasSystemSettings for stored origins that can't be normalized", () => { + const { service, state } = makeService({}); + state.installedPlugins = [ + { + id: "alpha", + name: "Alpha", + version: "1.0.0", + enabled: true, + userGrantedFetchOrigins: ["https://*/*", 42], + }, + ]; + // Otherwise the settings link would point at an empty system section + assert.equal(service.$pluginsInfo.get()[0].hasSystemSettings, false); + }); + it("includes __LOCAL plugins when localPluginsEnabled is true", () => { const { service, state } = makeService({ localListings: [] }); state.installedPlugins = [ @@ -2590,3 +2629,226 @@ describe("getPostComposerInit", () => { }); }); }); + +describe("user-granted fetch origins", () => { + async function makeInstalledService({ + permissions = { userFetch: true }, + } = {}) { + const { state, provider } = makeProvider(); + const service = makeServiceWithRealBridge({ provider }); + await service.prefManager.addInstalledPlugin({ + id: "alpha", + name: "Alpha", + version: "1.0.0", + author: "Someone", + repo: "ow/alpha", + enabled: true, + permissions, + }); + return { service, state }; + } + + function callRequest(service, url) { + return service.pluginBridge._hostCallHandlers.get("requestFetchPermission")( + { pluginId: "alpha" }, + { url }, + ); + } + + function storedEntry(state) { + return state.installedPlugins.find((plugin) => plugin.id === "alpha"); + } + + it("throws for a plugin without the userFetch permission", async () => { + const { service } = await makeInstalledService({ permissions: {} }); + await assert.rejects( + () => callRequest(service, "https://api.example.com/v1"), + /userFetch/, + ); + }); + + it("grants the origin when the user confirms", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1/chat"); + await respondToConfirm(true); + assert.equal(await requesting, true); + assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [ + "https://api.example.com/*", + ]); + assert( + isFetchAllowed( + "https://api.example.com/anything", + service._getPermissionsForPlugin("alpha"), + ), + ); + }); + + it("does not write the grant into the manifest permissions", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + // Laundering a user grant into entry.permissions would make a later + // manifest claiming the same host diff to nothing. + assert.deepEqual(storedEntry(state).permissions, { userFetch: true }); + }); + + it("resolves true without prompting when already granted", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + assert.equal( + await callRequest(service, "https://api.example.com/other"), + true, + ); + assert.equal( + document.querySelector('[data-testid="fetch-permission-prompt"]'), + null, + ); + }); + + it("resolves false without prompting for a url that can't be an origin", async () => { + const { service } = await makeInstalledService(); + assert.equal(await callRequest(service, "http://example.com/"), false); + assert.equal(await callRequest(service, "not a url"), false); + assert.equal( + document.querySelector('[data-testid="fetch-permission-prompt"]'), + null, + ); + }); + + it("grants nothing when the user declines", async () => { + const { service, state } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(false); + assert.equal(await requesting, false); + assert.equal(storedEntry(state).userGrantedFetchOrigins, undefined); + }); + + it("prompts again after a decline", async () => { + const { service, state } = await makeInstalledService(); + const declining = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(false); + await declining; + + const retrying = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + assert.equal(await retrying, true); + assert.deepEqual(storedEntry(state).userGrantedFetchOrigins, [ + "https://api.example.com/*", + ]); + }); + + it("refuses a second request while a prompt is open", async () => { + const { service } = await makeInstalledService(); + const first = callRequest(service, "https://api.example.com/v1"); + const second = await callRequest(service, "https://other.example.com/v1"); + assert.equal(second, false); + await respondToConfirm(true); + assert.equal(await first, true); + }); + + it("ignores a stored grant that isn't a normalizable origin", async () => { + const { service } = await makeInstalledService(); + await service.prefManager.updateInstalledPlugin("alpha", (entry) => ({ + ...entry, + userGrantedFetchOrigins: ["https://*/*", "http://evil.example.com/*", 42], + })); + const permissions = service._getPermissionsForPlugin("alpha"); + assert.deepEqual(permissions.fetch ?? [], []); + assert(!isFetchAllowed("https://evil.example.com/", permissions)); + }); + + it("merges user grants with manifest patterns", async () => { + const { service } = await makeInstalledService({ + permissions: { userFetch: true, fetch: ["https://declared.example/*"] }, + }); + const requesting = callRequest(service, "https://granted.example/v1"); + await respondToConfirm(true); + await requesting; + const permissions = service._getPermissionsForPlugin("alpha"); + assert(isFetchAllowed("https://declared.example/x", permissions)); + assert(isFetchAllowed("https://granted.example/x", permissions)); + }); + + it("revokes a granted origin", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + await service.revokeUserGrantedFetchOrigin( + "alpha", + "https://api.example.com/*", + ); + assert.deepEqual(service.getUserGrantedFetchOrigins("alpha"), []); + assert( + !isFetchAllowed( + "https://api.example.com/x", + service._getPermissionsForPlugin("alpha"), + ), + ); + }); + + it("exposes granted origins to the plugin", async () => { + const { service } = await makeInstalledService(); + const requesting = callRequest(service, "https://api.example.com/v1"); + await respondToConfirm(true); + await requesting; + const origins = await service.pluginBridge._hostCallHandlers.get( + "getUserGrantedFetchOrigins", + )({ pluginId: "alpha" }, {}); + assert.deepEqual(origins, ["https://api.example.com/*"]); + }); +}); + +describe("preview installs and userFetch", () => { + // Preview's guarantee is "runs with nothing granted"; a plugin that can + // prompt for origins at runtime must not slip through it. + it("refuses to preview a plugin declaring userFetch", async () => { + const { state, provider } = makeProvider(); + const service = makeServiceWithRealBridge({ provider }); + service.remoteRegistry = { + getListing: async () => ({ id: "alpha", repo: "ow/alpha" }), + getListings: async () => [{ id: "alpha", repo: "ow/alpha" }], + }; + service.sourceProvider = { + getLiveManifest: async () => ({ + id: "alpha", + name: "Alpha", + version: "1.0.0", + permissions: { userFetch: true }, + }), + }; + + await service._installPreviewPlugin("alpha"); + + assert.deepEqual(state.installedPlugins, []); + }); +}); + +describe("updating a plugin that adds userFetch", () => { + it("prompts with the new scope and stores it", async () => { + const { service, state } = makeService({ + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], + liveManifests: { + alpha: { id: "alpha", name: "Alpha", version: "1.0.0" }, + }, + }); + await service.installPlugin("alpha"); + service.sourceProvider.getLiveManifest = async () => ({ + id: "alpha", + name: "Alpha", + version: "1.1.0", + permissions: { userFetch: true }, + }); + + const updating = service.updatePlugin("alpha"); + await respondToConfirm(true); + assert.deepEqual(await updating, { updated: true, version: "1.1.0" }); + const entry = state.installedPlugins.find( + (plugin) => plugin.id === "alpha", + ); + assert.deepEqual(entry.permissions, { userFetch: true }); + }); +}); -- knotmirror 0.0.1 From 54b6bc6ef0c44850ef4a23fae3d2e6d3f20e950f Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 18:46:42 -0500 Subject: [PATCH 07/25] Shorten delay for showing global loading spinner --- package.json | 2 +- src/index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e5dd42a8..52552395 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.178", + "version": "0.18.179", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/index.html b/src/index.html index 669f8079..2a9860c6 100644 --- a/src/index.html +++ b/src/index.html @@ -34,13 +34,13 @@ `; } - // Show loading state if the app hasn't loaded within 2 seconds, and show an error state if it hasn't loaded within 10 seconds. + // Show loading state if the app hasn't loaded within 1 second, and show an error state if it hasn't loaded within 10 seconds. setTimeout(() => { if (window.hasLoaded || window.hasErrored) { return; } showGlobalLoadingState(); - }, 2000); + }, 1000); setTimeout(() => { if (window.hasLoaded || window.hasErrored) { -- knotmirror 0.0.1 From f9f342d18322cc6b07e0a59734875c2973307076 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 18:51:45 -0500 Subject: [PATCH 08/25] Update plugin input styles --- package.json | 2 +- src/css/style.css | 40 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 52552395..87fd2c8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.179", + "version": "0.18.180", "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 e2b6c029..79b97ee3 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -10751,17 +10751,49 @@ h2.setting-item-name { } .setting-item-dropdown, -.plugin-content textarea { +.setting-item-text-input, +.setting-item-textarea, +.plugin-content textarea, +.plugin-content + :is( + input:not([type]), + input[type="text"], + input[type="search"], + input[type="email"], + input[type="url"], + input[type="tel"], + input[type="password"], + input[type="number"] + ) { font-size: 16px; - padding: 8px 10px; + font-family: inherit; + padding: 10px 12px; border-radius: var(--text-input-border-radius); - border: var(--hair) solid var(--generic-border-color); + border: var(--hair) solid var(--post-border-color); background: var(--text-input-background-color); color: var(--text-color); } +.setting-item-text-input:focus, +.setting-item-textarea:focus, +.plugin-content textarea:focus, +.plugin-content + :is( + input:not([type]), + input[type="text"], + input[type="search"], + input[type="email"], + input[type="url"], + input[type="tel"], + input[type="password"], + input[type="number"] + ):focus { + outline: 2px solid var(--highlight-color); + outline-offset: -1px; +} + +.setting-item-textarea, .plugin-content textarea { - font-family: inherit; resize: vertical; } -- knotmirror 0.0.1 From 9d59916d3f8e513a4da4e890a22f14a05aaf8705 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 23:41:53 -0500 Subject: [PATCH 09/25] Update label styles --- package.json | 2 +- src/css/style.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 87fd2c8c..3da236ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.180", + "version": "0.18.181", "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 79b97ee3..8683cc89 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1864,6 +1864,8 @@ container-link { color: var(--text-color-muted); width: fit-content; border-radius: var(--label-badge-border-radius); + border: none; + background-color: inherit; } .label-badge-image { -- knotmirror 0.0.1 From 7fcee921965c4c71ae962496cf77d53eadff10ae Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 13 Aug 2026 23:47:01 -0500 Subject: [PATCH 10/25] Remove home view opacity hack --- package.json | 2 +- src/js/views/home.view.js | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/package.json b/package.json index 3da236ad..949411a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.181", + "version": "0.18.182", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 42e9f4e7..1cf4bade 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -416,13 +416,7 @@ class HomeView extends View { root.addEventListener("page-restore", (e) => { const scrollY = e.detail?.scrollY ?? 0; - root.style.opacity = "0"; window.scrollTo(0, scrollY); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - root.style.opacity = "1"; - }); - }); for (const observer of postSeenObservers.values()) { observer.connect(); } -- knotmirror 0.0.1 From dc732d6e450ca32a4b5b48346e02622133250a85 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 14 Aug 2026 00:16:41 -0500 Subject: [PATCH 11/25] Update icons --- package.json | 2 +- src/css/style.css | 4 ++-- src/img/icons/custom/inbox.svg | 2 +- src/img/icons/custom/message-plus-line.svg | 2 +- src/img/icons/custom/reply.svg | 2 +- src/img/icons/custom/repost.svg | 2 +- src/js/templates/icons/inboxIcon.template.js | 16 ++++++++++++---- .../templates/icons/messagePlusIcon.template.js | 16 ++++++++++++---- src/js/templates/icons/replyIcon.template.js | 9 +++++++-- src/js/templates/icons/repostIcon.template.js | 16 ++++++++++++++-- 10 files changed, 52 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 949411a4..b582033e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.182", + "version": "0.18.183", "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 8683cc89..870ec5f8 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1950,7 +1950,7 @@ container-link { } .small-post .repost-label .icon path { - fill: var(--text-color-muted); + stroke: var(--text-color-muted); } .small-post .missing-post-indicator { @@ -3346,7 +3346,7 @@ image-carousel { } .notification-icon .repost-icon path { - fill: var(--highlight-color); + stroke: var(--highlight-color); } .notification-icon.verified-icon svg { diff --git a/src/img/icons/custom/inbox.svg b/src/img/icons/custom/inbox.svg index 888a6bdc..0b8665d3 100644 --- a/src/img/icons/custom/inbox.svg +++ b/src/img/icons/custom/inbox.svg @@ -1 +1 @@ - + diff --git a/src/img/icons/custom/message-plus-line.svg b/src/img/icons/custom/message-plus-line.svg index d8e4c8f1..69134f19 100644 --- a/src/img/icons/custom/message-plus-line.svg +++ b/src/img/icons/custom/message-plus-line.svg @@ -1 +1 @@ - + diff --git a/src/img/icons/custom/reply.svg b/src/img/icons/custom/reply.svg index d5a509aa..a8a3b16d 100644 --- a/src/img/icons/custom/reply.svg +++ b/src/img/icons/custom/reply.svg @@ -1 +1 @@ - + diff --git a/src/img/icons/custom/repost.svg b/src/img/icons/custom/repost.svg index f3620b1d..e759385d 100644 --- a/src/img/icons/custom/repost.svg +++ b/src/img/icons/custom/repost.svg @@ -1 +1 @@ - + diff --git a/src/js/templates/icons/inboxIcon.template.js b/src/js/templates/icons/inboxIcon.template.js index b9adaef8..2a7c12f7 100644 --- a/src/js/templates/icons/inboxIcon.template.js +++ b/src/js/templates/icons/inboxIcon.template.js @@ -11,10 +11,18 @@ export function inboxIconTemplate() { fill="none" > + `; diff --git a/src/js/templates/icons/messagePlusIcon.template.js b/src/js/templates/icons/messagePlusIcon.template.js index 9d8c9eea..97e939ac 100644 --- a/src/js/templates/icons/messagePlusIcon.template.js +++ b/src/js/templates/icons/messagePlusIcon.template.js @@ -11,10 +11,18 @@ export function messagePlusIconTemplate() { fill="none" > + `; diff --git a/src/js/templates/icons/replyIcon.template.js b/src/js/templates/icons/replyIcon.template.js index 5ab7cba5..397ecee4 100644 --- a/src/js/templates/icons/replyIcon.template.js +++ b/src/js/templates/icons/replyIcon.template.js @@ -1,16 +1,21 @@ import { html } from "/js/lib/lit-html.js"; +// Source: src/img/icons/custom/reply.svg export function replyIconTemplate() { return html` `; } diff --git a/src/js/templates/icons/repostIcon.template.js b/src/js/templates/icons/repostIcon.template.js index bd5fe3a2..97e98f0c 100644 --- a/src/js/templates/icons/repostIcon.template.js +++ b/src/js/templates/icons/repostIcon.template.js @@ -1,16 +1,28 @@ import { html } from "/js/lib/lit-html.js"; +// Source: src/img/icons/custom/repost.svg export function repostIconTemplate() { return html` + `; } -- knotmirror 0.0.1 From c77316365c563c83aa38aa5ed58e82261b2c5e7c Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 14 Aug 2026 00:24:40 -0500 Subject: [PATCH 12/25] Share scroll restore logic --- package.json | 2 +- src/js/app.js | 8 +- src/js/router.js | 14 ++ src/js/views/bookmarks.view.js | 10 +- src/js/views/chat.view.js | 10 +- src/js/views/chatRequests.view.js | 10 +- src/js/views/communityPluginListing.view.js | 4 - src/js/views/communityPlugins.view.js | 1 - src/js/views/feedDetail.view.js | 10 +- src/js/views/feeds.view.js | 10 +- src/js/views/groupChatDetails.view.js | 12 +- src/js/views/hashtag.view.js | 5 - src/js/views/home.view.js | 4 +- src/js/views/installedPlugins.view.js | 12 -- src/js/views/listDetail.view.js | 10 +- src/js/views/lists.view.js | 10 +- src/js/views/notifications.view.js | 1 - src/js/views/pluginPage.view.js | 4 - src/js/views/pluginSettings.view.js | 4 - src/js/views/postLikes.view.js | 12 +- src/js/views/postQuotes.view.js | 12 +- src/js/views/postReposts.view.js | 12 +- src/js/views/profile.view.js | 9 -- src/js/views/profileFollowers.view.js | 12 +- src/js/views/profileFollowing.view.js | 12 +- src/js/views/profileKnownFollowers.view.js | 12 +- src/js/views/settings.view.js | 4 - src/js/views/settings/advanced.view.js | 4 - src/js/views/settings/appearance.view.js | 4 - src/js/views/settings/blockedAccounts.view.js | 4 - src/js/views/settings/mutedAccounts.view.js | 4 - src/js/views/settings/mutedWords.view.js | 4 - src/js/views/settings/notifications.view.js | 4 - tests/e2e/specs/concerns/scroll.test.js | 60 ++++++++ tests/unit/specs/router.test.js | 133 +++++++++++++++--- 35 files changed, 223 insertions(+), 220 deletions(-) diff --git a/package.json b/package.json index b582033e..cc75a890 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.183", + "version": "0.18.184", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/app.js b/src/js/app.js index 9adceb0d..28d79496 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -255,10 +255,12 @@ export async function main() { router.addRoute(["/", "/intent/compose"], () => homeView, { layoutOptions: { activeNavItem: "home" }, + scrollRestore: "always", }); router.addRoute("/login", () => loginView, { layout: false }); router.addRoute("/notifications", () => notificationsView, { layoutOptions: { activeNavItem: "notifications" }, + scrollRestore: "always", }); router.addRoute("/messages/inbox", () => chatRequestsView, { layoutOptions: { activeNavItem: "chat" }, @@ -268,6 +270,7 @@ export async function main() { }); router.addRoute("/messages/:convoId", () => chatDetailView, { layoutOptions: { activeNavItem: "chat" }, + scrollRestore: "manual", }); router.addRoute("/messages", () => chatView, { layoutOptions: { activeNavItem: "chat" }, @@ -283,6 +286,7 @@ export async function main() { }); router.addRoute("/search", () => searchView, { layoutOptions: { activeNavItem: "search" }, + scrollRestore: "manual", }); router.addRoute("/hashtag/:tag", () => hashtagView); router.addRoute("/profile/:handleOrDid/feed/:rkey", () => feedDetailView); @@ -299,7 +303,9 @@ export async function main() { "/profile/:handleOrDid/post/:rkey/reposts", () => postRepostsView, ); - router.addRoute("/profile/:handleOrDid/post/:rkey", () => postThreadView); + router.addRoute("/profile/:handleOrDid/post/:rkey", () => postThreadView, { + scrollRestore: "manual", + }); router.addRoute( "/profile/:handleOrDid/known-followers", () => profileKnownFollowersView, diff --git a/src/js/router.js b/src/js/router.js index 08250ced..9f273eeb 100644 --- a/src/js/router.js +++ b/src/js/router.js @@ -272,6 +272,20 @@ export class Router extends EventEmitter { const scrollY = this.scrollStates.get(path) ?? 0; this.currentPage.classList.remove("page-hidden"); this.currentPage.classList.add("page-visible"); + // Scroll before dispatching so a "manual" view's own scroll wins + const scrollRestore = routeInfo.options.scrollRestore ?? "back"; + switch (scrollRestore) { + case "always": + window.scrollTo(0, scrollY); + break; + case "back": + window.scrollTo(0, isBack ? scrollY : 0); + break; + case "manual": + break; + default: + console.warn(`unknown scrollRestore type: ${scrollRestore}`); + } this.currentPage.dispatchEvent( new CustomEvent("page-restore", { detail: { diff --git a/src/js/views/bookmarks.view.js b/src/js/views/bookmarks.view.js index 81a3db6a..03f4e102 100644 --- a/src/js/views/bookmarks.view.js +++ b/src/js/views/bookmarks.view.js @@ -66,14 +66,8 @@ class BookmarksView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await loadBookmarks({ reload: true }); - } + if (e.detail?.isBack) return; + await loadBookmarks({ reload: true }); }); } } diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index 6aef6ced..0f218525 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -283,14 +283,8 @@ class ChatView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await loadConvoList({ reload: true }); - } + if (e.detail?.isBack) return; + await loadConvoList({ reload: true }); }); } } diff --git a/src/js/views/chatRequests.view.js b/src/js/views/chatRequests.view.js index 7fc4530c..631c35f5 100644 --- a/src/js/views/chatRequests.view.js +++ b/src/js/views/chatRequests.view.js @@ -261,14 +261,8 @@ class ChatRequestsView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadConvoRequestList({ reload: true }); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadConvoRequestList({ reload: true }); }); } } diff --git a/src/js/views/communityPluginListing.view.js b/src/js/views/communityPluginListing.view.js index c76ff5ff..e167eaa6 100644 --- a/src/js/views/communityPluginListing.view.js +++ b/src/js/views/communityPluginListing.view.js @@ -285,10 +285,6 @@ class CommunityPluginListingView extends View { await loadListings(); await loadDetails(); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/communityPlugins.view.js b/src/js/views/communityPlugins.view.js index 9f61c77b..239d9dd3 100644 --- a/src/js/views/communityPlugins.view.js +++ b/src/js/views/communityPlugins.view.js @@ -152,7 +152,6 @@ class CommunityPluginsView extends View { }); root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); loadListings(); }); } diff --git a/src/js/views/feedDetail.view.js b/src/js/views/feedDetail.view.js index eb02ec45..5cd446e8 100644 --- a/src/js/views/feedDetail.view.js +++ b/src/js/views/feedDetail.view.js @@ -145,14 +145,8 @@ class FeedDetailView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await loadFeed({ reload: true }); - } + if (e.detail?.isBack) return; + await loadFeed({ reload: true }); }); } } diff --git a/src/js/views/feeds.view.js b/src/js/views/feeds.view.js index 9ce672d6..460b46a6 100644 --- a/src/js/views/feeds.view.js +++ b/src/js/views/feeds.view.js @@ -303,14 +303,8 @@ class FeedsView extends View { root.addEventListener("page-restore", async (e) => { resetEditingState(); - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadPinnedItems(); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadPinnedItems(); }); } } diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index e424fee7..adec59be 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -340,16 +340,8 @@ class GroupChatDetailsView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await loadConvoDetails({ reload: true }); - } + if (e.detail?.isBack) return; + await loadConvoDetails({ reload: true }); }); } } diff --git a/src/js/views/hashtag.view.js b/src/js/views/hashtag.view.js index bde96bcd..20ddada4 100644 --- a/src/js/views/hashtag.view.js +++ b/src/js/views/hashtag.view.js @@ -119,11 +119,6 @@ class HashtagView extends View { root.addEventListener("page-enter", async () => { await loadCurrentFeed(); }); - - root.addEventListener("page-restore", (e) => { - const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); - }); } } diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 1cf4bade..fd933638 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -414,9 +414,7 @@ class HomeView extends View { await loadCurrentFeed(); }); - root.addEventListener("page-restore", (e) => { - const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); + root.addEventListener("page-restore", () => { for (const observer of postSeenObservers.values()) { observer.connect(); } diff --git a/src/js/views/installedPlugins.view.js b/src/js/views/installedPlugins.view.js index d5f572b1..993758b8 100644 --- a/src/js/views/installedPlugins.view.js +++ b/src/js/views/installedPlugins.view.js @@ -344,18 +344,6 @@ class InstalledPluginsView extends View { root, ); }); - - root.addEventListener("page-restore", (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - } - }); } } diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index 474d0dfd..23c1a1fa 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -431,14 +431,8 @@ class ListDetailView extends View { root.addEventListener("page-restore", async (e) => { userHasScrolled = false; - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await loadListAndFeeds({ reload: true }); - } + if (e.detail?.isBack) return; + await loadListAndFeeds({ reload: true }); }); } } diff --git a/src/js/views/lists.view.js b/src/js/views/lists.view.js index 22d9a000..479c8ee5 100644 --- a/src/js/views/lists.view.js +++ b/src/js/views/lists.view.js @@ -92,14 +92,8 @@ class ListsView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - await loadLists({ reload: true }); - } + if (e.detail?.isBack) return; + await loadLists({ reload: true }); }); } } diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index ea657d15..9bc506b5 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -841,7 +841,6 @@ class NotificationsView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); if (scrollY <= 200) { await loadNotifications({ reload: true }); } diff --git a/src/js/views/pluginPage.view.js b/src/js/views/pluginPage.view.js index 9ff70f50..4b327c57 100644 --- a/src/js/views/pluginPage.view.js +++ b/src/js/views/pluginPage.view.js @@ -100,10 +100,6 @@ class PluginPageView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/pluginSettings.view.js b/src/js/views/pluginSettings.view.js index fd7502b0..b87169b7 100644 --- a/src/js/views/pluginSettings.view.js +++ b/src/js/views/pluginSettings.view.js @@ -99,10 +99,6 @@ class PluginSettingsView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/postLikes.view.js b/src/js/views/postLikes.view.js index 07b5ce07..923e8e0a 100644 --- a/src/js/views/postLikes.view.js +++ b/src/js/views/postLikes.view.js @@ -100,16 +100,8 @@ class PostLikesView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadPostLikes(postUri); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadPostLikes(postUri); }); } } diff --git a/src/js/views/postQuotes.view.js b/src/js/views/postQuotes.view.js index 8ab37509..f2d9268c 100644 --- a/src/js/views/postQuotes.view.js +++ b/src/js/views/postQuotes.view.js @@ -103,16 +103,8 @@ class PostQuotesView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadPostQuotes(postUri); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadPostQuotes(postUri); }); } } diff --git a/src/js/views/postReposts.view.js b/src/js/views/postReposts.view.js index 2320b86b..2ecfd88f 100644 --- a/src/js/views/postReposts.view.js +++ b/src/js/views/postReposts.view.js @@ -99,16 +99,8 @@ class PostRepostsView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadPostReposts(postUri); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadPostReposts(postUri); }); } } diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index 5d8c7b1b..ed329c12 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -581,15 +581,6 @@ class ProfileView extends View { dataLayer.requests.loadProfileChatStatus(profile.did); } }); - - root.addEventListener("page-restore", (e) => { - const { isBack, scrollY } = e.detail; - if (isBack) { - window.scrollTo(0, scrollY); - } else { - window.scrollTo(0, 0); - } - }); } } diff --git a/src/js/views/profileFollowers.view.js b/src/js/views/profileFollowers.view.js index 89cfaaad..76430194 100644 --- a/src/js/views/profileFollowers.view.js +++ b/src/js/views/profileFollowers.view.js @@ -110,16 +110,8 @@ class ProfileFollowersView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadProfileFollowers(profileDid); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadProfileFollowers(profileDid); }); } } diff --git a/src/js/views/profileFollowing.view.js b/src/js/views/profileFollowing.view.js index ad0bb90b..368f05ef 100644 --- a/src/js/views/profileFollowing.view.js +++ b/src/js/views/profileFollowing.view.js @@ -108,16 +108,8 @@ class ProfileFollowingView extends View { }); root.addEventListener("page-restore", async (e) => { - const scrollY = e.detail?.scrollY ?? 0; - const isBack = e.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadProfileFollows(profileDid); - } + if (e.detail?.isBack) return; + await dataLayer.requests.loadProfileFollows(profileDid); }); } } diff --git a/src/js/views/profileKnownFollowers.view.js b/src/js/views/profileKnownFollowers.view.js index 52848b11..1ddcd33f 100644 --- a/src/js/views/profileKnownFollowers.view.js +++ b/src/js/views/profileKnownFollowers.view.js @@ -96,16 +96,8 @@ class ProfileKnownFollowersView extends View { }); root.addEventListener("page-restore", async (event) => { - const scrollY = event.detail?.scrollY ?? 0; - const isBack = event.detail?.isBack ?? false; - if (isBack) { - if (scrollY > 0) { - window.scrollTo(0, scrollY); - } - } else { - window.scrollTo(0, 0); - await dataLayer.requests.loadKnownFollowers(profileDid); - } + if (event.detail?.isBack) return; + await dataLayer.requests.loadKnownFollowers(profileDid); }); } } diff --git a/src/js/views/settings.view.js b/src/js/views/settings.view.js index b8291fa0..a2792b79 100644 --- a/src/js/views/settings.view.js +++ b/src/js/views/settings.view.js @@ -396,10 +396,6 @@ class SettingsView extends View { loadOtherAccounts(); }); - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); - // Account actions navigate away with the pending spinner showing; if the // user comes back via the back/forward cache the document is restored // as-is, so reset the stuck pending state. diff --git a/src/js/views/settings/advanced.view.js b/src/js/views/settings/advanced.view.js index bc3c3007..2f0ac0b4 100644 --- a/src/js/views/settings/advanced.view.js +++ b/src/js/views/settings/advanced.view.js @@ -277,10 +277,6 @@ class SettingsAdvancedView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/settings/appearance.view.js b/src/js/views/settings/appearance.view.js index d22a0385..d2bbc1ce 100644 --- a/src/js/views/settings/appearance.view.js +++ b/src/js/views/settings/appearance.view.js @@ -147,10 +147,6 @@ class SettingsAppearanceView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/settings/blockedAccounts.view.js b/src/js/views/settings/blockedAccounts.view.js index 01737c2d..89c599d5 100644 --- a/src/js/views/settings/blockedAccounts.view.js +++ b/src/js/views/settings/blockedAccounts.view.js @@ -81,10 +81,6 @@ class SettingsBlockedAccountsView extends View { root.addEventListener("page-enter", async () => { await loadMore(); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/settings/mutedAccounts.view.js b/src/js/views/settings/mutedAccounts.view.js index 708f8dce..f469709b 100644 --- a/src/js/views/settings/mutedAccounts.view.js +++ b/src/js/views/settings/mutedAccounts.view.js @@ -78,10 +78,6 @@ class SettingsMutedAccountsView extends View { root.addEventListener("page-enter", async () => { await loadMore(); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/settings/mutedWords.view.js b/src/js/views/settings/mutedWords.view.js index f3ef7c0a..c571705b 100644 --- a/src/js/views/settings/mutedWords.view.js +++ b/src/js/views/settings/mutedWords.view.js @@ -358,10 +358,6 @@ class SettingsMutedWordsView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/src/js/views/settings/notifications.view.js b/src/js/views/settings/notifications.view.js index 88799b20..6d560de5 100644 --- a/src/js/views/settings/notifications.view.js +++ b/src/js/views/settings/notifications.view.js @@ -103,10 +103,6 @@ class SettingsNotificationsView extends View { root, ); }); - - root.addEventListener("page-restore", () => { - window.scrollTo(0, 0); - }); } } diff --git a/tests/e2e/specs/concerns/scroll.test.js b/tests/e2e/specs/concerns/scroll.test.js index cf5e17b2..a99add73 100644 --- a/tests/e2e/specs/concerns/scroll.test.js +++ b/tests/e2e/specs/concerns/scroll.test.js @@ -55,4 +55,64 @@ test.describe("Scroll position restoration", () => { // Verify the post we scrolled to is still visible (scroll position restored) await expect(targetPost).toBeVisible({ timeout: 10000 }); }); + + test.describe("on a settings subpage", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("should restore on back and reset to the top on a forward visit", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.mutedWords = Array.from({ length: 40 }, (_, i) => ({ + value: `mutedword${i + 1}`, + targets: ["content"], + })); + await mockServer.setup(page); + + await login(page); + await page.goto("/settings"); + + const openMutedWords = () => + page.locator('[data-testid="settings-nav-muted-words"]').click(); + const view = page.locator("#settings-muted-words-view"); + + await openMutedWords(); + await expect(view.locator('[data-testid="muted-word-list"]')).toBeVisible( + { + timeout: 10000, + }, + ); + + await view + .locator('[data-testid="muted-word-item"]') + .last() + .scrollIntoViewIfNeeded(); + const scrollY = await page.evaluate(() => window.scrollY); + expect(scrollY).toBeGreaterThan(0); + + await page.goBack(); + await expect(page.locator("#settings-view")).toBeVisible({ + timeout: 10000, + }); + + // Forward navigation to the cached page starts at the top + await openMutedWords(); + await expect(view).toBeVisible({ timeout: 10000 }); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + + // ...and the position saved for that visit is restored on back + await view + .locator('[data-testid="muted-word-item"]') + .last() + .scrollIntoViewIfNeeded(); + await page.goBack(); + await expect(page.locator("#settings-view")).toBeVisible({ + timeout: 10000, + }); + await page.goForward(); + await expect + .poll(() => page.evaluate(() => window.scrollY)) + .toBe(scrollY); + }); + }); }); diff --git a/tests/unit/specs/router.test.js b/tests/unit/specs/router.test.js index cb417aa6..92af5eaa 100644 --- a/tests/unit/specs/router.test.js +++ b/tests/unit/specs/router.test.js @@ -28,6 +28,27 @@ function mountRouter(router, { layout = null } = {}) { }; } +// JSDOM's window.scrollY is a read-only getter, so temporarily override it to +// simulate the page being scrolled before we navigate away. +function withScrollY(value, callback) { + const original = Object.getOwnPropertyDescriptor(window, "scrollY"); + Object.defineProperty(window, "scrollY", { + value, + configurable: true, + }); + return (async () => { + try { + return await callback(); + } finally { + if (original) { + Object.defineProperty(window, "scrollY", original); + } else { + delete window.scrollY; + } + } + })(); +} + describe("constructor and initialization", () => { it("should initialize with empty routes", () => { const router = new Router(); @@ -1088,27 +1109,6 @@ describe("page cache", () => { }); describe("scroll position persistence", () => { - // JSDOM's window.scrollY is a read-only getter, so temporarily override it to - // simulate the page being scrolled before we navigate away. - function withScrollY(value, callback) { - const original = Object.getOwnPropertyDescriptor(window, "scrollY"); - Object.defineProperty(window, "scrollY", { - value, - configurable: true, - }); - return (async () => { - try { - return await callback(); - } finally { - if (original) { - Object.defineProperty(window, "scrollY", original); - } else { - delete window.scrollY; - } - } - })(); - } - function createRouter() { const router = new Router(); mountRouter(router); @@ -1168,3 +1168,94 @@ describe("scroll position persistence", () => { assert.deepEqual(restoredScrollY, 175); }); }); + +describe("scrollRestore", () => { + let scrollTo; + + beforeEach(() => { + scrollTo = mock.method(window, "scrollTo", () => {}); + }); + + afterEach(() => { + scrollTo.mock.restore(); + }); + + function scrollCalls() { + return scrollTo.mock.calls.map((call) => call.arguments); + } + + function createRouter(scrollRestore) { + const router = new Router(); + mountRouter(router); + router.addRoute( + "/a", + () => Promise.resolve({}), + scrollRestore ? { scrollRestore } : {}, + ); + router.addRoute("/b", () => Promise.resolve({})); + router.renderRoute(() => {}); + return router; + } + + // Returns to /a after leaving it scrolled to 175 + async function revisit(router, { isBack }) { + await router.load("/a"); + await withScrollY(175, () => router.load("/b")); + scrollTo.mock.resetCalls(); + await router.load("/a", { isBack }); + } + + it("restores the saved position on back navigation by default", async () => { + const router = createRouter(); + + await revisit(router, { isBack: true }); + + assert.deepEqual(scrollCalls(), [[0, 175]]); + }); + + it("scrolls to the top on forward navigation by default", async () => { + const router = createRouter(); + + await revisit(router, { isBack: false }); + + assert.deepEqual(scrollCalls(), [[0, 0]]); + }); + + it("restores the saved position on forward navigation when set to always", async () => { + const router = createRouter("always"); + + await revisit(router, { isBack: false }); + + assert.deepEqual(scrollCalls(), [[0, 175]]); + }); + + it("still restores the saved position on back navigation when set to always", async () => { + const router = createRouter("always"); + + await revisit(router, { isBack: true }); + + assert.deepEqual(scrollCalls(), [[0, 175]]); + }); + + it("does not touch the scroll position when set to manual", async () => { + const router = createRouter("manual"); + + await revisit(router, { isBack: false }); + await revisit(router, { isBack: true }); + + assert.deepEqual(scrollCalls(), []); + }); + + it("scrolls before dispatching page-restore so a view can override it", async () => { + const router = createRouter(); + await router.load("/a"); + let scrolledBeforeDispatch = null; + router.pages.get("/a").el.addEventListener("page-restore", () => { + scrolledBeforeDispatch = scrollCalls(); + }); + + await revisit(router, { isBack: true }); + + assert.deepEqual(scrolledBeforeDispatch, [[0, 175]]); + }); +}); -- knotmirror 0.0.1 From 1020db31977c75eb3149734b6a1d7c9724ab2237 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 14 Aug 2026 00:57:22 -0500 Subject: [PATCH 13/25] Update avatar styles --- package.json | 2 +- src/css/style.css | 29 +++++++------ src/js/components/account-switcher-dialog.js | 4 +- src/js/templates/avatarGroup.template.js | 4 +- src/js/templates/footer.template.js | 2 +- src/js/views/chatDetail.view.js | 1 - .../templates/avatarGroup.template.test.js | 43 +++++++++++++++++++ 7 files changed, 64 insertions(+), 21 deletions(-) create mode 100644 tests/unit/specs/templates/avatarGroup.template.test.js diff --git a/package.json b/package.json index cc75a890..e8628ecc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.184", + "version": "0.18.185", "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 870ec5f8..2febc4ff 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -874,7 +874,8 @@ input[type="color"]::-moz-color-swatch { width: var(--avatar-size-post); height: var(--avatar-size-post); border-radius: var(--avatar-border-radius); - background-color: var(--generic-border-color); + border: var(--hair) solid var(--post-border-color); + background-color: var(--skeleton-color); } header { @@ -1216,12 +1217,6 @@ tab-bar:not([full-width]) { border: var(--hair) solid var(--post-border-color); } -.sidebar-profile-avatar .avatar-placeholder { - border-radius: var(--avatar-border-radius); - background-color: var(--skeleton-color); - border: var(--hair) solid var(--post-border-color); -} - .sidebar-profile-info { margin-bottom: 12px; } @@ -1556,12 +1551,9 @@ button.sidebar-plugin-nav-item { height: var(--avatar-size-footer-nav); } -.footer-nav-item .avatar-image-placeholder { +.footer-nav-item .avatar-placeholder { width: var(--avatar-size-footer-nav); height: var(--avatar-size-footer-nav); - border-radius: var(--avatar-border-radius); - background-color: var(--skeleton-color); - border: var(--hair) solid var(--post-border-color); } .footer-nav-item[disabled] { @@ -3568,7 +3560,8 @@ image-carousel { flex-shrink: 0; } -.convo-avatar .avatar-image-frame { +.convo-avatar .avatar-image-frame, +.convo-avatar .avatar-placeholder { width: var(--avatar-size-convo); height: var(--avatar-size-convo); } @@ -3579,10 +3572,16 @@ image-carousel { height: var(--avatar-size-convo); } +.avatar-group > .avatar-placeholder { + width: 100%; + height: 100%; +} + .avatar-group-item { position: absolute; } +.avatar-group-item .avatar, .avatar-group-item .avatar-image-frame { display: block; width: 100%; @@ -4122,7 +4121,8 @@ image-carousel { align-self: flex-start; } -.message-avatar .avatar-image-frame { +.message-avatar .avatar-image-frame, +.message-avatar .avatar-placeholder { width: var(--avatar-size-message); height: var(--avatar-size-message); } @@ -5071,6 +5071,7 @@ chat-input .rich-text-input-placeholder { .reaction-row-avatar .avatar, .reaction-row-avatar .avatar-image-frame, +.reaction-row-avatar .avatar-placeholder, .reaction-row-avatar .avatar-image { width: 42px; height: 42px; @@ -8486,7 +8487,7 @@ context-menu-item-group > context-menu-item > button { } .account-switcher-item .avatar, -.account-switcher-item .avatar-image-placeholder, +.account-switcher-item .avatar-placeholder, .account-switcher-item .skeleton-avatar { width: 40px; height: 40px; diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index 5042448c..9da2ba3f 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -181,9 +181,7 @@ class AccountSwitcherDialog extends Component { author: profile, clickAction: "none", }) - : html`
`} + : html`
`}