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``}
diff --git a/src/js/templates/avatarGroup.template.js b/src/js/templates/avatarGroup.template.js
index d2bc2174..30eb883b 100644
--- a/src/js/templates/avatarGroup.template.js
+++ b/src/js/templates/avatarGroup.template.js
@@ -3,7 +3,9 @@ import { avatarTemplate } from "/js/templates/avatar.template.js";
export function avatarGroupTemplate({ authors }) {
if (authors.length === 0) {
- return html``;
+ return html``;
}
if (authors.length === 1) {
return avatarTemplate({ author: authors[0], clickAction: "none" });
diff --git a/src/js/templates/footer.template.js b/src/js/templates/footer.template.js
index 25f58aac..fff673db 100644
--- a/src/js/templates/footer.template.js
+++ b/src/js/templates/footer.template.js
@@ -83,7 +83,7 @@ export function footerTemplate({
template: () =>
html`${currentUser
? avatarTemplate({ author: currentUser, clickAction: "none" })
- : html``}`,
+ : html``}`,
},
];
diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js
index 33af1e6a..714e23a9 100644
--- a/src/js/views/chatDetail.view.js
+++ b/src/js/views/chatDetail.view.js
@@ -1297,7 +1297,6 @@ class ChatDetailView extends View {
const reversedMessages = messages.toReversed();
const messageGroups = groupMessages(reversedMessages, currentUserDid);
const days = groupMessageGroupsByDay(messageGroups);
- // const message
return html`
{
diff --git a/tests/unit/specs/templates/avatarGroup.template.test.js b/tests/unit/specs/templates/avatarGroup.template.test.js
new file mode 100644
index 00000000..75cf66ed
--- /dev/null
+++ b/tests/unit/specs/templates/avatarGroup.template.test.js
@@ -0,0 +1,43 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js";
+import { render } from "/js/lib/lit-html.js";
+import { createProfile } from "../../../shared/factories.js";
+
+describe("avatarGroupTemplate", () => {
+ function renderTemplate(authors) {
+ const container = document.createElement("div");
+ render(avatarGroupTemplate({ authors }), container);
+ return container;
+ }
+
+ function makeAuthors(count) {
+ return Array.from({ length: count }, (_unused, index) =>
+ createProfile({
+ did: `did:plc:member${index}`,
+ handle: `member${index}.bsky.social`,
+ displayName: `Member ${index}`,
+ }),
+ );
+ }
+
+ it("should render a placeholder inside the group wrapper when there are no authors", () => {
+ const container = renderTemplate([]);
+ const group = container.querySelector(".avatar-group");
+ assert(group !== null);
+ assert(group.querySelector(".avatar-placeholder") !== null);
+ });
+
+ it("should render a single avatar without the group wrapper", () => {
+ const container = renderTemplate(makeAuthors(1));
+ assert(container.querySelector(".avatar-group") === null);
+ assert(container.querySelector("[data-testid='avatar']") !== null);
+ });
+
+ it("should stack at most four avatars", () => {
+ const container = renderTemplate(makeAuthors(6));
+ const group = container.querySelector("[data-testid='avatar-group']");
+ assert(group.classList.contains("avatar-group-4"));
+ assert.equal(group.querySelectorAll(".avatar-group-item").length, 4);
+ });
+});
--
knotmirror 0.0.1
From 3a814b4b3ffc0bb538231e2cfad7e30d43ccdfdc Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 01:14:57 -0500
Subject: [PATCH 14/25] Dynamic import emoji picker
---
package.json | 2 +-
src/js/components/emoji-picker-dialog.js | 20 ++++++++++++++++++--
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index e8628ecc..00263a20 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.18.185",
+ "version": "0.18.187",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/js/components/emoji-picker-dialog.js b/src/js/components/emoji-picker-dialog.js
index 4193e02f..56116a41 100644
--- a/src/js/components/emoji-picker-dialog.js
+++ b/src/js/components/emoji-picker-dialog.js
@@ -1,12 +1,24 @@
import { Component } from "/js/components/component.js";
import { scrollLocks } from "/js/scrollLocks.js";
-import "/js/lib/emoji-picker-element.js";
+
+let loadPickerPromise = null;
+
+function loadPickerComponent() {
+ loadPickerPromise ??= import("/js/lib/emoji-picker-element.js").catch(
+ (error) => {
+ loadPickerPromise = null;
+ console.error("Failed to load emoji-picker-element", error);
+ },
+ );
+ return loadPickerPromise;
+}
class EmojiPickerDialog extends Component {
connectedCallback() {
if (this._initialized) {
return;
}
+ loadPickerComponent();
this.scrollLock = null;
this.isOpen = false;
this._initialized = true;
@@ -46,7 +58,11 @@ class EmojiPickerDialog extends Component {
this.isOpen = true;
this._dialog = dialog;
this._picker = picker;
- this._positionPicker();
+ loadPickerComponent().then(() => {
+ if (this._picker === picker) {
+ this._positionPicker();
+ }
+ });
this._reposition = () => this._positionPicker();
window.addEventListener("resize", this._reposition);
}
--
knotmirror 0.0.1
From c9fa3e0fbf83dbbd5bfada48a18b582fb7c4d9c8 Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 01:35:05 -0500
Subject: [PATCH 15/25] Lazy load feed images
---
package.json | 2 +-
src/js/templates/postEmbed.template.js | 6 ++++--
src/js/templates/postFeed.template.js | 10 ++++++++++
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 00263a20..31a61332 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.18.187",
+ "version": "0.18.188",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js
index 461c6443..62920c9c 100644
--- a/src/js/templates/postEmbed.template.js
+++ b/src/js/templates/postEmbed.template.js
@@ -115,7 +115,7 @@ function showNestedEmbed(embed) {
return true;
}
-function condensedMediaTemplate({ embed }) {
+function condensedMediaTemplate({ embed, lazyLoadImages }) {
if (embed?.$type === "app.bsky.embed.images#view") {
return html`
${embed.images
@@ -126,6 +126,7 @@ function condensedMediaTemplate({ embed }) {
class="quoted-post-media-thumb"
src="${image.thumb}"
alt="${image.alt || ""}"
+ loading=${lazyLoadImages ? "lazy" : "eager"}
/>`,
)}
`;
@@ -137,6 +138,7 @@ function condensedMediaTemplate({ embed }) {
class="quoted-post-media-thumb"
src="${embed.thumbnail}"
alt="${embed.alt || ""}"
+ loading=${lazyLoadImages ? "lazy" : "eager"}
/>
@@ -243,7 +245,7 @@ export function quotedPostTemplate({
`
: ""}
${embed && condensed
- ? condensedMediaTemplate({ embed })
+ ? condensedMediaTemplate({ embed, lazyLoadImages })
: embed && showNestedEmbed(embed)
? html`
${postEmbedTemplate({
diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js
index 597a4aec..4cb7610e 100644
--- a/src/js/templates/postFeed.template.js
+++ b/src/js/templates/postFeed.template.js
@@ -5,6 +5,9 @@ import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js";
import { linkToPost } from "/js/navigation.js";
import { isEmptyPost, isBlockedPost } from "/js/dataHelpers.js";
+// Posts likely to be above the fold on load; lazy load the rest
+const EAGER_IMAGE_ITEM_COUNT = 5;
+
function feedFeedbackMessageTemplate({ post }) {
// Attach post URI, we use it to maintain scroll position when feedback is sent
return html`
@@ -46,6 +49,7 @@ function replyContextTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
}) {
const root = reply.root;
const parent = reply.parent;
@@ -81,6 +85,7 @@ function replyContextTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
isParent: true,
})}
`
@@ -112,6 +117,7 @@ function replyContextTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
isParent: true,
})}
`
@@ -131,6 +137,7 @@ function feedItemTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
}) {
const post = feedItem.post;
const reply = feedItem.reply;
@@ -170,6 +177,7 @@ function feedItemTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
})
: ""}
${postTemplate({
@@ -191,6 +199,7 @@ function feedItemTemplate({
replyToBlocked,
enableFeedFeedback,
pluginService,
+ lazyLoadImages,
})}
`;
@@ -263,6 +272,7 @@ export function postFeedTemplate({
onClickShowMore,
enableFeedFeedback,
pluginService,
+ lazyLoadImages: i >= EAGER_IMAGE_ITEM_COUNT,
}),
)}
`;
--
knotmirror 0.0.1
From 4ae40abf46cd9b8a4c588604c7b175f6bcc6593c Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 02:10:08 -0500
Subject: [PATCH 16/25] Chunk css and split out build helpers
---
build-support/assetPaths.js | 26 ++
build-support/contentHash.js | 77 ++++
build-support/localPlugins.js | 71 ++++
.../modulepreload.js | 0
build-support/splitCss.js | 75 ++++
eleventy.config.js | 140 +------
functions/_middleware.js | 5 +-
package.json | 2 +-
scripts/serve-static.js | 25 +-
src/css/style.css | 364 +++++++++---------
tests/e2e/base.js | 2 +-
11 files changed, 460 insertions(+), 327 deletions(-)
create mode 100644 build-support/assetPaths.js
create mode 100644 build-support/contentHash.js
create mode 100644 build-support/localPlugins.js
rename modulepreload.js => build-support/modulepreload.js (100%)
create mode 100644 build-support/splitCss.js
diff --git a/build-support/assetPaths.js b/build-support/assetPaths.js
new file mode 100644
index 00000000..af66a19e
--- /dev/null
+++ b/build-support/assetPaths.js
@@ -0,0 +1,26 @@
+export const MIME = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".md": "text/markdown; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".avif": "image/avif",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+};
+
+// Excludes HTML and unknown extensions
+export function isAssetPath(pathname) {
+ const match = /\.[^./]+$/.exec(pathname);
+ if (!match) return false;
+ const type = MIME[match[0].toLowerCase()];
+ return Boolean(type) && !type.startsWith("text/html");
+}
diff --git a/build-support/contentHash.js b/build-support/contentHash.js
new file mode 100644
index 00000000..13c80108
--- /dev/null
+++ b/build-support/contentHash.js
@@ -0,0 +1,77 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { linkHtml } from "./modulepreload.js";
+import { splitCss } from "./splitCss.js";
+
+async function transformFiles(filePaths, replacer) {
+ await Promise.all(
+ filePaths.map(async (filePath) => {
+ const content = await fs.promises.readFile(filePath, "utf-8");
+ const updated = await replacer(content);
+ if (content !== updated) await fs.promises.writeFile(filePath, updated);
+ }),
+ );
+}
+
+// Rewrite every JS and CSS reference to a content-hashed filename so the build
+// output can be cached forever (see src/_headers). JS is remapped through an
+// import map; the stylesheet is split on its @chunk markers first, so a change
+// to one area of the CSS only invalidates that chunk.
+export async function applyContentHashing({ outputDir, htmlFiles, cacheSalt }) {
+ const buildBaseUrl = pathToFileURL(path.resolve(outputDir) + path.sep);
+
+ const hashedUrlPath = (filePath) => {
+ const hash = crypto
+ .createHash("sha256")
+ .update(cacheSalt)
+ .update(fs.readFileSync(filePath))
+ .digest("hex")
+ .slice(0, 10);
+ const urlPath =
+ "/" + path.relative(outputDir, filePath).split(path.sep).join("/");
+ return [urlPath, urlPath.replace(/(\.[^.]+)$/, `.${hash}$1`)];
+ };
+
+ const imports = {};
+ for (const filePath of fs.globSync(`${outputDir}/js/**/*.js`)) {
+ const [urlPath, hashed] = hashedUrlPath(filePath);
+ imports[urlPath] = hashed;
+ }
+
+ const stylesheetPath = path.join(outputDir, "css", "style.css");
+ const cssUrlPaths = [];
+ const cssLinkTags = splitCss(fs.readFileSync(stylesheetPath, "utf-8"))
+ .map((chunk) => {
+ const chunkPath = path.join(outputDir, "css", `${chunk.name}.css`);
+ fs.writeFileSync(chunkPath, chunk.css);
+ const [urlPath, hashed] = hashedUrlPath(chunkPath);
+ cssUrlPaths.push([urlPath, hashed]);
+ return ``;
+ })
+ .join("");
+
+ const importMapTag = ``;
+ await transformFiles(htmlFiles, async (content) => {
+ // linkHtml crawls the un-hashed files on disk, so it must run before renaming
+ const linked = await linkHtml(content, {
+ baseUrl: buildBaseUrl,
+ urlMap: imports,
+ });
+ return linked
+ .replace("", `${importMapTag}`)
+ .replace(/]+href="\/css\/style\.css"[^>]*>/, cssLinkTags);
+ });
+ fs.rmSync(stylesheetPath);
+
+ for (const [urlPath, hashed] of [
+ ...Object.entries(imports),
+ ...cssUrlPaths,
+ ]) {
+ fs.renameSync(
+ path.join(outputDir, "." + urlPath),
+ path.join(outputDir, "." + hashed),
+ );
+ }
+}
diff --git a/build-support/localPlugins.js b/build-support/localPlugins.js
new file mode 100644
index 00000000..aff65921
--- /dev/null
+++ b/build-support/localPlugins.js
@@ -0,0 +1,71 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const LOCAL_PLUGINS_DIR = "plugins-local";
+const LOCAL_SUFFIX = "__LOCAL";
+
+export function watchLocalPlugins(eleventyConfig, buildDir) {
+ // Watch the symlinks' real paths, since eleventy won't follow the links.
+ eleventyConfig.addWatchTarget(LOCAL_PLUGINS_DIR);
+ for (const entry of fs.readdirSync(LOCAL_PLUGINS_DIR, {
+ withFileTypes: true,
+ })) {
+ if (!entry.isSymbolicLink()) continue;
+ const realPath = fs.realpathSync(path.join(LOCAL_PLUGINS_DIR, entry.name));
+ eleventyConfig.addWatchTarget(
+ `${realPath}/{manifest.json,main.js,styles.css,README.md}`,
+ );
+ }
+
+ eleventyConfig.on("eleventy.before", () => {
+ const buildPluginsDir = path.join(buildDir, LOCAL_PLUGINS_DIR);
+ const listings = [];
+ fs.mkdirSync(buildPluginsDir, { recursive: true });
+
+ for (const entry of fs.readdirSync(LOCAL_PLUGINS_DIR, {
+ withFileTypes: true,
+ })) {
+ if (!(entry.isDirectory() || entry.isSymbolicLink())) continue;
+ if (entry.name.startsWith(".")) continue;
+
+ const pluginPath = path.join(LOCAL_PLUGINS_DIR, entry.name);
+ const manifestPath = path.join(pluginPath, "manifest.json");
+ const mainPath = path.join(pluginPath, "main.js");
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(mainPath)) continue;
+
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
+ listings.push({
+ id: manifest.id + LOCAL_SUFFIX,
+ name: manifest.name,
+ author: manifest.author,
+ description: manifest.description,
+ });
+
+ const destDir = path.join(buildPluginsDir, manifest.id + LOCAL_SUFFIX);
+ fs.mkdirSync(destDir, { recursive: true });
+ fs.copyFileSync(manifestPath, path.join(destDir, "manifest.json"));
+ fs.copyFileSync(mainPath, path.join(destDir, "main.js"));
+
+ for (const optional of ["styles.css", "README.md"]) {
+ const source = path.join(pluginPath, optional);
+ if (fs.existsSync(source)) {
+ fs.copyFileSync(source, path.join(destDir, optional));
+ }
+ }
+
+ for (const font of manifest.fonts ?? []) {
+ if (typeof font?.file !== "string") continue;
+ const source = path.join(pluginPath, font.file);
+ if (!fs.existsSync(source)) continue;
+ const dest = path.join(destDir, font.file);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(source, dest);
+ }
+ }
+
+ fs.writeFileSync(
+ path.join(buildPluginsDir, "index.json"),
+ JSON.stringify(listings, null, 2),
+ );
+ });
+}
diff --git a/modulepreload.js b/build-support/modulepreload.js
similarity index 100%
rename from modulepreload.js
rename to build-support/modulepreload.js
diff --git a/build-support/splitCss.js b/build-support/splitCss.js
new file mode 100644
index 00000000..98fd81fb
--- /dev/null
+++ b/build-support/splitCss.js
@@ -0,0 +1,75 @@
+const MARKER = /^\/\*\s*@chunk\s+([a-z0-9][a-z0-9-]*)\s*\*\/$/;
+
+// Split a stylesheet on `/* @chunk */` markers into named parts, in
+// source order.
+export function splitCss(css) {
+ const parts = [];
+ let current = { name: null, start: 0 };
+ let index = 0;
+ let depth = 0;
+
+ while (index < css.length) {
+ const char = css[index];
+
+ if (char === "/" && css[index + 1] === "*") {
+ const close = css.indexOf("*/", index + 2);
+ const stop = close === -1 ? css.length : close + 2;
+ const match = MARKER.exec(css.slice(index, stop).trim());
+ if (match) {
+ if (depth !== 0) {
+ throw new Error(
+ `@chunk "${match[1]}" sits at brace depth ${depth}; markers must be at the top level`,
+ );
+ }
+ parts.push({ ...current, end: index });
+ current = { name: match[1], start: stop };
+ }
+ index = stop;
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ const quote = char;
+ index++;
+ while (index < css.length) {
+ if (css[index] === "\\") {
+ index += 2;
+ continue;
+ }
+ if (css[index] === quote) {
+ index++;
+ break;
+ }
+ index++;
+ }
+ continue;
+ }
+
+ if (char === "{") depth++;
+ else if (char === "}") depth--;
+ index++;
+ }
+ parts.push({ ...current, end: css.length });
+
+ const chunks = parts
+ .map((part) => ({ name: part.name, css: css.slice(part.start, part.end) }))
+ .filter((chunk) => chunk.css.trim());
+
+ if (!chunks.some((chunk) => chunk.name !== null)) {
+ throw new Error("stylesheet has no @chunk markers");
+ }
+ if (chunks.some((chunk) => chunk.name === null)) {
+ throw new Error(
+ "stylesheet has rules before the first @chunk marker; every rule must belong to a chunk",
+ );
+ }
+
+ const seen = new Set();
+ for (const chunk of chunks) {
+ if (seen.has(chunk.name)) {
+ throw new Error(`duplicate @chunk name "${chunk.name}"`);
+ }
+ seen.add(chunk.name);
+ }
+ return chunks;
+}
diff --git a/eleventy.config.js b/eleventy.config.js
index 20991976..7e381b05 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -1,19 +1,8 @@
-import { linkHtml } from "./modulepreload.js";
-import { MIME } from "./scripts/serve-static.js";
-import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
-import { pathToFileURL } from "node:url";
-
-async function transformFiles(filePaths, replacer) {
- await Promise.all(
- filePaths.map(async (filePath) => {
- const content = await fs.promises.readFile(filePath, "utf-8");
- const updated = await replacer(content);
- if (content !== updated) await fs.promises.writeFile(filePath, updated);
- }),
- );
-}
+import { isAssetPath } from "./build-support/assetPaths.js";
+import { applyContentHashing } from "./build-support/contentHash.js";
+import { watchLocalPlugins } from "./build-support/localPlugins.js";
const BUILD_DIR = process.env.BUILD_DIR || "build";
@@ -41,69 +30,7 @@ export default async function (eleventyConfig) {
const isDev = process.env.NODE_ENV !== "production";
- // Add watch targets for local plugins
- if (isDev) {
- eleventyConfig.addWatchTarget("plugins-local");
- for (const entry of fs.readdirSync("plugins-local", {
- withFileTypes: true,
- })) {
- if (!entry.isSymbolicLink()) continue;
- const realPath = fs.realpathSync(path.join("plugins-local", entry.name));
- eleventyConfig.addWatchTarget(
- `${realPath}/{manifest.json,main.js,styles.css,README.md}`,
- );
- }
- }
-
- // Copy local plugins into build and generate index
- eleventyConfig.on("eleventy.before", () => {
- if (!isDev) return;
- const buildPluginsDir = path.join(BUILD_DIR, "plugins-local");
- const localPluginsDir = "plugins-local";
- const listings = [];
- fs.mkdirSync(buildPluginsDir, { recursive: true });
- for (const entry of fs.readdirSync(localPluginsDir, {
- withFileTypes: true,
- })) {
- if (!(entry.isDirectory() || entry.isSymbolicLink())) continue;
- if (entry.name.startsWith(".")) continue;
- const pluginPath = path.join(localPluginsDir, entry.name);
- const manifestPath = path.join(pluginPath, "manifest.json");
- const mainPath = path.join(pluginPath, "main.js");
- if (!fs.existsSync(manifestPath) || !fs.existsSync(mainPath)) continue;
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
- listings.push({
- id: manifest.id + "__LOCAL",
- name: manifest.name,
- author: manifest.author,
- description: manifest.description,
- });
- const destDir = path.join(buildPluginsDir, manifest.id + "__LOCAL");
- fs.mkdirSync(destDir, { recursive: true });
- fs.copyFileSync(manifestPath, path.join(destDir, "manifest.json"));
- fs.copyFileSync(mainPath, path.join(destDir, "main.js"));
- const stylesPath = path.join(pluginPath, "styles.css");
- if (fs.existsSync(stylesPath)) {
- fs.copyFileSync(stylesPath, path.join(destDir, "styles.css"));
- }
- const readmePath = path.join(pluginPath, "README.md");
- if (fs.existsSync(readmePath)) {
- fs.copyFileSync(readmePath, path.join(destDir, "README.md"));
- }
- for (const font of manifest.fonts ?? []) {
- if (typeof font?.file !== "string") continue;
- const src = path.join(pluginPath, font.file);
- if (!fs.existsSync(src)) continue;
- const dest = path.join(destDir, font.file);
- fs.mkdirSync(path.dirname(dest), { recursive: true });
- fs.copyFileSync(src, dest);
- }
- }
- fs.writeFileSync(
- path.join(buildPluginsDir, "index.json"),
- JSON.stringify(listings, null, 2),
- );
- });
+ if (isDev) watchLocalPlugins(eleventyConfig, BUILD_DIR);
// Send index for SPA
eleventyConfig.setServerOptions({
@@ -118,8 +45,7 @@ export default async function (eleventyConfig) {
if (url.pathname.includes("reload-client.js")) {
return null;
}
- const ext = path.extname(url.pathname);
- if (ext && MIME[ext] && !MIME[ext].startsWith("text/html")) {
+ if (isAssetPath(url.pathname)) {
return {
status: 404,
headers: { "Content-Type": "text/plain" },
@@ -131,58 +57,16 @@ export default async function (eleventyConfig) {
},
});
- // Cache busting via content-hashed filenames
+ // Create content-hashed filenames
eleventyConfig.on("eleventy.after", async ({ dir, results }) => {
if (isDev) return;
-
- const htmlFiles = results
- .map((result) => result.outputPath)
- .filter((outputPath) => outputPath.endsWith(".html"));
-
- const buildBaseUrl = pathToFileURL(path.resolve(dir.output) + path.sep);
-
- const hashedUrlPath = (filePath) => {
- const hash = crypto
- .createHash("sha256")
- .update(CACHE_SALT)
- .update(fs.readFileSync(filePath))
- .digest("hex")
- .slice(0, 10);
- const urlPath =
- "/" + path.relative(dir.output, filePath).split(path.sep).join("/");
- return [urlPath, urlPath.replace(/(\.[^.]+)$/, `.${hash}$1`)];
- };
-
- const imports = {};
- for (const filePath of fs.globSync(`${dir.output}/js/**/*.js`)) {
- const [urlPath, hashed] = hashedUrlPath(filePath);
- imports[urlPath] = hashed;
- }
- const [cssUrlPath, hashedCssUrlPath] = hashedUrlPath(
- path.join(dir.output, "css", "style.css"),
- );
-
- const importMapTag = ``;
- await transformFiles(htmlFiles, async (content) => {
- // linkHtml crawls the un-hashed files on disk, so it must run before renaming
- const linked = await linkHtml(content, {
- baseUrl: buildBaseUrl,
- urlMap: imports,
- });
- return linked
- .replace("", `${importMapTag}`)
- .replace(`href="${cssUrlPath}"`, `href="${hashedCssUrlPath}"`);
+ await applyContentHashing({
+ outputDir: dir.output,
+ htmlFiles: results
+ .map((result) => result.outputPath)
+ .filter((outputPath) => outputPath.endsWith(".html")),
+ cacheSalt: CACHE_SALT,
});
-
- for (const [urlPath, hashed] of [
- ...Object.entries(imports),
- [cssUrlPath, hashedCssUrlPath],
- ]) {
- fs.renameSync(
- path.join(dir.output, "." + urlPath),
- path.join(dir.output, "." + hashed),
- );
- }
});
return {
diff --git a/functions/_middleware.js b/functions/_middleware.js
index 519c9f6b..c3e132c1 100644
--- a/functions/_middleware.js
+++ b/functions/_middleware.js
@@ -1,7 +1,6 @@
// Send 404s for missing assets in Cloudflare pages
-const ASSET_EXTENSIONS =
- /\.(m?js|css|json|md|svg|png|jpe?g|gif|webp|ico|woff2?)$/;
+import { isAssetPath } from "../build-support/assetPaths.js";
export async function onRequest(context) {
const response = await context.next();
@@ -9,7 +8,7 @@ export async function onRequest(context) {
const isHtml = (response.headers.get("Content-Type") || "").includes(
"text/html",
);
- if (ASSET_EXTENSIONS.test(pathname) && isHtml) {
+ if (isAssetPath(pathname) && isHtml) {
return new Response(`Not found: ${pathname}`, {
status: 404,
headers: {
diff --git a/package.json b/package.json
index 31a61332..8d886ff7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.18.188",
+ "version": "0.18.189",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/scripts/serve-static.js b/scripts/serve-static.js
index dcef4df1..81cbbdac 100644
--- a/scripts/serve-static.js
+++ b/scripts/serve-static.js
@@ -5,29 +5,12 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import { MIME, isAssetPath } from "../build-support/assetPaths.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..", process.env.BUILD_DIR || "build");
const port = parseInt(process.env.PORT || "8081", 10);
-export const MIME = {
- ".html": "text/html; charset=utf-8",
- ".js": "text/javascript; charset=utf-8",
- ".mjs": "text/javascript; charset=utf-8",
- ".css": "text/css; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- ".md": "text/markdown; charset=utf-8",
- ".svg": "image/svg+xml",
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".ico": "image/x-icon",
- ".woff": "font/woff",
- ".woff2": "font/woff2",
-};
-
let indexHtml = null;
function getIndexHtml() {
if (indexHtml === null) {
@@ -81,10 +64,10 @@ function createStaticServer() {
}
}
- if (!ext || !MIME[ext]) {
- send(res, 200, getIndexHtml(), MIME[".html"]);
- } else {
+ if (isAssetPath(req.url.split("?")[0])) {
send(res, 404, `Not found: ${req.url}`, "text/plain; charset=utf-8");
+ } else {
+ send(res, 200, getIndexHtml(), MIME[".html"]);
}
});
}
diff --git a/src/css/style.css b/src/css/style.css
index 2febc4ff..e68d3105 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1,3 +1,4 @@
+/* @chunk base */
:root {
color-scheme: light dark;
}
@@ -421,6 +422,7 @@ plugin-slot:empty {
}
}
+/* @chunk buttons */
.rounded-button {
display: flex;
align-items: center;
@@ -847,6 +849,7 @@ input[type="color"]::-moz-color-swatch {
flex-shrink: 0;
}
+/* @chunk layout */
.avatar-image-frame.labeler-avatar {
border-radius: var(--labeler-avatar-border-radius);
}
@@ -1618,6 +1621,7 @@ button.sidebar-plugin-nav-item {
transform-origin: bottom;
}
+/* @chunk feed */
.feed-end-message {
padding: 32px 16px;
color: var(--text-color-muted);
@@ -1782,6 +1786,7 @@ container-link {
gap: 8px;
}
+/* @chunk post */
.who-can-reply-body span {
display: block;
}
@@ -2829,10 +2834,6 @@ animated-button {
max-width: 100%;
}
-.post-composer-embed-preview {
- position: relative;
-}
-
/* Quoted-post cards carry a margin-top for the feed context; inside preview
containers it would leave the close button flush with the card's top edge. */
.post-composer-embed-preview .quoted-post,
@@ -2862,13 +2863,6 @@ animated-button {
height: 18px;
}
-.post-composer-image-preview {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 12px 16px;
-}
-
.image-preview-item {
position: relative;
width: 120px;
@@ -2915,13 +2909,6 @@ animated-button {
top: -1px;
}
-.post-composer-video-preview {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px 16px;
-}
-
.video-preview-item {
position: relative;
width: 100%;
@@ -3337,6 +3324,7 @@ image-carousel {
stroke: var(--like-color);
}
+/* @chunk notifications */
.notification-icon .repost-icon path {
stroke: var(--highlight-color);
}
@@ -3657,6 +3645,7 @@ image-carousel {
left: 60%;
}
+/* @chunk chat */
.convo-content {
flex: 1;
min-width: 0;
@@ -5156,6 +5145,7 @@ emoji-picker-dialog emoji-picker,
filter: blur(100px);
}
+/* @chunk profile */
.profile-banner-container lightbox-image-group {
display: block;
width: 100%;
@@ -6196,36 +6186,6 @@ button.profile-list-item-button.is-disabled {
border-radius: var(--skeleton-border-radius);
}
-.post-composer.bottom-sheet {
- padding-bottom: 0;
- overflow: visible;
- touch-action: pan-y;
- display: flex;
- flex-direction: column;
-}
-
-@media (min-width: 800px) {
- .post-composer.bottom-sheet {
- max-width: 600px;
- max-height: calc(100dvh - 64px);
- touch-action: auto;
- overflow: hidden;
- }
-}
-
-.post-composer-content {
- padding-top: 16px;
- padding-bottom: 0;
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
-}
-
-.post-composer-scroll-area-content {
- padding: 0 16px;
-}
-
.composer-error-banner {
display: flex;
align-items: flex-start;
@@ -6271,49 +6231,7 @@ button.profile-list-item-button.is-disabled {
}
}
-.post-composer-scroll-area {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
-}
-
-@media (min-width: 800px) {
- .post-composer-content {
- padding-top: 10px;
- padding-bottom: 0;
- }
-
- .post-composer-scroll-area-content {
- padding: 0 10px;
- }
-}
-
-.post-composer-drop-overlay {
- position: fixed;
- inset: 0;
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- background: color-mix(in srgb, var(--highlight-color) 15%, transparent);
- border: 2px dashed var(--highlight-color);
-}
-
-.post-composer-drop-overlay span {
- color: var(--text-color);
- font-size: 18px;
- font-weight: 600;
- padding: 12px 20px;
- border-radius: 8px;
- background-color: var(--background-color);
-}
-
-.post-composer::backdrop {
- background-color: var(--overlay-color-medium);
- animation: fade-in 0.2s ease-out;
-}
-
+/* @chunk bottom-sheet */
@keyframes fade-in {
from {
opacity: 0;
@@ -6479,6 +6397,98 @@ button.profile-list-item-button.is-disabled {
}
}
+/* @chunk post-composer */
+.post-composer-embed-preview {
+ position: relative;
+}
+
+.post-composer-image-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 12px 16px;
+}
+
+.post-composer-video-preview {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 16px;
+}
+
+.post-composer.bottom-sheet {
+ padding-bottom: 0;
+ overflow: visible;
+ touch-action: pan-y;
+ display: flex;
+ flex-direction: column;
+}
+
+@media (min-width: 800px) {
+ .post-composer.bottom-sheet {
+ max-width: 600px;
+ max-height: calc(100dvh - 64px);
+ touch-action: auto;
+ overflow: hidden;
+ }
+}
+
+.post-composer-content {
+ padding-top: 16px;
+ padding-bottom: 0;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.post-composer-scroll-area-content {
+ padding: 0 16px;
+}
+
+.post-composer-scroll-area {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+@media (min-width: 800px) {
+ .post-composer-content {
+ padding-top: 10px;
+ padding-bottom: 0;
+ }
+
+ .post-composer-scroll-area-content {
+ padding: 0 10px;
+ }
+}
+
+.post-composer-drop-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ background: color-mix(in srgb, var(--highlight-color) 15%, transparent);
+ border: 2px dashed var(--highlight-color);
+}
+
+.post-composer-drop-overlay span {
+ color: var(--text-color);
+ font-size: 18px;
+ font-weight: 600;
+ padding: 12px 20px;
+ border-radius: 8px;
+ background-color: var(--background-color);
+}
+
+.post-composer::backdrop {
+ background-color: var(--overlay-color-medium);
+ animation: fade-in 0.2s ease-out;
+}
+
.post-composer-top-bar {
display: flex;
justify-content: space-between;
@@ -6626,6 +6636,28 @@ button.profile-list-item-button.is-disabled {
outline: none;
}
+.post-composer-emoji-wrapper {
+ display: flex;
+ align-items: center;
+}
+
+.post-composer-emoji-button {
+ padding: 6px;
+ color: var(--highlight-color);
+}
+
+@media (hover: hover) {
+ .post-composer-emoji-button:not(:disabled):hover {
+ background-color: var(--background-color-secondary);
+ }
+}
+
+.post-composer-emoji-button .emoji-icon {
+ width: 24px;
+ height: 24px;
+}
+
+/* @chunk composer-input */
.rich-text-input {
white-space: pre-wrap;
overflow-wrap: break-word;
@@ -6674,27 +6706,6 @@ button.profile-list-item-button.is-disabled {
color: var(--highlight-color);
}
-.post-composer-emoji-wrapper {
- display: flex;
- align-items: center;
-}
-
-.post-composer-emoji-button {
- padding: 6px;
- color: var(--highlight-color);
-}
-
-@media (hover: hover) {
- .post-composer-emoji-button:not(:disabled):hover {
- background-color: var(--background-color-secondary);
- }
-}
-
-.post-composer-emoji-button .emoji-icon {
- width: 24px;
- height: 24px;
-}
-
.word-count {
display: flex;
align-items: center;
@@ -6949,6 +6960,7 @@ button.profile-list-item-button.is-disabled {
line-height: 1.4;
}
+/* @chunk menus-modals */
.mention-suggestion {
display: flex;
align-items: center;
@@ -7228,6 +7240,63 @@ context-menu-item-group > context-menu-item > button {
}
}
+.plugin-modal {
+ display: flex;
+ flex-direction: column;
+}
+
+.permission-prompt {
+ display: block;
+}
+
+.permission-prompt-intro {
+ display: block;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.permission-prompt-sections {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.permission-prompt-section {
+ border: 1px solid var(--generic-border-color);
+ border-radius: 12px;
+ background-color: var(--background-color-secondary);
+ padding: 12px 14px;
+}
+
+.permission-prompt-section-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-color-muted);
+}
+
+.permission-prompt-list {
+ margin: 8px 0 0;
+ padding-left: 20px;
+ font-size: 13px;
+ color: var(--text-color-secondary);
+}
+
+.permission-prompt-list li + li {
+ margin-top: 4px;
+}
+
+.permission-prompt-list code {
+ font-family: var(--monospace-font, monospace);
+ background: var(--background-color);
+ border: 1px solid var(--generic-border-color);
+ color: var(--text-color);
+ padding: 1px 5px;
+ border-radius: 4px;
+ word-break: break-all;
+}
+
/* Shared modal base styles */
.modal-dialog {
border: var(--hair) solid var(--post-border-color);
@@ -7259,11 +7328,6 @@ context-menu-item-group > context-menu-item > button {
margin-bottom: 12px;
}
-.plugin-modal {
- display: flex;
- flex-direction: column;
-}
-
.plugin-modal .modal-dialog-content {
display: flex;
flex-direction: column;
@@ -7301,58 +7365,6 @@ context-menu-item-group > context-menu-item > button {
color: var(--text-link-color);
}
-.permission-prompt {
- display: block;
-}
-
-.permission-prompt-intro {
- display: block;
- font-size: 15px;
- font-weight: 600;
- color: var(--text-color);
-}
-
-.permission-prompt-sections {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 12px;
-}
-
-.permission-prompt-section {
- border: 1px solid var(--generic-border-color);
- border-radius: 12px;
- background-color: var(--background-color-secondary);
- padding: 12px 14px;
-}
-
-.permission-prompt-section-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-color-muted);
-}
-
-.permission-prompt-list {
- margin: 8px 0 0;
- padding-left: 20px;
- font-size: 13px;
- color: var(--text-color-secondary);
-}
-
-.permission-prompt-list li + li {
- margin-top: 4px;
-}
-
-.permission-prompt-list code {
- font-family: var(--monospace-font, monospace);
- background: var(--background-color);
- border: 1px solid var(--generic-border-color);
- color: var(--text-color);
- padding: 1px 5px;
- border-radius: 4px;
- word-break: break-all;
-}
-
@media (hover: hover) {
.modal-dialog-message a:hover {
text-decoration: underline;
@@ -7448,6 +7460,11 @@ context-menu-item-group > context-menu-item > button {
border-top-color: var(--error-color);
}
+.modal-dialog-buttons-stacked {
+ flex-direction: column;
+}
+
+/* @chunk lightbox */
@media (min-width: 800px) {
.image-alt-text-dialog.bottom-sheet {
max-width: 600px;
@@ -7793,6 +7810,7 @@ context-menu-item-group > context-menu-item > button {
stroke-width: 3;
}
+/* @chunk settings */
.settings-section {
padding: 16px;
border-bottom: var(--hair) solid var(--post-border-color);
@@ -8248,10 +8266,7 @@ context-menu-item-group > context-menu-item > button {
}
}
-.modal-dialog-buttons-stacked {
- flex-direction: column;
-}
-
+/* @chunk drafts */
@media (min-width: 800px) {
.drafts-dialog {
max-width: 500px;
@@ -8398,6 +8413,7 @@ context-menu-item-group > context-menu-item > button {
height: 18px;
}
+/* @chunk views-misc */
.account-switcher-content {
padding: 16px;
/* Extra top padding leaves room for the sheet drag handle on mobile */
@@ -9814,6 +9830,7 @@ toggle-switch {
cursor: not-allowed;
}
+/* @chunk form-dialog */
.new-chat-result .avatar {
flex-shrink: 0;
}
@@ -10361,6 +10378,7 @@ image-cropper {
max-width: 32em;
}
+/* @chunk plugins */
#community-plugins-view .error-state,
#installed-plugins-view .error-state {
padding: 32px 20px;
diff --git a/tests/e2e/base.js b/tests/e2e/base.js
index 3204be22..77771879 100644
--- a/tests/e2e/base.js
+++ b/tests/e2e/base.js
@@ -51,7 +51,7 @@ export const test = baseTest.extend({
if (collectCssCoverage) {
const entries = await page.coverage.stopCSSCoverage();
const styleEntries = entries.filter((entry) =>
- entry.url.includes("/css/style."),
+ entry.url.includes("/css/"),
);
if (styleEntries.length > 0) {
const fileName = `${testInfo.testId}-${testInfo.retry}.json`;
--
knotmirror 0.0.1
From c7a521a2664b661a876e2b6b08e52b79a9a59e51 Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 02:49:50 -0500
Subject: [PATCH 17/25] Remove page-enter scrollTos
---
package.json | 2 +-
src/js/views/bookmarks.view.js | 1 -
src/js/views/home.view.js | 1 -
src/js/views/lists.view.js | 1 -
4 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/package.json b/package.json
index 8d886ff7..3fa57763 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.18.189",
+ "version": "0.18.190",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/js/views/bookmarks.view.js b/src/js/views/bookmarks.view.js
index 03f4e102..2cd1b53c 100644
--- a/src/js/views/bookmarks.view.js
+++ b/src/js/views/bookmarks.view.js
@@ -61,7 +61,6 @@ class BookmarksView extends View {
}
root.addEventListener("page-enter", async () => {
- window.scrollTo(0, 0);
await loadBookmarks();
});
diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js
index fd933638..37719290 100644
--- a/src/js/views/home.view.js
+++ b/src/js/views/home.view.js
@@ -386,7 +386,6 @@ class HomeView extends View {
}
root.addEventListener("page-enter", async () => {
- window.scrollTo(0, 0);
const currentFeedUri = state.$currentFeedUri.get();
await dataLayer.declarative.ensurePinnedItems().then((pinnedItems) => {
if (!pinnedItems.some((item) => item.uri === currentFeedUri)) {
diff --git a/src/js/views/lists.view.js b/src/js/views/lists.view.js
index 479c8ee5..71b060b9 100644
--- a/src/js/views/lists.view.js
+++ b/src/js/views/lists.view.js
@@ -87,7 +87,6 @@ class ListsView extends View {
}
root.addEventListener("page-enter", async () => {
- window.scrollTo(0, 0);
await loadLists();
});
--
knotmirror 0.0.1
From a6de559cb6bb83e25497681c1dbc63df250a920d Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 03:30:11 -0500
Subject: [PATCH 18/25] Hash files in dev
---
eleventy.config.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/eleventy.config.js b/eleventy.config.js
index 7e381b05..001a2c0d 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -59,7 +59,6 @@ export default async function (eleventyConfig) {
// Create content-hashed filenames
eleventyConfig.on("eleventy.after", async ({ dir, results }) => {
- if (isDev) return;
await applyContentHashing({
outputDir: dir.output,
htmlFiles: results
--
knotmirror 0.0.1
From 4eecbc7f6a939d0b5fb4e43960518624d6c45d58 Mon Sep 17 00:00:00 2001
From: Grace Kind
Date: Fri, 14 Aug 2026 04:56:57 -0500
Subject: [PATCH 19/25] Refactor view lifecycle and routing
---
package.json | 2 +-
scripts/node-test-parallel.js | 40 +-
src/js/app.js | 38 +-
src/js/mainLayout.js | 10 +
src/js/router.js | 63 +-
src/js/templates/footer.template.js | 6 +-
src/js/templates/sidebar.template.js | 13 +-
src/js/views/bookmarks.view.js | 110 +-
src/js/views/chat.view.js | 469 ++-
src/js/views/chatDetail.view.js | 3016 ++++++++---------
src/js/views/chatRequests.view.js | 436 ++-
src/js/views/communityPluginListing.view.js | 486 ++-
src/js/views/communityPlugins.view.js | 263 +-
src/js/views/feedDetail.view.js | 252 +-
src/js/views/feeds.view.js | 497 ++-
src/js/views/groupChatDetails.view.js | 289 +-
src/js/views/hashtag.view.js | 199 +-
src/js/views/home.view.js | 721 ++--
src/js/views/installedPlugins.view.js | 607 ++--
src/js/views/listDetail.view.js | 764 ++---
src/js/views/lists.view.js | 144 +-
src/js/views/login.view.js | 921 +++--
src/js/views/notFound.view.js | 37 +-
src/js/views/notifications.view.js | 1450 ++++----
src/js/views/pluginPage.view.js | 182 +-
src/js/views/pluginSettings.view.js | 180 +-
src/js/views/postLikes.view.js | 173 +-
src/js/views/postQuotes.view.js | 180 +-
src/js/views/postReposts.view.js | 174 +-
src/js/views/postThread.view.js | 1151 +++----
src/js/views/profile.view.js | 1005 +++---
src/js/views/profileFollowers.view.js | 187 +-
src/js/views/profileFollowing.view.js | 182 +-
src/js/views/profileKnownFollowers.view.js | 168 +-
src/js/views/search.view.js | 1433 ++++----
src/js/views/settings.view.js | 718 ++--
src/js/views/settings/advanced.view.js | 487 ++-
src/js/views/settings/appearance.view.js | 255 +-
src/js/views/settings/blockedAccounts.view.js | 136 +-
src/js/views/settings/mutedAccounts.view.js | 133 +-
src/js/views/settings/mutedWords.view.js | 630 ++--
src/js/views/settings/notifications.view.js | 172 +-
src/js/views/view.js | 6 -
tests/e2e/specs/views/feeds.view.test.js | 2 +
tests/unit/specs/mainLayout.test.js | 8 +-
tests/unit/specs/router.test.js | 36 +-
.../specs/templates/sidebar.template.test.js | 72 +-
47 files changed, 9122 insertions(+), 9381 deletions(-)
delete mode 100644 src/js/views/view.js
diff --git a/package.json b/package.json
index 3fa57763..7443dca6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.18.190",
+ "version": "0.18.191",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/scripts/node-test-parallel.js b/scripts/node-test-parallel.js
index e298ba4f..43beb9bd 100644
--- a/scripts/node-test-parallel.js
+++ b/scripts/node-test-parallel.js
@@ -63,7 +63,7 @@ for (const { file, size } of filesBySize) {
smallest.totalSize += size;
}
-const runWorker = (files) =>
+const runWorker = (files, workerNumber) =>
new Promise((resolve) => {
const child = spawn(
process.execPath,
@@ -74,14 +74,19 @@ const runWorker = (files) =>
child.stdout.on("data", (chunk) => chunks.push(chunk));
child.stderr.on("data", (chunk) => chunks.push(chunk));
child.on("close", (code) => {
- resolve({ code, output: Buffer.concat(chunks).toString() });
+ resolve({
+ code,
+ files,
+ workerNumber,
+ output: Buffer.concat(chunks).toString(),
+ });
});
});
const results = await Promise.all(
buckets
.filter((bucket) => bucket.files.length > 0)
- .map((bucket) => runWorker(bucket.files)),
+ .map((bucket, index) => runWorker(bucket.files, index + 1)),
);
const SUMMED_STATS = [
@@ -106,6 +111,7 @@ for (const result of results) {
const match = line.match(statLinePattern);
if (match && SUMMED_STATS.includes(match[1])) {
combinedStats[match[1]] += Number(match[2]);
+ if (match[1] === "fail") result.failCount = Number(match[2]);
foundStats = true;
} else if (match && match[1] === "duration_ms") {
slowestWorkerMs = Math.max(slowestWorkerMs, Number(match[2]));
@@ -113,6 +119,7 @@ for (const result of results) {
keptLines.push(line);
}
}
+ result.keptLines = keptLines;
process.stdout.write(keptLines.join("\n"));
}
@@ -127,5 +134,28 @@ if (foundStats) {
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(2);
process.stdout.write(`ℹ total duration ${elapsedSeconds}s\n`);
-const failed = results.some((result) => result.code !== 0);
-process.exitCode = failed ? 1 : 0;
+const TAIL_LINES = 20;
+const failures = results.filter((result) => result.code !== 0);
+
+for (const failure of failures) {
+ process.stdout.write(
+ `\n✖ worker ${failure.workerNumber} exited with code ${failure.code}\n`,
+ );
+ if (failure.failCount === 0) {
+ // Node exits non-zero on an unhandled rejection even when every test passed
+ process.stdout.write(
+ " (0 test failures — likely an unhandled rejection or async activity outliving a test)\n",
+ );
+ }
+ process.stdout.write(` files: ${failure.files.join(" ")}\n`);
+ const tail = failure.keptLines
+ .filter((line) => line.trim() !== "")
+ .slice(-TAIL_LINES);
+ process.stdout.write(
+ ` last ${tail.length} lines of its output:\n${tail
+ .map((line) => ` ${line}`)
+ .join("\n")}\n`,
+ );
+}
+
+process.exitCode = failures.length > 0 ? 1 : 0;
diff --git a/src/js/app.js b/src/js/app.js
index 28d79496..611aeda5 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -254,12 +254,12 @@ export async function main() {
}
router.addRoute(["/", "/intent/compose"], () => homeView, {
- layoutOptions: { activeNavItem: "home" },
+ layoutOptions: { activeNavItem: "home", isNavItemPage: true },
scrollRestore: "always",
});
router.addRoute("/login", () => loginView, { layout: false });
router.addRoute("/notifications", () => notificationsView, {
- layoutOptions: { activeNavItem: "notifications" },
+ layoutOptions: { activeNavItem: "notifications", isNavItemPage: true },
scrollRestore: "always",
});
router.addRoute("/messages/inbox", () => chatRequestsView, {
@@ -273,19 +273,19 @@ export async function main() {
scrollRestore: "manual",
});
router.addRoute("/messages", () => chatView, {
- layoutOptions: { activeNavItem: "chat" },
+ layoutOptions: { activeNavItem: "chat", isNavItemPage: true },
});
router.addRoute("/feeds", () => feedsView, {
- layoutOptions: { activeNavItem: "feeds" },
+ layoutOptions: { activeNavItem: "feeds", isNavItemPage: true },
});
router.addRoute("/lists", () => listsView, {
- layoutOptions: { activeNavItem: "lists" },
+ layoutOptions: { activeNavItem: "lists", isNavItemPage: true },
});
router.addRoute("/bookmarks", () => bookmarksView, {
- layoutOptions: { activeNavItem: "bookmarks" },
+ layoutOptions: { activeNavItem: "bookmarks", isNavItemPage: true },
});
router.addRoute("/search", () => searchView, {
- layoutOptions: { activeNavItem: "search" },
+ layoutOptions: { activeNavItem: "search", isNavItemPage: true },
scrollRestore: "manual",
});
router.addRoute("/hashtag/:tag", () => hashtagView);
@@ -322,12 +322,15 @@ export async function main() {
router.addRoute(["/profile/:handleOrDid", "/profile"], () => profileView, {
layoutOptions: {
activeNavItem: (params) => (isOwnProfile(params) ? "profile" : null),
+ isNavItemPage: (params) => isOwnProfile(params),
},
});
const settingsRouteOptions = {
layoutOptions: { activeNavItem: "settings" },
};
- router.addRoute("/settings", () => settingsView, settingsRouteOptions);
+ router.addRoute("/settings", () => settingsView, {
+ layoutOptions: { activeNavItem: "settings", isNavItemPage: true },
+ });
router.addRoute(
"/settings/appearance",
() => settingsAppearanceView,
@@ -361,11 +364,9 @@ export async function main() {
const pluginsRouteOptions = {
layoutOptions: { activeNavItem: "plugins" },
};
- router.addRoute(
- "/plugins/installed",
- () => installedPluginsView,
- pluginsRouteOptions,
- );
+ router.addRoute("/plugins/installed", () => installedPluginsView, {
+ layoutOptions: { activeNavItem: "plugins", isNavItemPage: !!session },
+ });
router.addRoute(
"/plugin/:pluginId/settings",
() => pluginSettingsView,
@@ -376,11 +377,10 @@ export async function main() {
() => pluginPageView,
pluginsRouteOptions,
);
- router.addRoute(
- "/plugins/community",
- () => communityPluginsView,
- pluginsRouteOptions,
- );
+ router.addRoute("/plugins/community", () => communityPluginsView, {
+ // Logged out, the Plugins nav item points here instead
+ layoutOptions: { activeNavItem: "plugins", isNavItemPage: !session },
+ });
router.addRoute(
"/plugins/community/:pluginId",
() => communityPluginListingView,
@@ -399,7 +399,7 @@ export async function main() {
router.setNotFoundView(() => notFoundView);
router.renderRoute(({ view, params, container, layout }) => {
- return view.render({
+ return view({
root: container,
layout,
router,
diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js
index 7014ae28..a9500e4b 100644
--- a/src/js/mainLayout.js
+++ b/src/js/mainLayout.js
@@ -57,6 +57,7 @@ export function mainLayoutTemplate({
isAuthenticated = true,
currentUser,
activeNavItem,
+ isNavItemPage = false,
numNotifications = 0,
numChatNotifications = 0,
onClickActiveNavItem,
@@ -82,6 +83,7 @@ export function mainLayoutTemplate({
isAuthenticated,
currentUser,
activeNavItem,
+ isNavItemPage,
numNotifications,
numChatNotifications,
onClickActiveItem: onClickActiveNavItem,
@@ -100,6 +102,7 @@ export function mainLayoutTemplate({
isAuthenticated,
currentUser,
activeNavItem,
+ isNavItemPage,
numNotifications,
numChatNotifications,
onClickActiveItem: onClickActiveNavItem,
@@ -166,12 +169,19 @@ export class MainLayout extends Layout {
typeof layoutOptions.activeNavItem === "function"
? layoutOptions.activeNavItem(currentRoute.params)
: (layoutOptions.activeNavItem ?? null);
+ // True when this route is the nav item's own page, rather than somewhere
+ // deeper in its section
+ const isNavItemPage =
+ typeof layoutOptions.isNavItemPage === "function"
+ ? layoutOptions.isNavItemPage(currentRoute.params)
+ : (layoutOptions.isNavItemPage ?? false);
const previewingPlugins = pluginService.getPreviewPlugins();
render(
mainLayoutTemplate({
isAuthenticated,
currentUser,
activeNavItem,
+ isNavItemPage,
numNotifications:
notificationService?.$numNotifications.get() ?? null,
numChatNotifications:
diff --git a/src/js/router.js b/src/js/router.js
index 9f273eeb..9a62a346 100644
--- a/src/js/router.js
+++ b/src/js/router.js
@@ -23,22 +23,30 @@ function bindMiddleClickRedispatch() {
});
}
-export function bindToPage(root, source, event, handler) {
- if (!source) return;
- const usesEmitterApi = typeof source.on === "function";
- const attach = () =>
- usesEmitterApi
- ? source.on(event, handler)
- : source.addEventListener(event, handler);
- const detach = () =>
- usesEmitterApi
- ? source.off(event, handler)
- : source.removeEventListener(event, handler);
+// Runs attach whenever the page becomes active (first entry or a return from
+// the route cache) and detach when it's swapped out.
+function bindActive(root, attach, detach) {
root.addEventListener("page-enter", attach);
root.addEventListener("page-restore", attach);
root.addEventListener("page-exit", detach);
}
+export function bindToPage(root, source, event, handler) {
+ if (!source) return;
+ const usesEmitterApi = typeof source.on === "function";
+ bindActive(
+ root,
+ () =>
+ usesEmitterApi
+ ? source.on(event, handler)
+ : source.addEventListener(event, handler),
+ () =>
+ usesEmitterApi
+ ? source.off(event, handler)
+ : source.removeEventListener(event, handler),
+ );
+}
+
export class Layout extends EventTarget {
slot = null;
mount(container) {}
@@ -47,17 +55,17 @@ export class Layout extends EventTarget {
export function pageEffect(root, callback, options) {
let dispose;
- const attach = () => {
- dispose?.();
- dispose = effect(callback, options);
- };
- const detach = () => {
- dispose?.();
- dispose = null;
- };
- root.addEventListener("page-enter", attach);
- root.addEventListener("page-restore", attach);
- root.addEventListener("page-exit", detach);
+ bindActive(
+ root,
+ () => {
+ dispose?.();
+ dispose = effect(callback, options);
+ },
+ () => {
+ dispose?.();
+ dispose = null;
+ },
+ );
}
const APP_TITLE = document.title;
@@ -77,9 +85,7 @@ export function bindPageTitle(root, callback, options) {
dispose = null;
document.title = APP_TITLE;
};
- root.addEventListener("page-enter", attach);
- root.addEventListener("page-restore", attach);
- root.addEventListener("page-exit", detach);
+ bindActive(root, attach, detach);
}
export class Router extends EventEmitter {
@@ -287,11 +293,8 @@ export class Router extends EventEmitter {
console.warn(`unknown scrollRestore type: ${scrollRestore}`);
}
this.currentPage.dispatchEvent(
- new CustomEvent("page-restore", {
- detail: {
- scrollY,
- isBack,
- },
+ new CustomEvent(isBack ? "page-restore" : "page-enter", {
+ detail: { scrollY },
}),
);
this.emit("page-shown", this.currentPage);
diff --git a/src/js/templates/footer.template.js b/src/js/templates/footer.template.js
index fff673db..3ef061d1 100644
--- a/src/js/templates/footer.template.js
+++ b/src/js/templates/footer.template.js
@@ -40,6 +40,7 @@ export function footerTemplate({
isAuthenticated,
currentUser,
activeNavItem = null,
+ isNavItemPage = false,
numNotifications = 0,
numChatNotifications = 0,
onClickActiveItem,
@@ -109,8 +110,9 @@ export function footerTemplate({
?disabled=${item.disabled}
@long-press=${longPressEnabled ? () => onLongPressProfile() : null}
@click=${(e) => {
- // tap active item to scroll to top
- if (active) {
+ // tap the item for the page you're on to scroll to top; deeper
+ // pages in the section just follow the link
+ if (active && isNavItemPage) {
e.preventDefault();
e.stopPropagation();
onClickActiveItem?.(item.id);
diff --git a/src/js/templates/sidebar.template.js b/src/js/templates/sidebar.template.js
index 5f276f17..a2c5d642 100644
--- a/src/js/templates/sidebar.template.js
+++ b/src/js/templates/sidebar.template.js
@@ -70,6 +70,7 @@ function pluginSidebarItemTemplate({ entry }) {
function sidebarNavTemplate({
menuItems,
activeNavItem,
+ isNavItemPage,
onClickActiveItem,
pluginSidebarItems = [],
}) {
@@ -85,7 +86,7 @@ function sidebarNavTemplate({
data-testid="sidebar-nav-${item.id}"
@click=${function (e) {
const isActive = activeNavItem === item.id;
- if (isActive) {
+ if (isActive && isNavItemPage) {
e.preventDefault();
e.stopPropagation();
onClickActiveItem?.(item.id);
@@ -113,7 +114,11 @@ function sidebarNavTemplate({
`;
}
-function loggedOutSidebarTemplate({ activeNavItem, onClickActiveItem }) {
+function loggedOutSidebarTemplate({
+ activeNavItem,
+ isNavItemPage,
+ onClickActiveItem,
+}) {
const menuItems = [
{
id: "home",
@@ -150,6 +155,7 @@ function loggedOutSidebarTemplate({ activeNavItem, onClickActiveItem }) {
${sidebarNavTemplate({
menuItems,
activeNavItem,
+ isNavItemPage,
onClickActiveItem,
})}