notedeck

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

commit e774c105060e73d927738d6cedd40fd96b21f96f
parent 2afe7728d670a1b8ff873aa9d4dbaf50a53ba4e5
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 26 Feb 2026 17:18:37 -0500

feat(messages): ensure selected account DM relay list

Add a state machine that fetches or creates the selected account's
kind 10050 DM relay list on the messages screen. When no list is
found after all relays EOSE, a default list is published.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_messages/src/cache/conversation.rs | 8++++++++
Mcrates/notedeck_messages/src/lib.rs | 42++++++++++++++++++++++++++++++++++++++++--
Acrates/notedeck_messages/src/relay_ensure.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 297 insertions(+), 2 deletions(-)

diff --git a/crates/notedeck_messages/src/cache/conversation.rs b/crates/notedeck_messages/src/cache/conversation.rs @@ -10,6 +10,7 @@ use crate::{ }, convo_renderable::ConversationRenderable, nip17::get_participants, + relay_ensure::DmListState, }; use super::message_store::MessageStore; @@ -23,6 +24,7 @@ pub struct ConversationCache { conversations: HashMap<ConversationId, Conversation>, order: Vec<ConversationOrder>, pub state: ConversationListState, + dm_relay_list_ensure: DmListState, pub active: Option<ConversationId>, } @@ -98,6 +100,11 @@ impl ConversationCache { pub fn first_convo_id(&self) -> Option<ConversationId> { Some(self.order.first()?.id) } + + /// Mutable access to the selected-account DM relay-list ensure state. + pub fn dm_relay_list_ensure_mut(&mut self) -> &mut DmListState { + &mut self.dm_relay_list_ensure + } } fn refresh_order(order: &mut Vec<ConversationOrder>, id: ConversationId, latest: LatestMessage) { @@ -231,6 +238,7 @@ impl Default for ConversationCache { conversations: HashMap::new(), order: Vec::new(), state: Default::default(), + dm_relay_list_ensure: Default::default(), active: None, } } diff --git a/crates/notedeck_messages/src/lib.rs b/crates/notedeck_messages/src/lib.rs @@ -3,18 +3,22 @@ pub mod convo_renderable; pub mod loader; pub mod nav; pub mod nip17; +mod relay_ensure; pub mod ui; use enostr::Pubkey; use hashbrown::{HashMap, HashSet}; use nav::{process_messages_ui_response, Route}; -use nostrdb::{Subscription, Transaction}; -use notedeck::{ui::is_narrow, Accounts, App, AppContext, AppResponse, Router}; +use nostrdb::{Ndb, Subscription, Transaction}; +use notedeck::{ + ui::is_narrow, Accounts, App, AppContext, AppResponse, RemoteApi, Router, SubKey, SubOwnerKey, +}; use crate::{ cache::{ConversationCache, ConversationListState, ConversationStates}, loader::{LoaderMsg, MessagesLoader}, nip17::conversation_filter, + relay_ensure::ensure_selected_account_dm_list, ui::{login_nsec_prompt, messages::messages_ui}, }; @@ -84,6 +88,8 @@ impl App for MessagesApp { } } + ensure_selected_account_dm_relay_list(ctx.ndb, &mut ctx.remote, ctx.accounts, cache); + match cache.state { ConversationListState::Initializing => { initialize(ctx, cache, is_narrow(ui.ctx()), &self.loader); @@ -287,6 +293,38 @@ fn request_conversation_messages( ); } +/// Scoped-sub owner namespace for messages DM relay-list lifecycles. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum RelayListOwner { + Ensure, +} + +const RELAY_LIST_KEY: &str = "dm_relay_list"; + +/// Stable owner for selected-account DM relay-list ensure subscriptions per selected account. +fn list_ensure_owner_key(account_pk: Pubkey) -> SubOwnerKey { + SubOwnerKey::builder(RelayListOwner::Ensure) + .with(account_pk) + .finish() +} + +/// Stable key for one participant's DM relay-list remote stream. +pub fn list_fetch_sub_key(participant: &Pubkey) -> SubKey { + SubKey::builder(RELAY_LIST_KEY) + .with(*participant.bytes()) + .finish() +} + +#[profiling::function] +pub(crate) fn ensure_selected_account_dm_relay_list( + ndb: &mut Ndb, + remote: &mut RemoteApi<'_>, + accounts: &Accounts, + cache: &mut ConversationCache, +) { + ensure_selected_account_dm_list(ndb, remote, accounts, cache.dm_relay_list_ensure_mut()) +} + /// Storage for conversations per account. Account management is performed by `Accounts` #[derive(Default)] struct ConversationsCtx { diff --git a/crates/notedeck_messages/src/relay_ensure.rs b/crates/notedeck_messages/src/relay_ensure.rs @@ -0,0 +1,249 @@ +use enostr::Pubkey; +use nostrdb::{Ndb, Subscription, Transaction}; +use notedeck::{ + Accounts, RelaySelection, RelayType, RemoteApi, ScopedSubEoseStatus, ScopedSubIdentity, + SubConfig, SubKey, SubOwnerKey, +}; + +use crate::{ + list_ensure_owner_key, list_fetch_sub_key, + nip17::{ + build_default_dm_relay_list_note, is_participant_dm_relay_list, + participant_dm_relay_list_filter, + }, +}; + +/// Local view over the dependencies used by the DM relay-list ensure state machine. +struct EnsureListCtx<'a, 'remote> { + ndb: &'a mut Ndb, + remote: &'a mut RemoteApi<'remote>, + accounts: &'a Accounts, + owner_key: SubOwnerKey, +} + +/// Pure builder for the selected account's own DM relay-list ensure scoped-sub spec. +fn dm_relay_list_spec(selected_account: &Pubkey) -> SubConfig { + SubConfig { + relays: RelaySelection::AccountsRead, + filters: vec![participant_dm_relay_list_filter(selected_account)], + use_transparent: false, + } +} + +#[profiling::function] +pub(crate) fn ensure_selected_account_dm_list( + ndb: &mut Ndb, + remote: &mut RemoteApi<'_>, + accounts: &Accounts, + ensure_state: &mut DmListState, +) { + let DmListState::Finding(state) = ensure_state else { + return; + }; + + let selected_account = *accounts.selected_account_pubkey(); + let mut ctx = EnsureListCtx { + ndb, + remote, + accounts, + owner_key: list_ensure_owner_key(selected_account), + }; + + let list_found = match &state { + ListFindingState::Idle => handle_idle(&mut ctx, state), + ListFindingState::Waiting { + remote_sub_key, + local_sub, + } => handle_waiting(&mut ctx, *remote_sub_key, *local_sub), + }; + + if list_found { + set_list_found(&mut ctx, ensure_state); + } +} + +type ListFound = bool; + +/// Handles the `Idle` ensure phase for the selected account DM relay list. +fn handle_idle(ctx: &mut EnsureListCtx<'_, '_>, ensure_state: &mut ListFindingState) -> ListFound { + tracing::debug!("In idle state"); + let pk = ctx.accounts.selected_account_pubkey(); + let filter = participant_dm_relay_list_filter(pk); + let local_sub = match ctx.ndb.subscribe(std::slice::from_ref(&filter)) { + Ok(sub) => Some(sub), + Err(err) => { + tracing::error!("failed to subscribe to local dm relay list: {err}"); + None + } + }; + + let remote_sub_key = list_fetch_sub_key(pk); + let spec = dm_relay_list_spec(pk); + let identity = ScopedSubIdentity::account(ctx.owner_key, remote_sub_key); + let _ = ctx + .remote + .scoped_subs(ctx.accounts) + .ensure_sub(identity, spec); + + tracing::info!("waiting for selected account dm relay list ensure"); + *ensure_state = ListFindingState::Waiting { + remote_sub_key, + local_sub, + }; + + false +} + +/// Handles the `Waiting` ensure phase for the selected account DM relay list. +fn handle_waiting( + ctx: &mut EnsureListCtx<'_, '_>, + remote_sub_key: SubKey, + local_sub: Option<Subscription>, +) -> ListFound { + let pk = ctx.accounts.selected_account_pubkey(); + if let Some(local_sub) = local_sub { + if received_dm_relay_list_from_poll(ctx.ndb, local_sub, pk) { + tracing::debug!( + "found selected account dm relay list on ndb poll; still waiting for remote EOSE" + ); + } + } + + if !all_eosed(ctx, remote_sub_key) { + return false; + } + + republish_existing_or_publish_default_list(ctx, pk) +} + +fn publish_default_list(ctx: &mut EnsureListCtx<'_, '_>) -> ListFound { + let Some(secret_key) = ctx.accounts.get_selected_account().key.secret_key.as_ref() else { + return false; + }; + + let Some(note) = build_default_dm_relay_list_note(secret_key) else { + return false; + }; + + let Ok(note_json) = note.json() else { + return false; + }; + + if let Err(err) = ctx.ndb.process_client_event(&note_json) { + tracing::error!("failed to ingest default dm relay list: {err}"); + return false; + } + + let mut publisher = ctx.remote.publisher(ctx.accounts); + publisher.publish_note(&note, RelayType::AccountsWrite); + + true +} + +/// After all-EOSE, republish the latest local selected-account kind `10050` if present. +/// +/// Falls back to publishing a default kind `10050` when no local list exists. +fn republish_existing_or_publish_default_list( + ctx: &mut EnsureListCtx<'_, '_>, + selected_account: &Pubkey, +) -> ListFound { + let filter = participant_dm_relay_list_filter(selected_account); + let txn = Transaction::new(ctx.ndb).expect("txn"); + + let Ok(results) = ctx.ndb.query(&txn, std::slice::from_ref(&filter), 1) else { + tracing::error!("failed to query selected account dm relay list during ensure"); + return false; + }; + + match results.first() { + Some(result) => { + tracing::info!("all relays eosed; republishing existing local dm relay list note"); + let mut publisher = ctx.remote.publisher(ctx.accounts); + publisher.publish_note(&result.note, RelayType::AccountsWrite); + true + } + None => { + tracing::info!( + "all relays eosed; no local dm relay list note found, publishing default list" + ); + publish_default_list(ctx) + } + } +} + +/// Returns true when the selected-account DM relay-list ensure scoped sub reached all-EOSE. +fn all_eosed(ctx: &mut EnsureListCtx<'_, '_>, remote_sub_key: SubKey) -> bool { + let scoped_subs = ctx.remote.scoped_subs(ctx.accounts); + let identity = ScopedSubIdentity::account(ctx.owner_key, remote_sub_key); + matches!( + scoped_subs.sub_eose_status(identity), + ScopedSubEoseStatus::Live(live) if live.all_eosed + ) +} + +/// Returns true when the ensure local subscription delivers a selected-account kind `10050` note. +fn received_dm_relay_list_from_poll( + ndb: &Ndb, + local_sub: Subscription, + selected_account: &Pubkey, +) -> bool { + let note_keys = ndb.poll_for_notes(local_sub, 1); + + let Some(key) = note_keys.first() else { + return false; + }; + + let txn = Transaction::new(ndb).expect("txn"); + let Ok(note) = ndb.get_note_by_key(&txn, *key) else { + return false; + }; + + is_participant_dm_relay_list(&note, selected_account) +} + +/// Moves DM relay-list ensure state to `Done` and tears down the local ensure subscription. +/// +/// The remote scoped sub is intentionally left declared so it stays alive for the account session +/// and can be shared with later conversation prefetch activity. +fn set_list_found(ctx: &mut EnsureListCtx<'_, '_>, list_state: &mut DmListState) { + let prior = std::mem::replace(list_state, DmListState::Found); + let DmListState::Finding(ListFindingState::Waiting { + remote_sub_key: _, + local_sub, + }) = prior + else { + return; + }; + + let Some(local_sub) = local_sub else { + return; + }; + + if let Err(err) = ctx.ndb.unsubscribe(local_sub) { + tracing::error!("failed to unsubscribe dm relay-list local sub: {err}"); + } +} + +/// Active (non-terminal) phases for selected-account DM relay-list ensure. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ListFindingState { + #[default] + Idle, + Waiting { + remote_sub_key: SubKey, + local_sub: Option<Subscription>, + }, +} + +/// Ensure-state for the selected account's kind `10050` DM relay list. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DmListState { + Finding(ListFindingState), + Found, +} + +impl Default for DmListState { + fn default() -> Self { + Self::Finding(ListFindingState::Idle) + } +}