notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 99bdc743961889f608ed2c1d7cb239bbc605263a
parent 76e17d3d85d07107fa71024c97586bb41e86d49e
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 19 Feb 2026 13:30:24 -0500

feat(scoped-subs): add owner slots and app-facing facade

Add host-owned owner-slot mapping plus ScopedSubApi/ScopedSubsState wrappers so app code can set, clear, and drop scoped subscriptions by stable owner+key identifiers without handling runtime internals.

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck/src/lib.rs | 6++++++
Acrates/notedeck/src/scoped_sub_api.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/scoped_sub_owners.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck/src/scoped_sub_state.rs | 36++++++++++++++++++++++++++++++++++++
4 files changed, 336 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -32,6 +32,9 @@ pub mod relay_debug; pub mod relayspec; mod result; mod route; +mod scoped_sub_api; +mod scoped_sub_owners; +mod scoped_sub_state; mod scoped_subs; mod setup; pub mod storage; @@ -91,6 +94,9 @@ pub use relay_debug::RelayDebugView; pub use relayspec::RelaySpec; pub use result::Result; pub use route::{DrawerRouter, ReplacementType, Router}; +pub use scoped_sub_api::ScopedSubApi; +pub use scoped_sub_owners::SubOwnerKeyBuilder; +pub use scoped_sub_state::ScopedSubsState; pub use scoped_subs::{ ClearSubResult, DropSlotResult, EnsureSubResult, RelaySelection, ScopedSubEoseStatus, ScopedSubIdentity, ScopedSubLiveEoseStatus, SetSubResult, SubConfig, SubKey, SubKeyBuilder, diff --git a/crates/notedeck/src/scoped_sub_api.rs b/crates/notedeck/src/scoped_sub_api.rs @@ -0,0 +1,160 @@ +use enostr::Pubkey; + +use crate::scoped_sub_owners::ScopedSubOwners; +use crate::scoped_subs::ScopedSubRuntime; +use crate::{ + Accounts, ClearSubResult, EnsureSubResult, Outbox, ScopedSubEoseStatus, ScopedSubIdentity, + SetSubResult, SubConfig, SubOwnerKey, +}; + +/// App-facing facade over scoped subscription owner/runtime operations. +/// +/// This bundles host resources that are commonly passed together to avoid +/// argument plumbing through app-layer helper functions. +pub struct ScopedSubApi<'o, 'a> { + pool: &'o mut Outbox<'a>, + accounts: &'o Accounts, + owners: &'o mut ScopedSubOwners, + runtime: &'o mut ScopedSubRuntime, +} + +impl<'o, 'a> ScopedSubApi<'o, 'a> { + pub(crate) fn new( + pool: &'o mut Outbox<'a>, + accounts: &'o Accounts, + owners: &'o mut ScopedSubOwners, + runtime: &'o mut ScopedSubRuntime, + ) -> Self { + Self { + pool, + accounts, + owners, + runtime, + } + } + + pub fn selected_account_pubkey(&self) -> Pubkey { + *self.accounts.selected_account_pubkey() + } + + /// Create or update one scoped remote subscription declaration. + /// + /// Thread example (recommended mental model): + /// - `identity.owner` = one thread view lifecycle (for example one open thread pane) + /// - `identity.key` = `replies-by-root(root_id)` + /// - `identity.scope` = `SubScope::Account` + /// + /// If two thread views open the same root on the same account, they should use: + /// - different `owner` + /// - same `key` + /// - same `scope` + /// + /// The runtime shares one live outbox subscription for that resolved `(scope, key)`. + /// + /// `set_sub(...)` is an upsert for the resolved `(scope, key)`: + /// - first call creates desired state + /// - repeated calls update/replace desired state and may modify the live outbox sub + /// + /// Use this when the remote config can change (filters and/or relays). + /// For thread reply subscriptions, prefer [`Self::ensure_sub`] unless the thread's + /// remote filters actually change. + /// + /// Account-scoped behavior (`SubScope::Account`): + /// - On switch away, the live outbox subscription is unsubscribed. + /// - Desired state is retained while owners still exist. + /// - On switch back, the live outbox subscription is restored from desired state. + /// - If owners are dropped while away, nothing is restored. + pub fn set_sub(&mut self, identity: ScopedSubIdentity, config: SubConfig) -> SetSubResult { + self.owners + .set_sub(self.runtime, self.pool, self.accounts, identity, config) + } + + /// Create a scoped remote subscription declaration only if it is absent. + /// + /// Thread open path example: + /// - build `identity = { owner: thread-view, key: replies-by-root(root_id), scope: Account }` + /// - call `ensure_sub(identity, config)` when opening the thread + /// + /// Repeated calls with the same resolved `(scope, key)`: + /// - keep ownership attached + /// - do not modify desired state + /// - do not modify the live outbox subscription + /// + /// This is the preferred API for stable thread reply subscriptions because it is + /// idempotent and avoids unnecessary outbox subscription updates on repeats. + /// + /// Account-switch behavior matches [`Self::set_sub`]. + pub fn ensure_sub( + &mut self, + identity: ScopedSubIdentity, + config: SubConfig, + ) -> EnsureSubResult { + self.owners + .ensure_sub(self.runtime, self.pool, self.accounts, identity, config) + } + + /// Clear one scoped subscription declaration while keeping the owner alive. + /// + /// Thread example: + /// - This is less common than [`Self::drop_owner`]. + /// - Use this only if a thread owner remains alive but should stop declaring one + /// specific thread remote sub key. + /// + /// Outbox behavior: + /// - If other owners still declare the same resolved `(scope, key)`, the shared live + /// outbox subscription remains active. + /// - If this was the last owner for that `(scope, key)`, the live outbox subscription + /// is unsubscribed (if active) and desired state is removed. + pub fn clear_sub(&mut self, identity: ScopedSubIdentity) -> ClearSubResult { + self.owners + .clear_sub(self.runtime, self.pool, self.accounts, identity) + } + + /// Clear one account-scoped declaration for an explicit account (host-only). + /// + /// This exists for host cleanup paths (for example deleting a non-selected account's + /// retained scoped declarations). App code should use [`Self::clear_sub`], which resolves + /// account scope from the currently selected account. + pub(crate) fn clear_sub_for_account( + &mut self, + account_pubkey: Pubkey, + identity: ScopedSubIdentity, + ) -> ClearSubResult { + self.owners + .clear_sub_for_account(self.runtime, self.pool, account_pubkey, identity) + } + + /// Query aggregate EOSE state for one scoped subscription declaration. + /// + /// Thread example: + /// - query the status of `{ owner: thread-view, key: replies-by-root(root_id), scope: Account }` + /// - the lookup uses the current selected account to resolve `SubScope::Account` + /// + /// If the same thread root is open in multiple thread views, each owner can query the same + /// shared outbox subscription status through its own identity. + /// + /// Account-switch behavior: + /// - Switch away: status typically becomes [`ScopedSubEoseStatus::Inactive`] because the + /// live outbox subscription is removed while desired state is retained. + /// - Switch back: status may return to `Live(...)` after restore. + pub fn sub_eose_status(&self, identity: ScopedSubIdentity) -> ScopedSubEoseStatus { + self.owners + .sub_eose_status(self.runtime, self.pool, self.accounts, identity) + } + + /// Drop one owner lifecycle and release all scoped subscriptions declared by it. + /// + /// Thread example: + /// - `owner` is one thread view lifecycle token. + /// - If two thread views opened the same `replies-by-root(root_id)` on the same account, + /// dropping one owner keeps the shared live outbox subscription active. + /// - Dropping the last owner unsubscribes the live outbox subscription for that thread key + /// (if active) and removes the retained desired declaration. + /// + /// Account-scoped behavior: + /// - If the thread owner is dropped while switched away, there may be no live outbox sub to + /// unsubscribe, but the retained declaration is removed so nothing is restored on switch-back. + pub fn drop_owner(&mut self, owner: SubOwnerKey) -> bool { + self.owners.drop_owner(self.runtime, self.pool, owner) + } +} diff --git a/crates/notedeck/src/scoped_sub_owners.rs b/crates/notedeck/src/scoped_sub_owners.rs @@ -0,0 +1,134 @@ +use enostr::Pubkey; +use hashbrown::HashMap; + +use crate::{ + scoped_subs::{ScopedSubRuntime, SubOwnerKey, SubSlotId}, + Accounts, ClearSubResult, EnsureSubResult, Outbox, ScopedSubEoseStatus, ScopedSubIdentity, + SetSubResult, SubConfig, SubKeyBuilder, SubScope, +}; + +/// Incremental builder for stable owner keys. +pub type SubOwnerKeyBuilder = SubKeyBuilder; + +/// Host-owned mapping from owner lifecycles to runtime slots. +/// +/// This is intended to be held by host/container code (not app feature state) +/// so slot ids are never stored in app modules. +#[derive(Default)] +pub(crate) struct ScopedSubOwners { + slots_by_owner: HashMap<SubOwnerKey, SubSlotId>, +} + +impl ScopedSubOwners { + /// Ensure one runtime slot exists for this owner key. + pub(crate) fn ensure_slot( + &mut self, + runtime: &mut ScopedSubRuntime, + owner: SubOwnerKey, + ) -> SubSlotId { + if let Some(slot) = self.slots_by_owner.get(&owner).copied() { + return slot; + } + + let slot = runtime.create_slot(); + self.slots_by_owner.insert(owner, slot); + slot + } + + /// Forward an upsert scoped-sub request for an owner lifecycle. + pub fn set_sub( + &mut self, + runtime: &mut ScopedSubRuntime, + pool: &mut Outbox<'_>, + accounts: &Accounts, + identity: ScopedSubIdentity, + config: SubConfig, + ) -> SetSubResult { + let slot = self.ensure_slot(runtime, identity.owner); + runtime.set_sub(pool, accounts, slot, identity.scope, identity.key, config) + } + + /// Forward a create-if-absent scoped-sub request for an owner lifecycle. + pub fn ensure_sub( + &mut self, + runtime: &mut ScopedSubRuntime, + pool: &mut Outbox<'_>, + accounts: &Accounts, + identity: ScopedSubIdentity, + config: SubConfig, + ) -> EnsureSubResult { + let slot = self.ensure_slot(runtime, identity.owner); + runtime.ensure_sub(pool, accounts, slot, identity.scope, identity.key, config) + } + + /// Clear one scoped subscription binding from an owner lifecycle. + pub fn clear_sub( + &mut self, + runtime: &mut ScopedSubRuntime, + pool: &mut Outbox<'_>, + accounts: &Accounts, + identity: ScopedSubIdentity, + ) -> ClearSubResult { + let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else { + return ClearSubResult::NotFound; + }; + + runtime.clear_sub(pool, accounts, slot, identity.key, identity.scope) + } + + /// Clear one account-scoped subscription binding from an owner lifecycle for an explicit account. + pub fn clear_sub_for_account( + &mut self, + runtime: &mut ScopedSubRuntime, + pool: &mut Outbox<'_>, + account_pubkey: Pubkey, + identity: ScopedSubIdentity, + ) -> ClearSubResult { + debug_assert!(matches!(identity.scope, SubScope::Account)); + let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else { + return ClearSubResult::NotFound; + }; + + runtime.clear_sub_with_selected(pool, account_pubkey, slot, identity.key, identity.scope) + } + + /// Query aggregate EOSE state for one scoped subscription binding owned by `owner`. + pub fn sub_eose_status( + &self, + runtime: &ScopedSubRuntime, + pool: &Outbox<'_>, + accounts: &Accounts, + identity: ScopedSubIdentity, + ) -> ScopedSubEoseStatus { + let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else { + return ScopedSubEoseStatus::Missing; + }; + + runtime.sub_eose_status(pool, accounts, slot, identity.key, identity.scope) + } + + /// Drop one owner lifecycle and release all its scoped subscriptions. + pub fn drop_owner( + &mut self, + runtime: &mut ScopedSubRuntime, + pool: &mut Outbox<'_>, + owner: SubOwnerKey, + ) -> bool { + let Some(slot) = self.slots_by_owner.remove(&owner) else { + return false; + }; + + let _ = runtime.drop_slot(pool, slot); + true + } + + /// Number of tracked owner lifecycles. + pub fn len(&self) -> usize { + self.slots_by_owner.len() + } + + /// Returns true if no owner lifecycles are tracked. + pub fn is_empty(&self) -> bool { + self.slots_by_owner.is_empty() + } +} diff --git a/crates/notedeck/src/scoped_sub_state.rs b/crates/notedeck/src/scoped_sub_state.rs @@ -0,0 +1,36 @@ +use crate::scoped_sub_owners::ScopedSubOwners; +use crate::scoped_subs::ScopedSubRuntime; +use crate::{Accounts, Outbox, ScopedSubApi}; + +/// Host-owned scoped subscription state. +/// +/// This keeps scoped owner slots and runtime state together so they are +/// managed as one unit by the host. +#[derive(Default)] +pub struct ScopedSubsState { + runtime: ScopedSubRuntime, + owners: ScopedSubOwners, +} + +impl ScopedSubsState { + /// Borrow owner/runtime internals for legacy callsites that still expect + /// both references separately. + pub(crate) fn split_mut(&mut self) -> (&mut ScopedSubOwners, &mut ScopedSubRuntime) { + (&mut self.owners, &mut self.runtime) + } + + /// Build the app-facing scoped subscription API bound to host resources. + pub fn api<'o, 'a>( + &'o mut self, + pool: &'o mut Outbox<'a>, + accounts: &'o Accounts, + ) -> ScopedSubApi<'o, 'a> { + let (owners, runtime) = self.split_mut(); + ScopedSubApi::new(pool, accounts, owners, runtime) + } + + /// Mutable access to runtime internals for host account-switch integration. + pub(crate) fn runtime_mut(&mut self) -> &mut ScopedSubRuntime { + &mut self.runtime + } +}