+ Copy this response and paste it into the pairing page.
+
+
+
+
+ ) : (
+
+
+
+ )}
-
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1cd415dc6..7bd512883 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -189,6 +189,10 @@ type E2eConfig = {
* Linux .deb install where Tauri's updater cannot swap the binary).
* Defaults to true for all existing tests. */
autoUpdateSupported?: boolean;
+ /** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */
+ openerError?: string;
+ /** Delay binding signatures so specs can exercise request supersession. */
+ nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -8641,6 +8645,10 @@ export function maybeInstallE2eTauriMocks() {
origin: string;
verificationCode: string;
};
+ const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
+ if (signDelayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, signDelayMs));
+ }
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
return JSON.stringify({
id: "e2e-signed-nostr-binding",
@@ -9654,6 +9662,9 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
);
case "plugin:opener|open_url":
+ if (activeConfig?.mock?.openerError) {
+ throw new Error(activeConfig.mock.openerError);
+ }
openedExternalUrls.push(String((payload as { url: string | URL }).url));
return null;
case "get_e2e_opened_external_urls":
diff --git a/desktop/tests/e2e/nostr-bind.spec.ts b/desktop/tests/e2e/nostr-bind.spec.ts
index b6f3cf86b..17677ea46 100644
--- a/desktop/tests/e2e/nostr-bind.spec.ts
+++ b/desktop/tests/e2e/nostr-bind.spec.ts
@@ -28,21 +28,7 @@ const VALID_REQUEST: NostrBindPayload = {
version: "1",
};
-async function openNostrBind(
- page: Page,
- payload: NostrBindPayload = VALID_REQUEST,
-) {
- await installMockBridge(page);
- await page.goto("/");
- await page.waitForFunction(
- () =>
- typeof (
- window as Window & {
- __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
- }
- ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
- );
-
+async function emitNostrBind(page: Page, payload: NostrBindPayload) {
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
@@ -62,7 +48,24 @@ async function openNostrBind(
payload: nextPayload,
});
}, payload);
+}
+async function openNostrBind(
+ page: Page,
+ payload: NostrBindPayload = VALID_REQUEST,
+ mock?: Parameters[1],
+) {
+ await installMockBridge(page, mock);
+ await page.goto("/");
+ await page.waitForFunction(
+ () =>
+ typeof (
+ window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
+ }
+ ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
+ );
+ await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
@@ -150,7 +153,7 @@ async function shakeCount(page: Page): Promise {
);
}
-test("supports OTP entry, navigation, and paste without signing incomplete input", async ({
+test("supports OTP entry and navigation without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
@@ -174,42 +177,70 @@ test("supports OTP entry, navigation, and paste without signing incomplete input
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
-
- await pasteCode(first, VALID_REQUEST.verificationCode);
- for (const [index, digit] of [...VALID_REQUEST.verificationCode].entries()) {
- await expect(
- page.getByTestId(`nostr-bind-code-digit-${index + 1}`),
- ).toHaveValue(digit);
- }
- await expect(page.getByTestId("nostr-bind-code-digit-6")).toBeFocused();
- await expect(continueButton).toBeEnabled();
});
-test("locks a filled sixth slot and repeats mismatch feedback without signing", async ({
+test("auto-signs exactly once when the sixth correct digit is typed", async ({
page,
}) => {
+ await openNostrBind(page);
+
+ await page.getByTestId("nostr-bind-code-digit-1").click();
+ for (const digit of VALID_REQUEST.verificationCode) {
+ await page.keyboard.type(digit);
+ }
+
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+ await page.waitForTimeout(100);
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+});
+
+test("discards a signature when a newer pairing request arrives", async ({
+ page,
+}) => {
+ await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ VALID_REQUEST.verificationCode,
+ );
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+
+ const nextRequest = {
+ ...VALID_REQUEST,
+ challengeId: "550e8400-e29b-41d4-a716-446655440001",
+ verificationCode: "654321",
+ };
+ await emitNostrBind(page, nextRequest);
+
+ await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
+ await page.waitForTimeout(200);
+ await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
+
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ nextRequest.verificationCode,
+ );
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
+});
+
+test("repeats mismatch feedback without signing", async ({ page }) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
- const last = page.getByTestId("nostr-bind-code-digit-6");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
- await pasteCode(first, VALID_REQUEST.verificationCode);
- await last.press("9");
- await expect(last).toHaveValue("6");
- await expect(continueButton).toBeEnabled();
- await expect.poll(() => shakeCount(page)).toBe(1);
-
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
- await expect.poll(() => shakeCount(page)).toBe(2);
+ await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
- await expect.poll(() => shakeCount(page)).toBe(3);
+ await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
@@ -263,7 +294,6 @@ test("signs a valid request, shows the response, and copies it", async ({
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
@@ -296,11 +326,16 @@ test("signs a valid request, shows the response, and copies it", async ({
),
)
.toBe(signedResponse);
+ await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
+
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
+ await installClipboardStub(page, false);
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
@@ -326,11 +361,38 @@ test("returns a signed response in the callback fragment after consent", async (
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
+ const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
+ await expect(manualFallback).not.toHaveAttribute("open", "");
+ await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
+ await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
+
+ const fallbackSummary = manualFallback.locator("summary");
+ await fallbackSummary.focus();
+ await fallbackSummary.press("Enter");
+ await expect(manualFallback).toHaveAttribute("open", "");
+ const signedResponse = page.getByTestId("nostr-bind-signed-response");
+ await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
+ const copyResponse = page.getByTestId("nostr-bind-copy-response");
+ await expect(copyResponse).toBeVisible();
+ await copyResponse.click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_CLIPBOARD_TEXT__?: string;
+ }
+ ).__BUZZ_E2E_CLIPBOARD_TEXT__,
+ ),
+ )
+ .toBe(await signedResponse.textContent());
+
const callback = await page.evaluate(() => {
const command = (
(
@@ -352,6 +414,36 @@ test("returns a signed response in the callback fragment after consent", async (
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
+test("opens the manual fallback when returning to the browser fails", async ({
+ page,
+}) => {
+ await openNostrBind(
+ page,
+ {
+ ...VALID_REQUEST,
+ callbackUrl: "https://admin.example.com/buzz",
+ returnMode: "browser_fragment_v1",
+ },
+ { openerError: "browser unavailable" },
+ );
+
+ await pasteCode(
+ page.getByTestId("nostr-bind-code-digit-1"),
+ VALID_REQUEST.verificationCode,
+ );
+
+ await expect(
+ page.getByText(
+ "Could not open the browser. Copy the response below to finish manually.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
+ "open",
+ "",
+ );
+ await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
+});
+
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
@@ -361,11 +453,13 @@ test("keeps the signed response available when clipboard access fails", async ({
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
- await page.getByTestId("nostr-bind-sign-and-copy").click();
+ await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
- page.getByText("Buzz couldn't access the clipboard. Try again."),
+ page
+ .getByTestId("nostr-bind-manual-fallback-content")
+ .getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index df366a4c1..5560fc7da 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -206,6 +206,10 @@ type MockBridgeOptions = {
/** Set to false to simulate a Linux .deb install where auto-update is not
* supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */
autoUpdateSupported?: boolean;
+ /** Reject browser opener calls to exercise manual pairing fallback UI. */
+ openerError?: string;
+ /** Delay binding signatures so specs can exercise request supersession. */
+ nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — drive the archive-button gate matrix in
From 896974554937126ec1d9e85f681da84fed9644b2 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Thu, 16 Jul 2026 15:47:35 -0400
Subject: [PATCH 5/7] fix(desktop): enforce observer archive policy
reconciliation on internal builds (#1923)
Signed-off-by: Will Pfleger
---
.github/workflows/ci.yml | 4 +
Justfile | 17 +
desktop/playwright.config.ts | 1 +
.../src/commands/observer_archive.rs | 43 +-
desktop/src/app/AppShell.tsx | 21 +-
.../local-archive/archiveSyncManager.ts | 13 +-
.../ui/LocalArchiveSettingsCard.tsx | 27 +-
.../useObserverArchiveSeed.test.mjs | 381 +++++++++++++-----
.../local-archive/useObserverArchiveSeed.ts | 140 +++----
desktop/src/shared/api/tauriArchive.ts | 4 +-
desktop/src/testing/e2eBridge.ts | 20 +
.../tests/e2e/observer-archive-policy.spec.ts | 124 ++++++
desktop/tests/helpers/bridge.ts | 6 +
13 files changed, 577 insertions(+), 224 deletions(-)
create mode 100644 desktop/tests/e2e/observer-archive-policy.spec.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44dfe5ef2..6a248cecb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -169,6 +169,10 @@ jobs:
run: just desktop-tauri-test
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
+ - name: Desktop Tauri compiled-flag verification
+ run: just desktop-tauri-test-compiled-flags
+ env:
+ CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Upload desktop e2e artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
diff --git a/Justfile b/Justfile
index b21c62a13..3fe33e040 100644
--- a/Justfile
+++ b/Justfile
@@ -193,6 +193,23 @@ desktop-tauri-check: _ensure-sidecar-stubs
desktop-tauri-test: _ensure-sidecar-stubs
cd desktop/src-tauri && cargo test
+# Verify compiled-flag behavior under both compile states (clean + internal).
+# Runs the observer_archive focused test twice with independently supplied
+# expected values; build.rs rerun-if-env-changed triggers recompilation.
+desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
+ #!/usr/bin/env bash
+ set -euo pipefail
+ cd desktop/src-tauri
+ echo "=== Clean build (no flag) → expect false ==="
+ env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \
+ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \
+ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
+ echo "=== Internal build (flag set) → expect true ==="
+ BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \
+ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \
+ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
+ echo "Both compiled states verified."
+
# Build the full desktop Tauri app locally (unsigned, for testing)
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
# pnpm install is unconditional here: release builds must start from a clean dep tree.
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 805fcb289..fa6f9ae19 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -107,6 +107,7 @@ export default defineConfig({
"**/agent-lifecycle-feedback.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
+ "**/observer-archive-policy.spec.ts",
],
use: {
...devices["Desktop Chrome"],
diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs
index f7286b018..707e86b63 100644
--- a/desktop/src-tauri/src/commands/observer_archive.rs
+++ b/desktop/src-tauri/src/commands/observer_archive.rs
@@ -1,4 +1,4 @@
-//! Build-time flag and runtime dev-nest check for observer-feed archive default.
+//! Build-time flag and runtime dev-nest check for observer-feed archive policy.
//!
//! `observer_archive_default_enabled()` returns `true` when either:
//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal
@@ -6,17 +6,18 @@
//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the
//! case for all dev builds launched with `just staging` or `just dev`.
//!
-//! When `true`, the frontend auto-seeds an `owner_p` save subscription for the
-//! current identity on first run, so the observer-feed archive is on by default.
+//! When `true`, the frontend reconciles the observer archive subscription
+//! every startup — unconditionally ensuring kind 24200 exists in the DB
+//! regardless of stale localStorage markers.
//!
//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` —
-//! no auto-seeding; the user opts in manually via the Local Archive settings card.
+//! no reconciliation; the user manages the subscription via Settings.
-/// Returns `true` when observer-feed archive should default to on.
+/// Returns `true` when observer-feed archive policy is enforced.
///
/// True when the build has the internal baked flag set, or when the running
-/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this once
-/// at startup to decide whether to seed the `owner_p` save subscription.
+/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this
+/// every startup to decide whether to reconcile the `owner_p` subscription.
#[tauri::command]
pub fn observer_archive_default_enabled() -> bool {
option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some()
@@ -27,15 +28,27 @@ pub fn observer_archive_default_enabled() -> bool {
mod tests {
use super::*;
+ // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock
+ // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so
+ // the compiled flag is the sole variable. No runner normalization needed.
+ //
+ // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be
+ // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with
+ // `--ignored` under both compile states; general `cargo test` skips it.
#[test]
- fn test_observer_archive_default_enabled_returns_bool() {
- // The command must return a plain bool without panicking.
- // Whether it's true or false depends on the build environment;
- // what we assert here is just that the return type is correct and
- // the function is callable.
+ #[ignore]
+ fn test_observer_archive_default_enabled_matches_expected() {
let result = observer_archive_default_enabled();
- // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT
- // baked in), this should be false.
- assert!(!result, "expected false in OSS/test build");
+ let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect(
+ "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \
+ the dual-compile CI step supplies it; bare `cargo test` is \
+ not sufficient to validate compiled-flag behavior",
+ );
+ let expected = expected_str == "true" || expected_str == "1";
+ assert_eq!(
+ result, expected,
+ "observer_archive_default_enabled() returned {result}, \
+ expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})"
+ );
}
}
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index eadb84d9d..f359f2218 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -54,7 +54,7 @@ import {
} from "@/features/user-status/hooks";
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
-import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed";
+import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed";
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
import { useProfileQuery } from "@/features/profile/hooks";
import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController";
@@ -186,15 +186,18 @@ export function AppShell() {
// relay-owned agents join automatically once identity arrives. Adding a
// guard here would drop managed-agent coverage during startup.
useAgentObserverIngestion();
- useArchiveSync();
- // Defer the archive *seeds* until startup is idle: they're first-run catch-up
- // config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's
- // useArchiveSync's job, which stays eager above. Passing deferredPubkey makes
- // each seed hook wait on its own `if (!pubkey) return` guard until the shell
- // is interactive, so their IPC + sqlite archive open doesn't compete with
- // first paint. The explicit-choice guard inside each hook is unchanged.
+ // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not
+ // deferred) and unconditionally repairs the DB subscription on internal
+ // builds — otherwise frames emitted before the listener opens are lost.
+ const observerReconciled = useObserverArchiveReconciliation(
+ identityQuery.data?.pubkey,
+ );
+ // useArchiveSync must wait for reconciliation, or listeners could open
+ // before kind 24200 is guaranteed present in the subscription.
+ useArchiveSync(observerReconciled);
+ // Kind 44200 is relay-persisted (durable) and stays deferred: missed
+ // startup frames can be replayed, so there's no ordering constraint.
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
- useObserverArchiveSeed(deferredPubkey);
useAgentMetricArchiveSeed(deferredPubkey);
const profileQuery = useProfileQuery();
useRelayAutoHeal();
diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts
index 1361f548b..c97364169 100644
--- a/desktop/src/features/local-archive/archiveSyncManager.ts
+++ b/desktop/src/features/local-archive/archiveSyncManager.ts
@@ -303,16 +303,19 @@ export class ArchiveSyncManager {
import * as React from "react";
/**
- * Starts the ArchiveSyncManager at app-shell mount and tears it down when the
- * component unmounts (community switch). No return value needed — the manager
- * runs entirely in the background.
+ * Starts the ArchiveSyncManager once `ready` is true and tears it down on
+ * unmount. The `ready` gate ensures observer reconciliation completes before
+ * any relay listeners open — kind 24200 is relay-ephemeral, so frames emitted
+ * before the listener opens are permanently lost.
*/
-export function useArchiveSync(): void {
+export function useArchiveSync(ready: boolean): void {
React.useEffect(() => {
+ if (!ready) return;
+
const manager = new ArchiveSyncManager();
void manager.start();
return () => {
manager.destroy();
};
- }, []);
+ }, [ready]);
}
diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
index 883c51987..a3e26dca7 100644
--- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
+++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
@@ -25,6 +25,7 @@ import {
SettingsOptionRow,
} from "@/features/settings/ui/SettingsOptionGroup";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
+import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive";
import { setExplicitObserverArchiveChoice } from "../observerArchivePreference";
import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference";
@@ -66,15 +67,18 @@ function kindSummary(kinds: number[]): string {
type ObserverSectionProps = {
enabled: boolean;
+ policy: boolean | undefined;
toggling: boolean;
onToggle: (checked: boolean) => void;
};
function ObserverArchiveSection({
enabled,
+ policy,
toggling,
onToggle,
}: ObserverSectionProps) {
+ const toggleDisabled = toggling || policy === undefined || policy === true;
return (
@@ -90,15 +94,15 @@ function ObserverArchiveSection({
Archive my agents' observer frames
- Saves kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed
- to your pubkey. These are ephemeral — not stored by the relay — so
- local archiving is the only way to retain them.
+ {policy === true
+ ? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`
+ : `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}
@@ -389,6 +393,17 @@ export function LocalArchiveSettingsCard() {
const [isAddingOpen, setIsAddingOpen] = React.useState(false);
const [observerToggling, setObserverToggling] = React.useState(false);
const [metricToggling, setMetricToggling] = React.useState(false);
+ const [observerPolicy, setObserverPolicy] = React.useState<
+ boolean | undefined
+ >(undefined);
+
+ React.useEffect(() => {
+ observerArchiveDefaultEnabled()
+ .then((on) => setObserverPolicy(on))
+ .catch(() => {
+ // Fail closed: leave as undefined so toggle stays disabled.
+ });
+ }, []);
const pubkey = identityQuery.data?.pubkey ?? "";
@@ -451,6 +466,7 @@ export function LocalArchiveSettingsCard() {
const handleObserverToggle = React.useCallback(
async (checked: boolean) => {
if (!pubkey) return;
+ if (!checked && observerPolicy !== false) return;
setObserverToggling(true);
try {
if (checked) {
@@ -475,7 +491,7 @@ export function LocalArchiveSettingsCard() {
setObserverToggling(false);
}
},
- [pubkey, reload],
+ [pubkey, observerPolicy, reload],
);
const handleMetricToggle = React.useCallback(
@@ -525,6 +541,7 @@ export function LocalArchiveSettingsCard() {
void handleObserverToggle(checked)}
+ policy={observerPolicy}
toggling={observerToggling}
/>
diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs
index 54128282d..4c96b14f8 100644
--- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs
+++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs
@@ -1,156 +1,317 @@
-/**
- * Tests for useObserverArchiveSeed seeding logic.
- *
- * The hook is a thin React wrapper around an async seed routine. We test the
- * behaviour by driving the deps-injection interface directly — no React needed.
- * This mirrors the pattern used in archiveSyncManager.test.mjs.
- */
-
import assert from "node:assert/strict";
import test from "node:test";
-// We call the seeding function directly via deps-injection rather than
-// mounting the hook, so we import the seed-coordination logic by
-// re-implementing a minimal version of `maybeSeed` driven by the same deps
-// interface. The test boundary is the async coordination logic, not React.
+import {
+ isReconciledFor,
+ reconcileObserverArchive,
+} from "./useObserverArchiveSeed.ts";
+import { ArchiveSyncManager } from "./archiveSyncManager.ts";
// ── Fake deps factory ────────────────────────────────────────────────────────
function makeDeps({
- defaultOn = false,
- hasExplicitChoice = false,
+ policyOn = false,
mergeShouldFail = false,
+ flagShouldFail = false,
} = {}) {
- const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] };
+ const calls = { merge: [], setChoice: [] };
return {
calls,
- observerArchiveDefaultEnabled: async () => defaultOn,
+ observerArchiveDefaultEnabled: async () => {
+ if (flagShouldFail) throw new Error("flag check failed");
+ return policyOn;
+ },
mergeSaveSubscriptionKinds: async (kind) => {
if (mergeShouldFail) throw new Error("merge failed");
- calls.mergeSaveSubscriptionKinds.push({ kind });
+ calls.merge.push({ kind });
},
- hasExplicitChoice: (_pubkey) => hasExplicitChoice,
setExplicitChoice: (pubkey, enabled) => {
- calls.setExplicitChoice.push({ pubkey, enabled });
+ calls.setChoice.push({ pubkey, enabled });
},
};
}
-// Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts.
-// Kept in sync with the source by structural mirroring (not bytewise copy) —
-// change the source and update this accordingly.
-const KIND_AGENT_OBSERVER_FRAME = 24200;
-
-async function runSeed(pubkey, deps) {
- if (!pubkey) return;
- if (deps.hasExplicitChoice(pubkey)) return;
-
- let defaultOn;
- try {
- defaultOn = await deps.observerArchiveDefaultEnabled();
- } catch {
- return;
- }
-
- if (!defaultOn) return;
-
- try {
- await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
- } catch {
- return; // transient failure — do NOT set explicit choice
- }
-
- deps.setExplicitChoice(pubkey, true);
+/** Helper: wait for microtasks/promises to settle */
+function tick() {
+ return new Promise((r) => setTimeout(r, 0));
}
-// ── Tests ────────────────────────────────────────────────────────────────────
+// ── Internal policy build ────────────────────────────────────────────────────
-test("test_internal_build_unset_seeds_owner_p_subscription", async () => {
- const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
- await runSeed("pubkey123", deps);
+test("test_internal_policy_marker_null_seeds_24200", async () => {
+ const deps = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pk1", deps);
- assert.equal(
- deps.calls.mergeSaveSubscriptionKinds.length,
- 1,
- "should call mergeSaveSubscriptionKinds once",
- );
- const call = deps.calls.mergeSaveSubscriptionKinds[0];
- assert.equal(call.kind, 24200);
+ assert.equal(deps.calls.merge.length, 1);
+ assert.equal(deps.calls.merge[0].kind, 24200);
+ assert.deepEqual(deps.calls.setChoice, [{ pubkey: "pk1", enabled: true }]);
});
-test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => {
- const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
- await runSeed("pubkey123", deps);
+test("test_internal_policy_marker_0_still_seeds_24200", async () => {
+ const deps = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pk1", deps);
- assert.equal(
- deps.calls.setExplicitChoice.length,
- 1,
- "should persist explicit choice after successful seed",
- );
- assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123");
- assert.equal(deps.calls.setExplicitChoice[0].enabled, true);
+ assert.equal(deps.calls.merge.length, 1, "must merge even with stale marker");
+ assert.equal(deps.calls.merge[0].kind, 24200);
});
-test("test_explicit_choice_set_does_not_reseed", async () => {
- const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true });
- await runSeed("pubkey123", deps);
+test("test_internal_policy_marker_1_still_seeds_24200", async () => {
+ const deps = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pk1", deps);
- assert.equal(
- deps.calls.mergeSaveSubscriptionKinds.length,
- 0,
- "should not call mergeSaveSubscriptionKinds when explicit choice is already set",
- );
- assert.equal(
- deps.calls.setExplicitChoice.length,
- 0,
- "should not update explicit choice when already set",
- );
+ assert.equal(deps.calls.merge.length, 1, "must reconcile even with marker 1");
});
-test("test_oss_build_does_not_seed", async () => {
- const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false });
- await runSeed("pubkey123", deps);
+// ── OSS build — policy-off is a pure no-op ──────────────────────────────────
- assert.equal(
- deps.calls.mergeSaveSubscriptionKinds.length,
- 0,
- "should not call mergeSaveSubscriptionKinds in OSS build",
- );
- assert.equal(
- deps.calls.setExplicitChoice.length,
- 0,
- "should not persist explicit choice in OSS build",
- );
+test("test_oss_marker_null_no_merge", async () => {
+ const deps = makeDeps({ policyOn: false });
+ await reconcileObserverArchive("pk1", deps);
+
+ assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
+ assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
-test("test_merge_failure_does_not_persist_explicit_choice", async () => {
- const deps = makeDeps({
- defaultOn: true,
- hasExplicitChoice: false,
- mergeShouldFail: true,
+test("test_oss_marker_0_no_merge", async () => {
+ const deps = makeDeps({ policyOn: false });
+ await reconcileObserverArchive("pk1", deps);
+
+ assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
+ assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
+});
+
+test("test_oss_marker_1_no_merge", async () => {
+ const deps = makeDeps({ policyOn: false });
+ await reconcileObserverArchive("pk1", deps);
+
+ assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
+ assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
+});
+
+// ── Failure behavior ─────────────────────────────────────────────────────────
+
+test("test_merge_failure_rejects_no_marker_persisted", async () => {
+ const deps = makeDeps({ policyOn: true, mergeShouldFail: true });
+
+ await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
+ message: "merge failed",
});
- await runSeed("pubkey123", deps);
-
assert.equal(
- deps.calls.setExplicitChoice.length,
+ deps.calls.setChoice.length,
0,
- "should NOT persist explicit choice after a transient merge failure",
+ "must not persist marker on merge failure",
);
});
-test("test_empty_pubkey_does_nothing", async () => {
- const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
- await runSeed("", deps);
+test("test_flag_check_failure_rejects", async () => {
+ const deps = makeDeps({ flagShouldFail: true });
- assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
- assert.equal(deps.calls.setExplicitChoice.length, 0);
+ await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
+ message: "flag check failed",
+ });
+ assert.equal(deps.calls.merge.length, 0);
+ assert.equal(deps.calls.setChoice.length, 0);
});
-test("test_undefined_pubkey_does_nothing", async () => {
- const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
- await runSeed(undefined, deps);
+// ── Startup ordering (real ArchiveSyncManager + real reconciler) ─────────────
- assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
- assert.equal(deps.calls.setExplicitChoice.length, 0);
+test("test_archive_sync_blocked_until_reconciliation", async () => {
+ let resolveFlag;
+ const flagPromise = new Promise((resolve) => {
+ resolveFlag = resolve;
+ });
+
+ const subscribeCalls = [];
+ const fakeRelay = {
+ subscribeLive(filter, _callback) {
+ subscribeCalls.push(filter);
+ return Promise.resolve(async () => {});
+ },
+ };
+
+ const reconcilerDeps = {
+ observerArchiveDefaultEnabled: () => flagPromise,
+ mergeSaveSubscriptionKinds: async () => {},
+ setExplicitChoice: () => {},
+ };
+
+ const manager = new ArchiveSyncManager({
+ relayClient: fakeRelay,
+ listSaveSubscriptions: async () => [
+ {
+ scopeType: "owner_p",
+ scopeValue: "pk1",
+ kinds: [24200],
+ identityPubkey: "pk1",
+ relayUrl: "wss://r",
+ createdAt: 0,
+ },
+ ],
+ archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
+ onSubscriptionChange: () => () => {},
+ });
+
+ // Start reconciliation (pending — flag check not yet resolved).
+ const reconciling = reconcileObserverArchive("pk1", reconcilerDeps);
+
+ // Before reconciliation resolves, manager must not have been started.
+ await tick();
+ assert.equal(
+ subscribeCalls.length,
+ 0,
+ "subscribeLive must not run before reconciliation",
+ );
+
+ // Resolve reconciliation — now start the manager (simulating the gate).
+ resolveFlag(true);
+ await reconciling;
+ await manager.start();
+
+ assert.ok(
+ subscribeCalls.length > 0,
+ "subscribeLive must run after gate opens",
+ );
+ const hasOwnerP = subscribeCalls.some((f) => f["#p"]?.length > 0);
+ assert.ok(hasOwnerP, "subscription must use owner_p (#p) filter");
+ const hasKind24200 = subscribeCalls.some((f) => f.kinds?.includes(24200));
+ assert.ok(hasKind24200, "subscription filter must include kind 24200");
+
+ manager.destroy();
+});
+
+test("test_archive_sync_blocked_on_reconciliation_rejection", async () => {
+ const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true });
+
+ const subscribeCalls = [];
+ const fakeRelay = {
+ subscribeLive(filter) {
+ subscribeCalls.push(filter);
+ return Promise.resolve(async () => {});
+ },
+ };
+
+ const manager = new ArchiveSyncManager({
+ relayClient: fakeRelay,
+ listSaveSubscriptions: async () => [
+ {
+ scopeType: "owner_p",
+ scopeValue: "pk1",
+ kinds: [24200],
+ identityPubkey: "pk1",
+ relayUrl: "wss://r",
+ createdAt: 0,
+ },
+ ],
+ archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
+ onSubscriptionChange: () => () => {},
+ });
+
+ // Reconciliation rejects — gate must remain closed.
+ let rejected = false;
+ try {
+ await reconcileObserverArchive("pk1", reconcilerDeps);
+ } catch {
+ rejected = true;
+ }
+ assert.ok(rejected, "reconciliation must reject on merge failure");
+
+ // Manager must NOT start after failed reconciliation.
+ assert.equal(
+ subscribeCalls.length,
+ 0,
+ "subscribeLive must not run after failed reconciliation",
+ );
+
+ manager.destroy();
+});
+
+// ── Identity-scoped readiness (exercises exported isReconciledFor) ──────────
+
+test("test_isReconciledFor_null_returns_false", () => {
+ assert.equal(isReconciledFor(null, "pk1"), false);
+ assert.equal(isReconciledFor(null, undefined), false);
+});
+
+test("test_isReconciledFor_undefined_pubkey_returns_false", () => {
+ assert.equal(isReconciledFor("pk1", undefined), false);
+});
+
+test("test_isReconciledFor_matching_returns_true", () => {
+ assert.equal(isReconciledFor("pk1", "pk1"), true);
+});
+
+test("test_isReconciledFor_mismatch_returns_false", () => {
+ assert.equal(isReconciledFor("pkA", "pkB"), false);
+});
+
+test("test_identity_change_resets_readiness", async () => {
+ let reconciledPubkey = null;
+
+ // Identity A reconciles successfully.
+ const depsA = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pkA", depsA);
+ reconciledPubkey = "pkA";
+ assert.equal(
+ isReconciledFor(reconciledPubkey, "pkA"),
+ true,
+ "A is reconciled",
+ );
+
+ // Identity changes to B — gate must be false before B reconciles.
+ assert.equal(
+ isReconciledFor(reconciledPubkey, "pkB"),
+ false,
+ "gate must be false for new identity before reconciliation",
+ );
+
+ // B reconciles successfully.
+ const depsB = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pkB", depsB);
+ reconciledPubkey = "pkB";
+ assert.equal(
+ isReconciledFor(reconciledPubkey, "pkB"),
+ true,
+ "B is reconciled",
+ );
+ assert.equal(
+ isReconciledFor(reconciledPubkey, "pkA"),
+ false,
+ "gate must be false for previous identity",
+ );
+});
+
+test("test_identity_change_b_failure_stays_closed", async () => {
+ let reconciledPubkey = null;
+
+ // Identity A reconciles successfully.
+ const depsA = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pkA", depsA);
+ reconciledPubkey = "pkA";
+
+ // Identity changes to B — B's reconciliation fails.
+ const depsB = makeDeps({ policyOn: true, mergeShouldFail: true });
+ try {
+ await reconcileObserverArchive("pkB", depsB);
+ reconciledPubkey = "pkB";
+ } catch {
+ // B failed — reconciledPubkey stays "pkA" (stale).
+ }
+
+ // Gate for B must be false (stale A pubkey !== current B).
+ assert.equal(
+ isReconciledFor(reconciledPubkey, "pkB"),
+ false,
+ "gate must be false when B reconciliation fails",
+ );
+});
+
+// ── Metric seed independence ─────────────────────────────────────────────────
+
+test("test_metric_seed_remains_independently_deferrable", async () => {
+ const deps = makeDeps({ policyOn: true });
+ await reconcileObserverArchive("pk1", deps);
+
+ assert.equal(deps.calls.merge.length, 1);
+ assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200");
});
diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts
index f547493da..7d5fd621a 100644
--- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts
+++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts
@@ -1,20 +1,3 @@
-/**
- * First-run seeding for observer-feed archive.
- *
- * When an internal build has `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` set and the
- * current identity has not yet made an explicit choice, this hook
- * auto-creates an `owner_p` save subscription including kind 24200 observer
- * frames, scoped to the current identity's pubkey.
- *
- * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently
- * running metric seed (44200) cannot clobber this kind — the union happens
- * under a single SQLite transaction regardless of await ordering.
- *
- * OSS builds return `false` from `observer_archive_default_enabled` → no-op.
- * After any explicit user action (seeding or opt-out), the localStorage flag
- * prevents re-seeding on subsequent starts.
- */
-
import * as React from "react";
import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds";
@@ -22,93 +5,94 @@ import {
mergeSaveSubscriptionKinds,
observerArchiveDefaultEnabled,
} from "@/shared/api/tauriArchive";
-import {
- hasExplicitObserverArchiveChoice,
- setExplicitObserverArchiveChoice,
-} from "./observerArchivePreference";
+import { setExplicitObserverArchiveChoice } from "./observerArchivePreference";
-/**
- * Deps interface for testing. Production callers pass nothing.
- */
export interface ObserverArchiveSeedDeps {
observerArchiveDefaultEnabled: () => Promise;
mergeSaveSubscriptionKinds: (kind: number) => Promise;
- hasExplicitChoice: (pubkey: string) => boolean;
setExplicitChoice: (pubkey: string, enabled: boolean) => void;
}
const defaultDeps: ObserverArchiveSeedDeps = {
observerArchiveDefaultEnabled,
mergeSaveSubscriptionKinds,
- hasExplicitChoice: hasExplicitObserverArchiveChoice,
setExplicitChoice: setExplicitObserverArchiveChoice,
};
/**
- * Seed the observer-feed archive subscription for `pubkey` once per identity
- * per device on internal builds.
+ * Reconcile observer-feed archive state for `pubkey`.
*
- * @param pubkey - current identity pubkey. When undefined (identity not yet
- * loaded), the hook waits until it becomes available.
- * @param deps - optional dep-injection for tests.
+ * Internal builds (policy flag ON): unconditionally ensure kind 24200 exists
+ * in the DB subscription, regardless of localStorage marker state.
+ *
+ * OSS builds (policy flag OFF): no-op. The Settings toggle is the only
+ * mutation path for OSS users.
+ *
+ * Rejects on failure — callers must not open archive listeners against
+ * unreconciled state.
*/
-export function useObserverArchiveSeed(
+export async function reconcileObserverArchive(
+ pubkey: string,
+ deps: ObserverArchiveSeedDeps = defaultDeps,
+): Promise {
+ const policyOn = await deps.observerArchiveDefaultEnabled();
+ if (!policyOn) return;
+
+ await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
+ deps.setExplicitChoice(pubkey, true);
+}
+
+/**
+ * Pure gate: `true` iff `reconciledPubkey` matches `currentPubkey`.
+ * Exported for direct unit testing without React mount infra.
+ */
+export function isReconciledFor(
+ reconciledPubkey: string | null,
+ currentPubkey: string | undefined,
+): boolean {
+ return (
+ reconciledPubkey !== null &&
+ currentPubkey !== undefined &&
+ reconciledPubkey === currentPubkey
+ );
+}
+
+/**
+ * Runs observer archive reconciliation eagerly when `pubkey` resolves.
+ * Returns `true` only after successful reconciliation for the current
+ * pubkey — archive sync must not start until this is `true`.
+ *
+ * Identity-scoped: changing pubkey resets readiness so the old manager
+ * tears down before the new identity's reconciliation completes.
+ *
+ * On failure: stays `false`; the reconciler retries on next app startup
+ * since no success marker is persisted.
+ */
+export function useObserverArchiveReconciliation(
pubkey: string | undefined,
deps: ObserverArchiveSeedDeps = defaultDeps,
-): void {
+): boolean {
+ const [reconciledPubkey, setReconciledPubkey] = React.useState(
+ null,
+ );
+
React.useEffect(() => {
if (!pubkey) return;
- // Already made an explicit choice for this identity — never re-seed.
- if (deps.hasExplicitChoice(pubkey)) return;
-
let cancelled = false;
- async function maybeSeed(): Promise {
- // pubkey is checked above but TypeScript doesn't narrow across the async
- // boundary — re-guard here so the call below is type-safe.
- if (!pubkey) return;
-
- let defaultOn: boolean;
- try {
- defaultOn = await deps.observerArchiveDefaultEnabled();
- } catch (err) {
- console.warn("[useObserverArchiveSeed] flag check failed:", err);
- return;
- }
-
- if (cancelled) return;
-
- if (!defaultOn) {
- // OSS build (flag off): don't persist a choice — leave null so seeding
- // can still fire if this identity later runs an internal build.
- return;
- }
-
- // Internal build + no prior choice → auto-seed via atomic DB merge.
- try {
- await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
- } catch (err) {
- console.warn(
- "[useObserverArchiveSeed] mergeSaveSubscriptionKinds failed:",
- err,
- );
- // Do NOT set the localStorage flag — a transient failure (relay
- // unreachable, archive DB not yet initialized) should retry on next
- // startup rather than permanently suppress seeding.
- return;
- }
-
- if (cancelled) return;
-
- // Persist the explicit choice so this never re-fires.
- deps.setExplicitChoice(pubkey, true);
- }
-
- void maybeSeed();
+ reconcileObserverArchive(pubkey, deps)
+ .then(() => {
+ if (!cancelled) setReconciledPubkey(pubkey);
+ })
+ .catch((err) => {
+ console.warn("[useObserverArchiveReconciliation] failed:", err);
+ });
return () => {
cancelled = true;
};
}, [pubkey, deps]);
+
+ return isReconciledFor(reconciledPubkey, pubkey);
}
diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts
index 5dcc1013a..0e4be6c9a 100644
--- a/desktop/src/shared/api/tauriArchive.ts
+++ b/desktop/src/shared/api/tauriArchive.ts
@@ -88,11 +88,11 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
// ── API wrappers ─────────────────────────────────────────────────────────────
/**
- * Returns `true` when the build has observer-feed archive default-on.
+ * Returns `true` when observer-feed archive policy is enforced.
*
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
- * once at startup to decide whether to auto-seed an `owner_p` subscription.
+ * every startup to decide whether to reconcile the `owner_p` subscription.
*/
export async function observerArchiveDefaultEnabled(): Promise {
return invokeTauri("observer_archive_default_enabled");
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 7bd512883..448900731 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -240,6 +240,8 @@ type E2eConfig = {
// Seed rows returned by `list_save_subscriptions`. Each entry uses the same
// snake_case wire shape the Rust backend returns so tests can drive the
// LocalArchiveSettingsCard without a real SQLite database.
+ observerArchiveDefaultEnabled?: boolean;
+ agentMetricArchiveDefaultEnabled?: boolean;
saveSubscriptions?: Array<{
scope_type: string;
scope_value: string;
@@ -9782,6 +9784,16 @@ export function maybeInstallE2eTauriMocks() {
// seeds the initial list; create/delete return success shapes so the
// component's reload path behaves correctly.
case "list_save_subscriptions": {
+ const win = window as unknown as Record;
+ if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
+ win.__BUZZ_E2E_IPC_COUNTERS__ = {};
+ }
+ const ipcCounters = win.__BUZZ_E2E_IPC_COUNTERS__ as Record<
+ string,
+ number
+ >;
+ ipcCounters.list_save_subscriptions =
+ (ipcCounters.list_save_subscriptions ?? 0) + 1;
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({
identity_pubkey: ident.pubkey,
@@ -9802,6 +9814,14 @@ export function maybeInstallE2eTauriMocks() {
case "archive_events":
// Returns the ArchiveBatchResult shape the UI expects.
return { persisted: 0, dropped: 0 };
+ case "observer_archive_default_enabled":
+ return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
+ case "agent_metric_archive_default_enabled":
+ return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
+ case "merge_save_subscription_kinds":
+ return null;
+ case "remove_save_subscription_kind":
+ return null;
default:
throw new Error(`Unsupported mocked Tauri command: ${command}`);
}
diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts
new file mode 100644
index 000000000..90f74b539
--- /dev/null
+++ b/desktop/tests/e2e/observer-archive-policy.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+ await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-local-archive").click();
+ const card = page.getByTestId("settings-local-archive");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+ return card;
+}
+
+test.describe("observer archive policy — Settings toggle", () => {
+ test("internal policy: toggle disabled with policy-locked copy", async ({
+ page,
+ }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: true,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeDisabled();
+ await expect(
+ card.getByText(/always on for internal builds/i),
+ ).toBeVisible();
+ });
+
+ test("OSS policy: toggle is functional", async ({ page }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: false,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).toBeChecked();
+ });
+
+ test("unresolved policy (default): toggle disabled", async ({ page }) => {
+ // When observerArchiveDefaultEnabled is not set in mock config,
+ // the bridge returns false (OSS). To test unresolved, we don't need
+ // the flag — the initial state before the async flag resolves is
+ // `undefined` which disables the toggle. In mock E2E the flag resolves
+ // synchronously, so this test verifies the OSS disabled-while-loading
+ // path: with no subscriptions and OSS policy, toggle is unchecked
+ // and enabled (not disabled) — confirming fail-closed doesn't
+ // permanently lock OSS users out.
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: false,
+ saveSubscriptions: [],
+ });
+
+ const card = await openLocalArchiveSettings(page);
+ const toggle = card.getByTestId("local-archive-observer-toggle");
+ await expect(toggle).toBeVisible({ timeout: 5_000 });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).not.toBeChecked();
+ });
+});
+
+test.describe("observer archive policy — reconciliation gate", () => {
+ test("internal policy: archive sync reaches subscription path after reconciliation", async ({
+ page,
+ }) => {
+ await installMockBridge(page, {
+ observerArchiveDefaultEnabled: true,
+ saveSubscriptions: [
+ {
+ scope_type: "owner_p",
+ scope_value: "deadbeef".repeat(8),
+ kinds: "[24200]",
+ },
+ ],
+ });
+
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ // Wait for the channel list to appear (proves AppShell mounted fully).
+ await expect(page.getByTestId("channel-general")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // The reconciliation gate (useObserverArchiveReconciliation) must have
+ // resolved successfully, allowing useArchiveSync to start the
+ // ArchiveSyncManager, which calls list_save_subscriptions. The IPC
+ // counter proves the subscription path was reached.
+ await page.waitForFunction(
+ () => {
+ const counters = (window as Record)
+ .__BUZZ_E2E_IPC_COUNTERS__ as Record | undefined;
+ return (counters?.list_save_subscriptions ?? 0) > 0;
+ },
+ null,
+ { timeout: 10_000 },
+ );
+
+ const count = await page.evaluate(() => {
+ const counters = (window as Record)
+ .__BUZZ_E2E_IPC_COUNTERS__ as Record | undefined;
+ return counters?.list_save_subscriptions ?? 0;
+ });
+ expect(count).toBeGreaterThan(0);
+ });
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 5560fc7da..2b1d3c676 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -212,6 +212,12 @@ type MockBridgeOptions = {
nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
+ /**
+ * Value returned by the `observer_archive_default_enabled` mock command.
+ * `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS
+ * build (toggle functional). Drives LocalArchiveSettingsCard policy state.
+ */
+ observerArchiveDefaultEnabled?: boolean;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.
/**
From adb48311e6e8940c83e9b75ab896807e09263960 Mon Sep 17 00:00:00 2001
From: Kalvin C
Date: Thu, 16 Jul 2026 13:13:59 -0700
Subject: [PATCH 6/7] feat: open add community from deep links (#1970)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
---
desktop/src-tauri/src/deep_link.rs | 124 +++++++++++++-----
desktop/src/app/App.tsx | 6 +
desktop/src/app/AppShell.tsx | 14 +-
.../communities/addCommunityPrefill.ts | 64 +++++++++
.../communities/ui/AddCommunityDialog.tsx | 23 +++-
.../src/features/sidebar/ui/AppSidebar.tsx | 4 +
desktop/src/shared/deep-link.ts | 66 ++++++++--
desktop/src/testing/e2eBridge.ts | 12 +-
desktop/tests/e2e/deep-link-invite.spec.ts | 117 +++++++++++++++++
desktop/tests/helpers/bridge.ts | 9 +-
10 files changed, 383 insertions(+), 56 deletions(-)
create mode 100644 desktop/src/features/communities/addCommunityPrefill.ts
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index a890a99ad..3c98c66e3 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -13,6 +13,7 @@ pub(crate) struct PendingCommunityDeepLink {
kind: String,
relay_url: String,
code: Option,
+ name: Option,
}
#[derive(Default)]
@@ -25,6 +26,7 @@ impl PendingCommunityDeepLinks {
item.kind == pending.kind
&& item.relay_url == pending.relay_url
&& item.code == pending.code
+ && item.name == pending.name
}) {
return;
}
@@ -70,6 +72,7 @@ fn queue_community_deep_link(
kind: &str,
relay_url: String,
code: Option,
+ name: Option,
) {
app.state::()
.enqueue(PendingCommunityDeepLink {
@@ -77,6 +80,7 @@ fn queue_community_deep_link(
kind: kind.to_owned(),
relay_url,
code,
+ name,
});
}
@@ -133,7 +137,6 @@ fn parse_message_deep_link(url: &Url) -> Option {
/// `code`; returns `None` otherwise so the frontend never sees a half-formed
/// payload.
fn parse_join_deep_link(url: &Url) -> Option {
- let mut relay: Option = None;
let mut code: Option = None;
let mut policy_receipt: Option = None;
for (k, v) in url.query_pairs() {
@@ -142,17 +145,13 @@ fn parse_join_deep_link(url: &Url) -> Option {
continue;
}
match k.as_ref() {
- "relay" => relay = Some(v),
"code" => code = Some(v),
"policy_receipt" => policy_receipt = Some(v),
_ => {}
}
}
- let (relay_url, code) = (relay?, code?);
- match Url::parse(&relay_url) {
- Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
- _ => return None,
- }
+ let code = code?;
+ let relay_url = parse_websocket_relay_param(url)?;
Some(serde_json::json!({
"relayUrl": relay_url,
"code": code,
@@ -160,6 +159,33 @@ fn parse_join_deep_link(url: &Url) -> Option {
}))
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AddCommunityDeepLinkPayload {
+ relay_url: String,
+ name: Option,
+}
+
+fn parse_websocket_relay_param(url: &Url) -> Option {
+ let relay_url = url
+ .query_pairs()
+ .find(|(key, _)| key == "relay")
+ .map(|(_, value)| value.into_owned())
+ .filter(|value| !value.is_empty())?;
+ let parsed = Url::parse(&relay_url).ok()?;
+ if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
+ return None;
+ }
+ Some(relay_url)
+}
+
+fn parse_add_community_deep_link(url: &Url) -> Option {
+ Some(AddCommunityDeepLinkPayload {
+ relay_url: parse_websocket_relay_param(url)?,
+ name: optional_non_empty_param(url, "name"),
+ })
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct NostrBindDeepLinkPayload {
@@ -281,31 +307,12 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
match url.host_str() {
Some("connect") => {
- let relay = url
- .query_pairs()
- .find(|(k, _)| k == "relay")
- .map(|(_, v)| v.into_owned());
- let Some(relay_url) = relay else {
- eprintln!("buzz-desktop: connect deep link missing relay param: {url_str}");
+ let Some(relay_url) = parse_websocket_relay_param(&url) else {
+ eprintln!("buzz-desktop: connect deep link missing/invalid relay: {url_str}");
return;
};
- // Validate the relay URL is ws:// or wss://
- match Url::parse(&relay_url) {
- Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
- Ok(parsed) => {
- eprintln!(
- "buzz-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}",
- parsed.scheme()
- );
- return;
- }
- Err(e) => {
- eprintln!("buzz-desktop: invalid relay URL {relay_url:?}: {e}");
- return;
- }
- }
activate_main_window(app);
- queue_community_deep_link(app, "connect", relay_url.clone(), None);
+ queue_community_deep_link(app, "connect", relay_url.clone(), None, None);
let _ = app.emit("deep-link-connect", relay_url);
}
Some("join") => {
@@ -319,9 +326,24 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
activate_main_window(app);
let relay_url = payload["relayUrl"].as_str().unwrap_or_default().to_owned();
let code = payload["code"].as_str().map(str::to_owned);
- queue_community_deep_link(app, "join", relay_url, code);
+ queue_community_deep_link(app, "join", relay_url, code, None);
let _ = app.emit("deep-link-join", payload);
}
+ Some("add-community") => {
+ let Some(payload) = parse_add_community_deep_link(&url) else {
+ eprintln!("buzz-desktop: add-community deep link missing/invalid relay: {url_str}");
+ return;
+ };
+ activate_main_window(app);
+ queue_community_deep_link(
+ app,
+ "add-community",
+ payload.relay_url.clone(),
+ None,
+ payload.name.clone(),
+ );
+ let _ = app.emit("deep-link-add-community", payload);
+ }
Some("message") => {
// `buzz://message?channel=&id=[&thread=]`
//
@@ -361,8 +383,8 @@ mod tests {
use url::Url;
use super::{
- parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link,
- PendingCommunityDeepLink, PendingCommunityDeepLinks,
+ parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link,
+ parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks,
};
fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink {
@@ -371,6 +393,7 @@ mod tests {
kind: if code.is_some() { "join" } else { "connect" }.to_owned(),
relay_url: relay_url.to_owned(),
code: code.map(str::to_owned),
+ name: None,
}
}
@@ -401,6 +424,43 @@ mod tests {
.unwrap()
}
+ #[test]
+ fn parse_add_community_deep_link_extracts_relay_and_name() {
+ let url = Url::parse(
+ "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value",
+ )
+ .unwrap();
+ let payload = parse_add_community_deep_link(&url).unwrap();
+ assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz");
+ assert_eq!(payload.name.as_deref(), Some("Acme Team"));
+ }
+
+ #[test]
+ fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() {
+ for raw in [
+ "buzz://add-community?relay=wss%3A%2F%2Facme.example",
+ "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=",
+ ] {
+ assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap())
+ .unwrap()
+ .name
+ .is_none());
+ }
+ }
+
+ #[test]
+ fn parse_add_community_deep_link_rejects_invalid_relays() {
+ for raw in [
+ "buzz://add-community",
+ "buzz://add-community?relay=",
+ "buzz://add-community?relay=not-a-url",
+ "buzz://add-community?relay=https%3A%2F%2Facme.example",
+ "buzz://add-community?relay=wss%3A%2F%2F",
+ ] {
+ assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none());
+ }
+ }
+
#[test]
fn parse_message_deep_link_extracts_required_params() {
let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap();
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index dbdd0a2c6..ac92ffee8 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -28,6 +28,10 @@ import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
+import {
+ onAddCommunityPrefillAvailable,
+ requestAddCommunityPrefill,
+} from "@/features/communities/addCommunityPrefill";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
@@ -396,6 +400,8 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
useEffect(() => {
const unlisten = listenForDeepLinks({
startCommunityOnboarding: communityOnboarding.start,
+ openAddCommunity: requestAddCommunityPrefill,
+ onAddCommunityAvailable: onAddCommunityPrefillAvailable,
});
return () => {
void unlisten.then((fn) => fn());
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index f359f2218..44cedc9d4 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -72,6 +72,7 @@ import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { useCommunities } from "@/features/communities/useCommunities";
+import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { relayClient } from "@/shared/api/relayClient";
import { useFeatureEnabled } from "@/shared/features";
@@ -103,7 +104,7 @@ export function AppShell() {
const communitiesHook = useCommunities();
const communityRailEnabled = useFeatureEnabled("workspaceRail");
- const [isAddCommunityOpen, setIsAddCommunityOpen] = React.useState(false);
+ const addCommunityDialog = useAddCommunityDialogState();
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState(
@@ -766,7 +767,7 @@ export function AppShell() {
activeCommunityId={
communitiesHook.activeCommunity?.id ?? null
}
- onAddCommunity={() => setIsAddCommunityOpen(true)}
+ onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
@@ -837,7 +838,8 @@ export function AppShell() {
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
- isAddCommunityOpen={isAddCommunityOpen}
+ addCommunityPrefill={addCommunityDialog.prefill}
+ isAddCommunityOpen={addCommunityDialog.open}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
@@ -848,10 +850,12 @@ export function AppShell() {
const id = communitiesHook.addCommunity(community);
handleSwitchCommunity(id);
}}
- onAddCommunityOpenChange={setIsAddCommunityOpen}
+ onAddCommunityOpenChange={
+ addCommunityDialog.onOpenChange
+ }
onNewMessage={handleOpenNewDm}
onCreateChannelOpenChange={setIsCreateChannelOpen}
- onOpenAddCommunity={() => setIsAddCommunityOpen(true)}
+ onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={communitiesHook.removeCommunity}
diff --git a/desktop/src/features/communities/addCommunityPrefill.ts b/desktop/src/features/communities/addCommunityPrefill.ts
new file mode 100644
index 000000000..375a85d59
--- /dev/null
+++ b/desktop/src/features/communities/addCommunityPrefill.ts
@@ -0,0 +1,64 @@
+import * as React from "react";
+
+import type { AddCommunityDeepLinkPayload } from "@/shared/deep-link";
+
+export type AddCommunityPrefillRequest = AddCommunityDeepLinkPayload & {
+ requestId: string;
+};
+
+let currentRequest: AddCommunityPrefillRequest | null = null;
+const listeners = new Set<() => void>();
+const availableListeners = new Set<() => void>();
+
+export function requestAddCommunityPrefill(
+ request: AddCommunityPrefillRequest,
+): boolean {
+ if (currentRequest) return false;
+ currentRequest = request;
+ for (const listener of listeners) listener();
+ return true;
+}
+
+export function clearAddCommunityPrefill(requestId: string): void {
+ if (!currentRequest || currentRequest.requestId !== requestId) return;
+ currentRequest = null;
+ for (const listener of listeners) listener();
+ for (const listener of availableListeners) listener();
+}
+
+export function onAddCommunityPrefillAvailable(
+ listener: () => void,
+): () => void {
+ availableListeners.add(listener);
+ return () => availableListeners.delete(listener);
+}
+
+function useAddCommunityPrefill(): AddCommunityPrefillRequest | null {
+ return React.useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ () => currentRequest,
+ () => null,
+ );
+}
+
+export function useAddCommunityDialogState() {
+ const prefill = useAddCommunityPrefill();
+ const [open, setOpen] = React.useState(false);
+
+ React.useEffect(() => {
+ if (prefill) setOpen(true);
+ }, [prefill]);
+
+ const onOpenChange = React.useCallback(
+ (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen && prefill) clearAddCommunityPrefill(prefill.requestId);
+ },
+ [prefill],
+ );
+
+ return { prefill, open, onOpenChange, openDialog: () => setOpen(true) };
+}
diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
index 00986205f..227804e7b 100644
--- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
import {
deriveCommunityName,
expandTilde,
@@ -30,6 +31,7 @@ const POLICY_DISCOVERY_DELAY_MS = 250;
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
type AddCommunityDialogProps = {
+ prefill?: AddCommunityPrefillRequest | null;
onSubmit?: (
community: import("@/features/communities/types").Community,
) => void;
@@ -38,6 +40,7 @@ type AddCommunityDialogProps = {
};
export function AddCommunityDialog({
+ prefill,
open,
onOpenChange,
}: AddCommunityDialogProps) {
@@ -53,6 +56,18 @@ export function AddCommunityDialog({
const communityOnboarding = useCommunityOnboarding();
const [reposDirError, setReposDirError] = React.useState(null);
const shouldReduceMotion = useReducedMotion();
+ const appliedPrefillId = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!prefill || appliedPrefillId.current === prefill.requestId) return;
+ appliedPrefillId.current = prefill.requestId;
+ setName(prefill.name ?? deriveCommunityName(prefill.relayUrl));
+ setRelayUrl(prefill.relayUrl);
+ setToken("");
+ setInviteCode("");
+ setReposDir("");
+ setReposDirError(null);
+ }, [prefill]);
React.useEffect(() => {
if (!open || !relayUrl.trim()) return;
@@ -181,7 +196,13 @@ export function AddCommunityDialog({
);
return (
-