diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 3085493a307..cce1716dcc7 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,6 +13,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -72,6 +73,14 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } + // Presence-only flag: when set (any non-empty value), observer-feed archive + // defaults to ON for the current identity on first run. OSS builds leave + // this unset → default OFF. No JSON validation needed — the command only + // checks `.is_some()`. + if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index af30c3d8b91..92fd821ab51 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -33,6 +33,7 @@ mod project_repo_paths; mod project_terminal; mod relay_members; mod relay_reconnect; +mod observer_archive; mod social; mod teams; mod updater; @@ -70,6 +71,7 @@ pub use project_git_diff::*; pub use project_terminal::*; pub use relay_members::*; pub use relay_reconnect::*; +pub use observer_archive::*; pub use social::*; pub use teams::*; pub use updater::*; diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs new file mode 100644 index 00000000000..cdd932a0c69 --- /dev/null +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -0,0 +1,36 @@ +//! Build-time flag for observer-feed archive default. +//! +//! When `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` is set at build time (internal +//! builds), `observer_archive_default_enabled()` returns `true` and the +//! frontend auto-seeds an `owner_p` save subscription for the current identity +//! on first run. +//! +//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in +//! manually via the Local Archive settings card. + +/// Returns `true` when an internal build has observer-feed archive default-on. +/// +/// The frontend calls this once at startup to decide whether to seed the +/// `owner_p` save subscription. The result is stable for the lifetime of the +/// binary — it is baked at compile time. +#[tauri::command] +pub fn observer_archive_default_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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. + 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"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 673364703f5..7386dd7c569 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -622,6 +622,7 @@ pub fn run() { get_agent_memory, relay_reconnect_hook, relay_reconnect_hook_configured, + observer_archive_default_enabled, archive::archive_events, archive::create_save_subscription, archive::list_save_subscriptions, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index da9f6b65a38..712d0675836 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -50,6 +50,7 @@ import { } from "@/features/user-status/hooks"; import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; +import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -149,6 +150,7 @@ export function AppShell() { usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); useArchiveSync(); + useObserverArchiveSeed(identityQuery.data?.pubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 00000000000..824e44a0a7f --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,71 @@ +/** + * Persists whether the user has made an explicit choice about the + * observer-feed archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on workspace switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Returns `true` if the user has already made an explicit choice for this + * identity (either opted in or opted out). When `false`, the seeding path + * may fire. + */ +export function hasExplicitObserverArchiceChoice( + identityPubkey: string, +): boolean { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + return window.localStorage.getItem(storageKey(identityPubkey)) !== null; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitObserverArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (create_save_subscription is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitObserverArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index 3ee4e960182..192dec5fc07 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -14,11 +14,15 @@ import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; +import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow, } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { + setExplicitObserverArchiveChoice, +} from "../observerArchivePreference"; import { buildSubscriptionRequest, @@ -51,61 +55,51 @@ function kindSummary(kinds: number[]): string { return `${kinds.slice(0, 3).join(", ")} +${kinds.length - 3} more`; } -// ── Source selector (Step 1) ────────────────────────────────────────────────── - -type AddSource = "channel_h" | "owner_p"; +// ── Observer-feed archive section ───────────────────────────────────────────── -type SourceSelectorProps = { - ownerPAlreadySubscribed: boolean; - onSelect: (source: AddSource) => void; +type ObserverSectionProps = { + enabled: boolean; + toggling: boolean; + onToggle: (checked: boolean) => void; }; -function SourceSelector({ - ownerPAlreadySubscribed, - onSelect, -}: SourceSelectorProps) { +function ObserverArchiveSection({ + enabled, + toggling, + onToggle, +}: ObserverSectionProps) { return ( - - -
-

Channel

-

- Archive events from a channel you're a member of. Access is verified - against the relay at subscribe and persist time. -

-
- -
- -
-

My agents' observer feed

-

- Archive kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed - to your pubkey. Routed by pubkey, not stored by the relay. -

-
- -
-
+
+

Agent observer feed

+ + +
+ +

+ 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. +

+
+ +
+
+
); } -// ── Kind checklist (Step 2, channel_h only) ─────────────────────────────────── +// ── Add-subscription form ───────────────────────────────────────────────────── type KindChecklistProps = { checkedKinds: ReadonlySet; @@ -218,20 +212,15 @@ function CustomKindsInput({ value, onChange }: CustomKindsInputProps) { type AddFormProps = { channels: Array<{ id: string; name: string }>; - ownerPAlreadySubscribed: boolean; onSaved: () => void; onCancel: () => void; - pubkey: string; }; function AddSubscriptionForm({ channels, - ownerPAlreadySubscribed, onSaved, onCancel, - pubkey, }: AddFormProps) { - const [source, setSource] = React.useState(null); const [selectedChannelId, setSelectedChannelId] = React.useState(""); const [checkedKinds, setCheckedKinds] = React.useState>( new Set(), @@ -240,17 +229,12 @@ function AddSubscriptionForm({ const [isAdding, setIsAdding] = React.useState(false); const { valid: customKinds } = parseCustomKinds(customKindsRaw); - // `request` is non-null only when the subscription is valid to submit. - // `canAdd` mirrors the same check for the disabled prop without recomputing. - const request = - source !== null - ? buildSubscriptionRequest( - source, - source === "channel_h" ? selectedChannelId : pubkey, - checkedKinds, - customKinds, - ) - : null; + const request = buildSubscriptionRequest( + "channel_h", + selectedChannelId, + checkedKinds, + customKinds, + ); const canAdd = request !== null; const handleAdd = React.useCallback(async () => { @@ -275,93 +259,53 @@ function AddSubscriptionForm({ }, [request, onSaved]); const handleCancel = () => { - setSource(null); setSelectedChannelId(""); setCheckedKinds(new Set()); setCustomKindsRaw(""); onCancel(); }; - // Step 1: source picker - if (source === null) { - return ( - - ); - } - - // Step 2: event types + confirm return (
- {/* Step 1 summary + back link */} -
- - {source === "channel_h" ? "Channel" : "My agents' observer feed"} - - + Channel + +
- {source === "channel_h" ? ( - <> - {/* Channel picker */} -
- - -
- - {/* Event types (per-kind checklist) */} -
-

Event types

- -
+ {/* Event types (per-kind checklist) */} +
+

Event types

+ +
- {/* Advanced: custom kinds */} - - - ) : ( - /* owner_p: fixed / informational */ -

- Archives all kind {KIND_AGENT_OBSERVER_FRAME} observer frames - addressed to your pubkey. The event type is fixed to{" "} - [{KIND_AGENT_OBSERVER_FRAME}] — - these frames are never stored by the relay and cannot be filtered by - channel at this layer. -

- )} + {/* Advanced: custom kinds */} +