notedeck

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

commit dfa6a3547acc4133bf0511155d2a8e32cc45d1b4
parent e774c105060e73d927738d6cedd40fd96b21f96f
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 26 Feb 2026 17:21:51 -0500

feat(messages): prefetch participant DM relay lists and route outgoing messages

Prefetch kind 10050 relay lists when opening a conversation so
participant relay preferences are available before sending. Outgoing
gift-wrapped messages are now routed to each participant's preferred
DM relays when known, falling back to account write relays otherwise.

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

Diffstat:
Mcrates/notedeck_messages/src/lib.rs | 28+++++++++++++++++++++++++++-
Mcrates/notedeck_messages/src/nav.rs | 5+++--
Mcrates/notedeck_messages/src/nip17/message.rs | 19+++++++++++++++++--
Acrates/notedeck_messages/src/relay_prefetch.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 109 insertions(+), 5 deletions(-)

diff --git a/crates/notedeck_messages/src/lib.rs b/crates/notedeck_messages/src/lib.rs @@ -4,6 +4,7 @@ pub mod loader; pub mod nav; pub mod nip17; mod relay_ensure; +mod relay_prefetch; pub mod ui; use enostr::Pubkey; @@ -226,7 +227,12 @@ fn handle_loader_messages( if cache.active.is_none() && !is_narrow { if let Some(first) = cache.first_convo_id() { - cache.active = Some(first); + open_conversation_with_prefetch( + &mut ctx.remote, + ctx.accounts, + cache, + first, + ); request_conversation_messages( cache, ctx.accounts.selected_account_pubkey(), @@ -296,11 +302,19 @@ fn request_conversation_messages( /// Scoped-sub owner namespace for messages DM relay-list lifecycles. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum RelayListOwner { + Prefetch, Ensure, } const RELAY_LIST_KEY: &str = "dm_relay_list"; +/// Stable owner for DM relay-list prefetch subscriptions per selected account. +fn list_prefetch_owner_key(account_pk: Pubkey) -> SubOwnerKey { + SubOwnerKey::builder(RelayListOwner::Prefetch) + .with(account_pk) + .finish() +} + /// 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) @@ -325,6 +339,18 @@ pub(crate) fn ensure_selected_account_dm_relay_list( ensure_selected_account_dm_list(ndb, remote, accounts, cache.dm_relay_list_ensure_mut()) } +/// Marks a conversation active and ensures participant relay-list prefetch. +#[profiling::function] +pub(crate) fn open_conversation_with_prefetch( + remote: &mut RemoteApi<'_>, + accounts: &Accounts, + cache: &mut ConversationCache, + conversation_id: cache::ConversationId, +) { + cache.active = Some(conversation_id); + relay_prefetch::ensure_conversation_prefetch(remote, accounts, cache, conversation_id); +} + /// Storage for conversations per account. Account management is performed by `Accounts` #[derive(Default)] struct ConversationsCtx { diff --git a/crates/notedeck_messages/src/nav.rs b/crates/notedeck_messages/src/nav.rs @@ -9,6 +9,7 @@ use crate::{ }, loader::MessagesLoader, nip17::send_conversation_message, + open_conversation_with_prefetch, }; #[derive(Clone, Debug)] @@ -160,7 +161,7 @@ fn handle_messages_action( )); cache.initialize_conversation(id, vec![recipient, *selected]); - cache.active = Some(id); + open_conversation_with_prefetch(&mut ctx.remote, ctx.accounts, cache, id); request_conversation_messages( cache, ctx.accounts.selected_account_pubkey(), @@ -197,7 +198,7 @@ fn open_coversation_action( loader: &MessagesLoader, inflight_messages: &mut HashSet<ConversationId>, ) { - cache.active = Some(id); + open_conversation_with_prefetch(&mut ctx.remote, ctx.accounts, cache, id); request_conversation_messages( cache, ctx.accounts.selected_account_pubkey(), diff --git a/crates/notedeck_messages/src/nip17/message.rs b/crates/notedeck_messages/src/nip17/message.rs @@ -1,7 +1,9 @@ +use nostrdb::Transaction; +use notedeck::enostr::RelayId; use notedeck::{AppContext, RelayType}; use crate::cache::{ConversationCache, ConversationId}; -use crate::nip17::{build_rumor_json, giftwrap_message, OsRng}; +use crate::nip17::{build_rumor_json, giftwrap_message, query_participant_dm_relays, OsRng}; pub fn send_conversation_message( conversation_id: ConversationId, @@ -36,6 +38,7 @@ pub fn send_conversation_message( return; }; + let txn = Transaction::new(ctx.ndb).expect("txn"); let mut rng = OsRng; for participant in &conversation.metadata.participants { let Some(gifrwrap_note) = @@ -53,7 +56,19 @@ pub fn send_conversation_message( } } + let participant_relays = query_participant_dm_relays(ctx.ndb, &txn, participant); + let relay_type = if participant_relays.is_empty() { + RelayType::AccountsWrite + } else { + RelayType::Explicit( + participant_relays + .into_iter() + .map(RelayId::Websocket) + .collect(), + ) + }; + let mut publisher = ctx.remote.publisher(ctx.accounts); - publisher.publish_note(&gifrwrap_note, RelayType::AccountsWrite); + publisher.publish_note(&gifrwrap_note, relay_type); } } diff --git a/crates/notedeck_messages/src/relay_prefetch.rs b/crates/notedeck_messages/src/relay_prefetch.rs @@ -0,0 +1,62 @@ +use enostr::Pubkey; +use notedeck::{ + Accounts, RelaySelection, RemoteApi, ScopedSubApi, ScopedSubIdentity, SubConfig, SubOwnerKey, +}; + +use crate::{ + cache::{ConversationCache, ConversationId}, + list_fetch_sub_key, list_prefetch_owner_key, + nip17::participant_dm_relay_list_filter, +}; + +/// Pure builder for the scoped-sub spec used to prefetch one participant relay list. +fn participant_relay_prefetch_spec(participant: &Pubkey) -> SubConfig { + SubConfig { + relays: RelaySelection::AccountsRead, + filters: vec![participant_dm_relay_list_filter(participant)], + use_transparent: false, + } +} + +/// Ensures remote prefetch subscriptions for one conversation's participants. +pub(crate) fn ensure_conversation_prefetch( + remote: &mut RemoteApi<'_>, + accounts: &Accounts, + cache: &ConversationCache, + conversation_id: ConversationId, +) { + let Some(conversation) = cache.get(conversation_id) else { + return; + }; + + ensure_participant_prefetch(remote, accounts, &conversation.metadata.participants); +} + +/// Ensures remote prefetch subscriptions for all provided participants. +pub(crate) fn ensure_participant_prefetch( + remote: &mut RemoteApi<'_>, + accounts: &Accounts, + participants: &[Pubkey], +) { + if participants.is_empty() { + return; + } + + let account_pk = *accounts.selected_account_pubkey(); + let owner = list_prefetch_owner_key(account_pk); + let mut scoped_subs = remote.scoped_subs(accounts); + ensure_participant_subs(&mut scoped_subs, owner, participants); +} + +fn ensure_participant_subs( + scoped_subs: &mut ScopedSubApi<'_, '_>, + owner: SubOwnerKey, + participants: &[Pubkey], +) { + for participant in participants { + let key = list_fetch_sub_key(participant); + let spec = participant_relay_prefetch_spec(participant); + let identity = ScopedSubIdentity::account(owner, key); + let _ = scoped_subs.ensure_sub(identity, spec); + } +}