notedeck

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

commit 96cb5e26ce1338e923fbcd313b400abb672a9330
parent 217c1e52239b4625c88e7427bcbeb05ab3437558
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 11 Jul 2025 13:06:04 -0700

Merge follow/unfollow from kernel

Jakub Gladysz (1):
      ui: add follow button

kernelkind (14):
      bump nostrdb
      move polling responsibility to `AccountData`
      `AccountData`: decouple query from constructor
      add constructor for `AccountData`
      add `Contacts`
      use `Contacts` in `AccountData`
      expose `AccountSubs`
      Unify sub for contacts in accounts & timeline
      move `styled_button_toggleable` to notedeck_ui
      construct NoteBuilder from existing note
      send kind 3 event
      add actions for follow/unfollow
      add UI for (un)follow
      send contact list event on account creation

Diffstat:
MCargo.lock | 4++--
MCargo.toml | 2+-
Mcrates/notedeck/src/account/accounts.rs | 176++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Acrates/notedeck/src/account/contacts.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/account/mod.rs | 1+
Mcrates/notedeck/src/account/mute.rs | 37++++++++++++++++++++++++++++---------
Mcrates/notedeck/src/account/relay.rs | 51++++++++++++++++++++++++++++++++++-----------------
Mcrates/notedeck/src/app.rs | 4++--
Mcrates/notedeck/src/lib.rs | 3++-
Mcrates/notedeck/src/user_account.rs | 8++++++--
Mcrates/notedeck_columns/src/accounts/mod.rs | 13+++++--------
Mcrates/notedeck_columns/src/app.rs | 61++++++++++++++++++++++---------------------------------------
Mcrates/notedeck_columns/src/nav.rs | 19++++++++++++++++++-
Mcrates/notedeck_columns/src/profile.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_columns/src/timeline/mod.rs | 51+++++++++++++++++++++++----------------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 10++++++++--
Mcrates/notedeck_columns/src/ui/add_column.rs | 2++
Mcrates/notedeck_columns/src/ui/note/custom_zap.rs | 7++++---
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 3+++
Mcrates/notedeck_columns/src/ui/widgets.rs | 46++--------------------------------------------
Mcrates/notedeck_ui/src/profile/mod.rs | 17+++++++++++++++--
Mcrates/notedeck_ui/src/widgets.rs | 44++++++++++++++++++++++++++++++++++++++++++++
23 files changed, 684 insertions(+), 252 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3267,8 +3267,8 @@ dependencies = [ [[package]] name = "nostrdb" -version = "0.6.1" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=3e87e504090b8cc153474e584a1ecd4618441099#3e87e504090b8cc153474e584a1ecd4618441099" +version = "0.7.0" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=ee7287a897fc229fa2ef060e2358a7ba258a4a6d#ee7287a897fc229fa2ef060e2358a7ba258a4a6d" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml @@ -37,7 +37,7 @@ log = "0.4.17" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "3e87e504090b8cc153474e584a1ecd4618441099" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "ee7287a897fc229fa2ef060e2358a7ba258a4a6d" } #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } diff --git a/crates/notedeck/src/account/accounts.rs b/crates/notedeck/src/account/accounts.rs @@ -1,7 +1,7 @@ -use tracing::debug; use uuid::Uuid; use crate::account::cache::AccountCache; +use crate::account::contacts::Contacts; use crate::account::mute::AccountMutedData; use crate::account::relay::{ modify_advertised_relays, update_relay_configuration, AccountRelayData, RelayAction, @@ -42,10 +42,7 @@ impl Accounts { ) -> Self { let (mut cache, unknown_id) = AccountCache::new(UserAccount::new( Keypair::only_pubkey(fallback), - AccountData { - relay: AccountRelayData::new(ndb, txn, fallback.bytes()), - muted: AccountMutedData::new(ndb, txn, fallback.bytes()), - }, + AccountData::new(fallback.bytes()), )); unknown_id.process_action(unknown_ids, ndb, txn); @@ -56,7 +53,7 @@ impl Accounts { match reader.get_accounts() { Ok(accounts) => { for account in accounts { - add_account_from_storage(&mut cache, ndb, txn, account).process_action( + add_account_from_storage(&mut cache, account).process_action( unknown_ids, ndb, txn, @@ -76,8 +73,10 @@ impl Accounts { let relay_defaults = RelayDefaults::new(forced_relays); - let selected = cache.selected(); - let selected_data = &selected.data; + let selected = cache.selected_mut(); + let selected_data = &mut selected.data; + + selected_data.query(ndb, txn); let subs = { AccountSubs::new( @@ -117,12 +116,7 @@ impl Accounts { } #[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"] - pub fn add_account( - &mut self, - ndb: &Ndb, - txn: &Transaction, - kp: Keypair, - ) -> Option<AddAccountResponse> { + pub fn add_account(&mut self, kp: Keypair) -> Option<AddAccountResponse> { let acc = if let Some(acc) = self.cache.get_mut(&kp.pubkey) { if kp.secret_key.is_none() || acc.key.secret_key.is_some() { tracing::info!("Already have account, not adding"); @@ -132,10 +126,7 @@ impl Accounts { acc.key = kp.clone(); AccType::Acc(&*acc) } else { - let new_account_data = AccountData { - relay: AccountRelayData::new(ndb, txn, kp.pubkey.bytes()), - muted: AccountMutedData::new(ndb, txn, kp.pubkey.bytes()), - }; + let new_account_data = AccountData::new(kp.pubkey.bytes()); AccType::Entry( self.cache .add(UserAccount::new(kp.clone(), new_account_data)), @@ -213,6 +204,7 @@ impl Accounts { &mut self, pk_to_select: &Pubkey, ndb: &mut Ndb, + txn: &Transaction, pool: &mut RelayPool, ctx: &egui::Context, ) { @@ -226,6 +218,7 @@ impl Accounts { } } + self.get_selected_account_mut().data.query(ndb, txn); self.subs.swap_to( ndb, pool, @@ -261,53 +254,40 @@ impl Accounts { ), relay_url, ); - } - - fn poll_for_updates(&mut self, ndb: &Ndb) -> bool { - let mut changed = false; - let relay_sub = self.subs.relay.local; - let mute_sub = self.subs.mute.local; - let acc = self.get_selected_account_mut(); - - let nks = ndb.poll_for_notes(relay_sub, 1); - if !nks.is_empty() { - let txn = Transaction::new(ndb).expect("txn"); - let relays = AccountRelayData::harvest_nip65_relays(ndb, &txn, &nks); - debug!( - "pubkey {}: updated relays {:?}", - acc.key.pubkey.hex(), - relays - ); - acc.data.relay.advertised = relays.into_iter().collect(); - changed = true; - } - - let nks = ndb.poll_for_notes(mute_sub, 1); - if !nks.is_empty() { - let txn = Transaction::new(ndb).expect("txn"); - let muted = AccountMutedData::harvest_nip51_muted(ndb, &txn, &nks); - debug!("pubkey {}: updated muted {:?}", acc.key.pubkey.hex(), muted); - acc.data.muted.muted = Arc::new(muted); - changed = true; - } - - changed + pool.send_to( + &ClientMessage::req( + self.subs.contacts.remote.clone(), + vec![data.contacts.filter.clone()], + ), + relay_url, + ); } pub fn update(&mut self, ndb: &mut Ndb, pool: &mut RelayPool, ctx: &egui::Context) { // IMPORTANT - This function is called in the UI update loop, // make sure it is fast when idle - // If needed, update the relay configuration - if self.poll_for_updates(ndb) { - let acc = self.cache.selected(); - update_relay_configuration( - pool, - &self.relay_defaults, - &acc.key.pubkey, - &acc.data, - create_wakeup(ctx), - ); + let Some(update) = self + .cache + .selected_mut() + .data + .poll_for_updates(ndb, &self.subs) + else { + return; + }; + + match update { + // If needed, update the relay configuration + AccountDataUpdate::Relay => { + let acc = self.cache.selected(); + update_relay_configuration( + pool, + &self.relay_defaults, + &acc.key.pubkey, + &acc.data.relay, + create_wakeup(ctx), + ); + } } } @@ -328,10 +308,14 @@ impl Accounts { pool, &self.relay_defaults, &acc.key.pubkey, - &acc.data, + &acc.data.relay, create_wakeup(ctx), ); } + + pub fn get_subs(&self) -> &AccountSubs { + &self.subs + } } enum AccType<'a> { @@ -357,11 +341,9 @@ fn create_wakeup(ctx: &egui::Context) -> impl Fn() + Send + Sync + Clone + 'stat fn add_account_from_storage( cache: &mut AccountCache, - ndb: &Ndb, - txn: &Transaction, user_account_serializable: UserAccountSerializable, ) -> SingleUnkIdAction { - let Some(acc) = get_acc_from_storage(ndb, txn, user_account_serializable) else { + let Some(acc) = get_acc_from_storage(user_account_serializable) else { return SingleUnkIdAction::NoAction; }; @@ -371,16 +353,9 @@ fn add_account_from_storage( SingleUnkIdAction::pubkey(pk) } -fn get_acc_from_storage( - ndb: &Ndb, - txn: &Transaction, - user_account_serializable: UserAccountSerializable, -) -> Option<UserAccount> { +fn get_acc_from_storage(user_account_serializable: UserAccountSerializable) -> Option<UserAccount> { let keypair = user_account_serializable.key; - let new_account_data = AccountData { - relay: AccountRelayData::new(ndb, txn, keypair.pubkey.bytes()), - muted: AccountMutedData::new(ndb, txn, keypair.pubkey.bytes()), - }; + let new_account_data = AccountData::new(keypair.pubkey.bytes()); let mut wallet = None; if let Some(wallet_s) = user_account_serializable.wallet { @@ -403,6 +378,46 @@ fn get_acc_from_storage( pub struct AccountData { pub(crate) relay: AccountRelayData, pub(crate) muted: AccountMutedData, + pub contacts: Contacts, +} + +impl AccountData { + pub fn new(pubkey: &[u8; 32]) -> Self { + Self { + relay: AccountRelayData::new(pubkey), + muted: AccountMutedData::new(pubkey), + contacts: Contacts::new(pubkey), + } + } + + pub(super) fn poll_for_updates( + &mut self, + ndb: &Ndb, + subs: &AccountSubs, + ) -> Option<AccountDataUpdate> { + let txn = Transaction::new(ndb).expect("txn"); + let mut resp = None; + if self.relay.poll_for_updates(ndb, &txn, subs.relay.local) { + resp = Some(AccountDataUpdate::Relay); + } + + self.muted.poll_for_updates(ndb, &txn, subs.mute.local); + self.contacts + .poll_for_updates(ndb, &txn, subs.contacts.local); + + resp + } + + /// Note: query should be called as close to the subscription as possible + pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) { + self.relay.query(ndb, txn); + self.muted.query(ndb, txn); + self.contacts.query(ndb, txn); + } +} + +pub(super) enum AccountDataUpdate { + Relay, } pub struct AddAccountResponse { @@ -410,13 +425,14 @@ pub struct AddAccountResponse { pub unk_id_action: SingleUnkIdAction, } -struct AccountSubs { +pub struct AccountSubs { relay: UnifiedSubscription, mute: UnifiedSubscription, + pub contacts: UnifiedSubscription, } impl AccountSubs { - pub fn new( + pub(super) fn new( ndb: &mut Ndb, pool: &mut RelayPool, relay_defaults: &RelayDefaults, @@ -426,12 +442,17 @@ impl AccountSubs { ) -> Self { let relay = subscribe(ndb, pool, &data.relay.filter); let mute = subscribe(ndb, pool, &data.muted.filter); - update_relay_configuration(pool, relay_defaults, pk, data, wakeup); + let contacts = subscribe(ndb, pool, &data.contacts.filter); + update_relay_configuration(pool, relay_defaults, pk, &data.relay, wakeup); - Self { relay, mute } + Self { + relay, + mute, + contacts, + } } - pub fn swap_to( + pub(super) fn swap_to( &mut self, ndb: &mut Ndb, pool: &mut RelayPool, @@ -442,6 +463,7 @@ impl AccountSubs { ) { unsubscribe(ndb, pool, &self.relay); unsubscribe(ndb, pool, &self.mute); + unsubscribe(ndb, pool, &self.contacts); *self = AccountSubs::new(ndb, pool, relay_defaults, pk, new_selection_data, wakeup); } diff --git a/crates/notedeck/src/account/contacts.rs b/crates/notedeck/src/account/contacts.rs @@ -0,0 +1,145 @@ +use std::collections::HashSet; + +use enostr::Pubkey; +use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction}; + +pub struct Contacts { + pub filter: Filter, + pub(super) state: ContactState, +} + +pub enum ContactState { + Unreceived, + Received { + contacts: HashSet<Pubkey>, + note_key: NoteKey, + }, +} + +#[derive(Eq, PartialEq, Debug, Clone, Copy)] +pub enum IsFollowing { + /// We don't have the contact list, so we don't know + Unknown, + + /// We are follow + Yes, + + No, +} + +impl Contacts { + pub fn new(pubkey: &[u8; 32]) -> Self { + let filter = Filter::new().authors([pubkey]).kinds([3]).limit(1).build(); + + Self { + filter, + state: ContactState::Unreceived, + } + } + + pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) { + let binding = ndb + .query(txn, &[self.filter.clone()], 1) + .expect("query user relays results"); + + let Some(res) = binding.first() else { + return; + }; + + update_state(&mut self.state, &res.note, res.note_key); + } + + pub fn is_following(&self, other: &Pubkey) -> IsFollowing { + match &self.state { + ContactState::Unreceived => IsFollowing::Unknown, + ContactState::Received { + contacts, + note_key: _, + } => { + if contacts.contains(other) { + IsFollowing::Yes + } else { + IsFollowing::No + } + } + } + } + + pub(super) fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) { + let nks = ndb.poll_for_notes(sub, 1); + + let Some(key) = nks.first() else { + return; + }; + + let note = match ndb.get_note_by_key(txn, *key) { + Ok(note) => note, + Err(e) => { + tracing::error!("Could not find note at key {:?}: {e}", key); + return; + } + }; + + update_state(&mut self.state, &note, *key); + } + + pub fn get_state(&self) -> &ContactState { + &self.state + } +} + +fn update_state(state: &mut ContactState, note: &Note, key: NoteKey) { + match state { + ContactState::Unreceived => { + *state = ContactState::Received { + contacts: get_contacts_owned(note), + note_key: key, + }; + } + ContactState::Received { contacts, note_key } => { + update_contacts(contacts, note); + *note_key = key; + } + }; +} + +fn get_contacts<'a>(note: &Note<'a>) -> HashSet<&'a [u8; 32]> { + let mut contacts = HashSet::with_capacity(note.tags().count().into()); + + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + + let Some("p") = tag.get_str(0) else { + continue; + }; + + let Some(cur_id) = tag.get_id(1) else { + continue; + }; + + contacts.insert(cur_id); + } + + contacts +} + +fn get_contacts_owned(note: &Note<'_>) -> HashSet<Pubkey> { + get_contacts(note) + .iter() + .map(|p| Pubkey::new(**p)) + .collect() +} + +fn update_contacts(cur: &mut HashSet<Pubkey>, new: &Note<'_>) { + let new_contacts = get_contacts(new); + + cur.retain(|pk| new_contacts.contains(pk.bytes())); + + new_contacts.iter().for_each(|c| { + if !cur.contains(*c) { + cur.insert(Pubkey::new(**c)); + } + }); +} diff --git a/crates/notedeck/src/account/mod.rs b/crates/notedeck/src/account/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod cache; +pub mod contacts; pub mod mute; pub mod relay; diff --git a/crates/notedeck/src/account/mute.rs b/crates/notedeck/src/account/mute.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use nostrdb::{Filter, Ndb, NoteKey, Transaction}; +use nostrdb::{Filter, Ndb, NoteKey, Subscription, Transaction}; use tracing::{debug, error}; use crate::Muted; @@ -11,7 +11,7 @@ pub(crate) struct AccountMutedData { } impl AccountMutedData { - pub fn new(ndb: &Ndb, txn: &Transaction, pubkey: &[u8; 32]) -> Self { + pub fn new(pubkey: &[u8; 32]) -> Self { // Construct a filter for the user's NIP-51 muted list let filter = Filter::new() .authors([pubkey]) @@ -19,21 +19,28 @@ impl AccountMutedData { .limit(1) .build(); + AccountMutedData { + filter, + muted: Arc::new(Muted::default()), + } + } + + pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) { // Query the ndb immediately to see if the user's muted list is already there - let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; + let lim = self + .filter + .limit() + .unwrap_or(crate::filter::default_limit()) as i32; let nks = ndb - .query(txn, &[filter.clone()], lim) + .query(txn, &[self.filter.clone()], lim) .expect("query user muted results") .iter() .map(|qr| qr.note_key) .collect::<Vec<NoteKey>>(); let muted = Self::harvest_nip51_muted(ndb, txn, &nks); - debug!("pubkey {}: initial muted {:?}", hex::encode(pubkey), muted); + debug!("initial muted {:?}", muted); - AccountMutedData { - filter, - muted: Arc::new(muted), - } + self.muted = Arc::new(muted); } pub(crate) fn harvest_nip51_muted(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Muted { @@ -76,4 +83,16 @@ impl AccountMutedData { } muted } + + pub(super) fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) { + let nks = ndb.poll_for_notes(sub, 1); + + if nks.is_empty() { + return; + } + + let muted = AccountMutedData::harvest_nip51_muted(ndb, txn, &nks); + debug!("updated muted {:?}", muted); + self.muted = Arc::new(muted); + } } diff --git a/crates/notedeck/src/account/relay.rs b/crates/notedeck/src/account/relay.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use enostr::{Keypair, Pubkey, RelayPool}; -use nostrdb::{Filter, Ndb, NoteBuilder, NoteKey, Transaction}; +use nostrdb::{Filter, Ndb, NoteBuilder, NoteKey, Subscription, Transaction}; use tracing::{debug, error, info}; use url::Url; @@ -14,7 +14,7 @@ pub(crate) struct AccountRelayData { } impl AccountRelayData { - pub fn new(ndb: &Ndb, txn: &Transaction, pubkey: &[u8; 32]) -> Self { + pub fn new(pubkey: &[u8; 32]) -> Self { // Construct a filter for the user's NIP-65 relay list let filter = Filter::new() .authors([pubkey]) @@ -22,26 +22,29 @@ impl AccountRelayData { .limit(1) .build(); + AccountRelayData { + filter, + local: BTreeSet::new(), + advertised: BTreeSet::new(), + } + } + + pub fn query(&mut self, ndb: &Ndb, txn: &Transaction) { // Query the ndb immediately to see if the user list is already there - let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; + let lim = self + .filter + .limit() + .unwrap_or(crate::filter::default_limit()) as i32; let nks = ndb - .query(txn, &[filter.clone()], lim) + .query(txn, &[self.filter.clone()], lim) .expect("query user relays results") .iter() .map(|qr| qr.note_key) .collect::<Vec<NoteKey>>(); let relays = Self::harvest_nip65_relays(ndb, txn, &nks); - debug!( - "pubkey {}: initial relays {:?}", - hex::encode(pubkey), - relays - ); + debug!("initial relays {:?}", relays); - AccountRelayData { - filter, - local: BTreeSet::new(), - advertised: relays.into_iter().collect(), - } + self.advertised = relays.into_iter().collect() } // standardize the format (ie, trailing slashes) to avoid dups @@ -106,6 +109,20 @@ impl AccountRelayData { let note = builder.sign(seckey).build().expect("note build"); pool.send(&enostr::ClientMessage::event(&note).expect("note client message")); } + + pub fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) -> bool { + let nks = ndb.poll_for_notes(sub, 1); + + if nks.is_empty() { + return false; + } + + let relays = AccountRelayData::harvest_nip65_relays(ndb, txn, &nks); + debug!("updated relays {:?}", relays); + self.advertised = relays.into_iter().collect(); + + true + } } pub(crate) struct RelayDefaults { @@ -142,7 +159,7 @@ pub(super) fn update_relay_configuration( pool: &mut RelayPool, relay_defaults: &RelayDefaults, pk: &Pubkey, - data: &AccountData, + data: &AccountRelayData, wakeup: impl Fn() + Send + Sync + Clone + 'static, ) { debug!( @@ -155,8 +172,8 @@ pub(super) fn update_relay_configuration( // Compose the desired relay lists from the selected account if desired_relays.is_empty() { - desired_relays.extend(data.relay.local.iter().cloned()); - desired_relays.extend(data.relay.advertised.iter().cloned()); + desired_relays.extend(data.local.iter().cloned()); + desired_relays.extend(data.advertised.iter().cloned()); } // If no relays are specified at this point use the bootstrap list diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -203,7 +203,7 @@ impl Notedeck { { for key in &parsed_args.keys { info!("adding account: {}", &key.pubkey); - if let Some(resp) = accounts.add_account(&ndb, &txn, key.clone()) { + if let Some(resp) = accounts.add_account(key.clone()) { resp.unk_id_action .process_action(&mut unknown_ids, &ndb, &txn); } @@ -211,7 +211,7 @@ impl Notedeck { } if let Some(first) = parsed_args.keys.first() { - accounts.select_account(&first.pubkey, &mut ndb, &mut pool, ctx); + accounts.select_account(&first.pubkey, &mut ndb, &txn, &mut pool, ctx); } let img_cache = Images::new(img_cache_dir); diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -33,7 +33,8 @@ mod user_account; mod wallet; mod zaps; -pub use account::accounts::{AccountData, Accounts}; +pub use account::accounts::{AccountData, AccountSubs, Accounts}; +pub use account::contacts::{ContactState, IsFollowing}; pub use account::relay::RelayAction; pub use account::FALLBACK_PUBKEY; pub use app::{App, AppAction, Notedeck}; diff --git a/crates/notedeck/src/user_account.rs b/crates/notedeck/src/user_account.rs @@ -1,9 +1,9 @@ -use enostr::{Keypair, KeypairUnowned}; +use enostr::{Keypair, KeypairUnowned, Pubkey}; use tokenator::{ParseError, TokenParser, TokenSerializable}; use crate::{ wallet::{WalletSerializable, ZapWallet}, - AccountData, + AccountData, IsFollowing, }; pub struct UserAccount { @@ -32,6 +32,10 @@ impl UserAccount { self.wallet = Some(wallet); self } + + pub fn is_following(&self, pk: &Pubkey) -> IsFollowing { + self.data.contacts.is_following(pk) + } } pub struct UserAccountSerializable { diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -5,6 +5,7 @@ use notedeck::{Accounts, AppContext, SingleUnkIdAction, UnknownIds}; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; +use crate::profile::send_new_contact_list; use crate::{ login_manager::AcquireKeyState, route::Route, @@ -149,18 +150,14 @@ pub fn process_login_view_response( ) -> AddAccountAction { let (r, pubkey) = match response { AccountLoginResponse::CreateNew => { - let kp = FullKeypair::generate().to_keypair(); + let kp = FullKeypair::generate(); let pubkey = kp.pubkey; - let txn = Transaction::new(app_ctx.ndb).expect("txn"); - (app_ctx.accounts.add_account(app_ctx.ndb, &txn, kp), pubkey) + send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool); + (app_ctx.accounts.add_account(kp.to_keypair()), pubkey) } AccountLoginResponse::LoginWith(keypair) => { let pubkey = keypair.pubkey; - let txn = Transaction::new(app_ctx.ndb).expect("txn"); - ( - app_ctx.accounts.add_account(app_ctx.ndb, &txn, keypair), - pubkey, - ) + (app_ctx.accounts.add_account(keypair), pubkey) } }; diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -8,14 +8,16 @@ use crate::{ storage, subscriptions::{SubKind, Subscriptions}, support::Support, - timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, + timeline::{ + self, fetch_contact_list, kind::ListKind, thread::Threads, TimelineCache, TimelineKind, + }, ui::{self, DesktopSidePanel, SidePanelAction}, view_state::ViewState, Result, }; use notedeck::{ - ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, UnknownIds, + ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, UnknownIds, }; use notedeck_ui::{jobs::JobsCache, NoteOptions}; @@ -116,13 +118,21 @@ fn try_process_event( .accounts .send_initial_filters(app_ctx.pool, &ev.relay); + let data = app_ctx.accounts.get_subs(); + damus.subscriptions.subs.insert( + data.contacts.remote.clone(), + SubKind::FetchingContactList(TimelineKind::List(ListKind::Contact( + *app_ctx.accounts.selected_account_pubkey(), + ))), + ); + timeline::send_initial_timeline_filters( - app_ctx.ndb, damus.since_optimize, &mut damus.timeline_cache, &mut damus.subscriptions, app_ctx.pool, &ev.relay, + app_ctx.accounts, ); } // TODO: handle reconnects @@ -248,44 +258,11 @@ fn handle_eose( } SubKind::FetchingContactList(timeline_uid) => { - let timeline = if let Some(tl) = timeline_cache.timelines.get_mut(timeline_uid) { - tl - } else { - error!( - "timeline uid:{} not found for FetchingContactList", - timeline_uid - ); - return Ok(()); - }; - - let filter_state = timeline.filter.get_mut(relay_url); - - // If this request was fetching a contact list, our filter - // state should be "FetchingRemote". We look at the local - // subscription for that filter state and get the subscription id - let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state { - unisub.local - } else { - // TODO: we could have multiple contact list results, we need - // to check to see if this one is newer and use that instead - warn!( - "Expected timeline to have FetchingRemote state but was {:?}", - timeline.filter - ); + let Some(timeline) = timeline_cache.timelines.get_mut(timeline_uid) else { return Ok(()); }; - info!( - "got contact list from {}, updating filter_state to got_remote", - relay_url - ); - - // We take the subscription id and pass it to the new state of - // "GotRemote". This will let future frames know that it can try - // to look for the contact list in nostrdb. - timeline - .filter - .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub)); + fetch_contact_list(relay_url, timeline, ctx.accounts); } } @@ -747,7 +724,13 @@ fn timelines_view( let mut save_cols = false; if let Some(action) = side_panel_action { save_cols = save_cols - || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx, ui.ctx()); + || action.process( + &mut app.timeline_cache, + &mut app.decks_cache, + &mut app.subscriptions, + ctx, + ui.ctx(), + ); } let mut app_action: Option<AppAction> = None; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -7,9 +7,11 @@ use crate::{ profile::{ProfileAction, SaveProfileChanges}, profile_state::ProfileState, route::{Route, Router, SingletonRouter}, + subscriptions::{SubKind, Subscriptions}, timeline::{ + kind::ListKind, route::{render_thread_route, render_timeline_route}, - TimelineCache, + TimelineCache, TimelineKind, }, ui::{ self, @@ -72,18 +74,31 @@ impl SwitchingAction { &self, timeline_cache: &mut TimelineCache, decks_cache: &mut DecksCache, + subs: &mut Subscriptions, ctx: &mut AppContext<'_>, ui_ctx: &egui::Context, ) -> bool { match &self { SwitchingAction::Accounts(account_action) => match account_action { AccountsAction::Switch(switch_action) => { + let txn = Transaction::new(ctx.ndb).expect("txn"); ctx.accounts.select_account( &switch_action.switch_to, ctx.ndb, + &txn, ctx.pool, ui_ctx, ); + + let new_subs = ctx.accounts.get_subs(); + + subs.subs.insert( + new_subs.contacts.remote.clone(), + SubKind::FetchingContactList(TimelineKind::List(ListKind::Contact( + *ctx.accounts.selected_account_pubkey(), + ))), + ); + // pop nav after switch get_active_columns_mut(ctx.accounts, decks_cache) .column_mut(switch_action.source_column) @@ -378,6 +393,7 @@ fn process_render_nav_action( if switching_action.process( &mut app.timeline_cache, &mut app.decks_cache, + &mut app.subscriptions, ctx, ui.ctx(), ) { @@ -390,6 +406,7 @@ fn process_render_nav_action( &mut app.view_state.pubkey_to_profile_state, ctx.ndb, ctx.pool, + ctx.accounts, ), RenderNavAction::WalletAction(wallet_action) => { wallet_action.process(ctx.accounts, ctx.global_wallet) diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; -use enostr::{FullKeypair, Pubkey, RelayPool}; -use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder}; +use enostr::{FilledKeypair, FullKeypair, Pubkey, RelayPool}; +use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; +use notedeck::{Accounts, ContactState}; use tracing::info; use crate::{nav::RouterAction, profile_state::ProfileState, route::Route}; @@ -37,6 +38,8 @@ fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { pub enum ProfileAction { Edit(FullKeypair), SaveChanges(SaveProfileChanges), + Follow(Pubkey), + Unfollow(Pubkey), } impl ProfileAction { @@ -45,6 +48,7 @@ impl ProfileAction { state_map: &mut HashMap<Pubkey, ProfileState>, ndb: &Ndb, pool: &mut RelayPool, + accounts: &Accounts, ) -> Option<RouterAction> { match self { ProfileAction::Edit(kp) => Some(RouterAction::route_to(Route::EditProfile(kp.pubkey))), @@ -62,6 +66,157 @@ impl ProfileAction { Some(RouterAction::GoBack) } + ProfileAction::Follow(target_key) => { + Self::send_follow_user_event(ndb, pool, accounts, target_key); + None + } + ProfileAction::Unfollow(target_key) => { + Self::send_unfollow_user_event(ndb, pool, accounts, target_key); + None + } + } + } + + fn send_follow_user_event( + ndb: &Ndb, + pool: &mut RelayPool, + accounts: &Accounts, + target_key: &Pubkey, + ) { + send_kind_3_event(ndb, pool, accounts, FollowAction::Follow(target_key)); + } + + fn send_unfollow_user_event( + ndb: &Ndb, + pool: &mut RelayPool, + accounts: &Accounts, + target_key: &Pubkey, + ) { + send_kind_3_event(ndb, pool, accounts, FollowAction::Unfollow(target_key)); + } +} + +pub fn builder_from_note<F>(note: Note<'_>, skip_tag: Option<F>) -> NoteBuilder<'_> +where + F: Fn(&nostrdb::Tag<'_>) -> bool, +{ + let mut builder = NoteBuilder::new(); + + builder = builder.content(note.content()); + builder = builder.options(NoteBuildOptions::default()); + builder = builder.kind(note.kind()); + builder = builder.pubkey(note.pubkey()); + + for tag in note.tags() { + if let Some(skip) = &skip_tag { + if skip(&tag) { + continue; + } + } + + builder = builder.start_tag(); + for tag_item in tag { + builder = match tag_item.variant() { + nostrdb::NdbStrVariant::Id(i) => builder.tag_id(i), + nostrdb::NdbStrVariant::Str(s) => builder.tag_str(s), + }; } } + + builder +} + +enum FollowAction<'a> { + Follow(&'a Pubkey), + Unfollow(&'a Pubkey), +} + +fn send_kind_3_event(ndb: &Ndb, pool: &mut RelayPool, accounts: &Accounts, action: FollowAction) { + let Some(kp) = accounts.get_selected_account().key.to_full() else { + return; + }; + + let txn = Transaction::new(ndb).expect("txn"); + + let ContactState::Received { + contacts: _, + note_key, + } = accounts.get_selected_account().data.contacts.get_state() + else { + return; + }; + + let contact_note = match ndb.get_note_by_key(&txn, *note_key).ok() { + Some(n) => n, + None => { + tracing::error!("Somehow we are in state ContactState::Received but the contact note key doesn't exist"); + return; + } + }; + + if contact_note.kind() != 3 { + tracing::error!("Something very wrong just occured. The key for the supposed contact note yielded a note which was not a contact..."); + return; + } + + let builder = match action { + FollowAction::Follow(pubkey) => { + builder_from_note(contact_note, None::<fn(&nostrdb::Tag<'_>) -> bool>) + .start_tag() + .tag_str("p") + .tag_str(&pubkey.hex()) + } + FollowAction::Unfollow(pubkey) => builder_from_note( + contact_note, + Some(|tag: &nostrdb::Tag<'_>| { + if tag.count() < 2 { + return false; + } + + let Some("p") = tag.get_str(0) else { + return false; + }; + + let Some(cur_val) = tag.get_id(1) else { + return false; + }; + + cur_val == pubkey.bytes() + }), + ), + }; + + send_note_builder(builder, ndb, pool, kp); +} + +fn send_note_builder(builder: NoteBuilder, ndb: &Ndb, pool: &mut RelayPool, kp: FilledKeypair) { + let note = builder + .sign(&kp.secret_key.secret_bytes()) + .build() + .expect("build note"); + + let raw_msg = format!("[\"EVENT\",{}]", note.json().unwrap()); + + let _ = ndb.process_event_with( + raw_msg.as_str(), + nostrdb::IngestMetadata::new().client(true), + ); + info!("sending {}", raw_msg); + pool.send(&enostr::ClientMessage::raw(raw_msg)); +} + +pub fn send_new_contact_list(kp: FilledKeypair, ndb: &Ndb, pool: &mut RelayPool) { + let builder = construct_new_contact_list(kp.pubkey); + + send_note_builder(builder, ndb, pool, kp); +} + +fn construct_new_contact_list<'a>(pk: &'a Pubkey) -> NoteBuilder<'a> { + NoteBuilder::new() + .content("") + .kind(3) + .options(NoteBuildOptions::default()) + .start_tag() + .tag_str("p") + .tag_str(&pk.hex()) } diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -7,7 +7,8 @@ use crate::{ }; use notedeck::{ - filter, CachedNote, FilterError, FilterState, FilterStates, NoteCache, NoteRef, UnknownIds, + filter, Accounts, CachedNote, FilterError, FilterState, FilterStates, NoteCache, NoteRef, + UnknownIds, }; use egui_virtual_list::VirtualList; @@ -474,6 +475,7 @@ pub fn setup_new_timeline( pool: &mut RelayPool, note_cache: &mut NoteCache, since_optimize: bool, + accounts: &Accounts, ) { // if we're ready, setup local subs if is_timeline_ready(ndb, pool, note_cache, timeline) { @@ -483,7 +485,7 @@ pub fn setup_new_timeline( } for relay in &mut pool.relays { - send_initial_timeline_filter(ndb, since_optimize, subs, relay, timeline); + send_initial_timeline_filter(since_optimize, subs, relay, timeline, accounts); } } @@ -492,29 +494,29 @@ pub fn setup_new_timeline( /// situations where you are adding a new timeline, use /// setup_new_timeline. pub fn send_initial_timeline_filters( - ndb: &Ndb, since_optimize: bool, timeline_cache: &mut TimelineCache, subs: &mut Subscriptions, pool: &mut RelayPool, relay_id: &str, + accounts: &Accounts, ) -> Option<()> { info!("Sending initial filters to {}", relay_id); let relay = &mut pool.relays.iter_mut().find(|r| r.url() == relay_id)?; for (_kind, timeline) in timeline_cache.timelines.iter_mut() { - send_initial_timeline_filter(ndb, since_optimize, subs, relay, timeline); + send_initial_timeline_filter(since_optimize, subs, relay, timeline, accounts); } Some(()) } pub fn send_initial_timeline_filter( - ndb: &Ndb, can_since_optimize: bool, subs: &mut Subscriptions, relay: &mut PoolRelay, timeline: &mut Timeline, + accounts: &Accounts, ) { let filter_state = timeline.filter.get_mut(relay.url()); @@ -572,34 +574,27 @@ pub fn send_initial_timeline_filter( } // we need some data first - FilterState::NeedsRemote(filter) => { - fetch_contact_list(filter.to_owned(), ndb, subs, relay, timeline) - } + FilterState::NeedsRemote(_filter) => fetch_contact_list(relay.url(), timeline, accounts), } } -fn fetch_contact_list( - filter: Vec<Filter>, - ndb: &Ndb, - subs: &mut Subscriptions, - relay: &mut PoolRelay, - timeline: &mut Timeline, -) { - let sub_kind = SubKind::FetchingContactList(timeline.kind.clone()); - let sub_id = subscriptions::new_sub_id(); - let local_sub = ndb.subscribe(&filter).expect("sub"); - - timeline.filter.set_relay_state( - relay.url().to_string(), - FilterState::fetching_remote(sub_id.clone(), local_sub), - ); +pub fn fetch_contact_list(relay_url: &str, timeline: &mut Timeline, accounts: &Accounts) { + let account_subs = accounts.get_subs(); + let local = account_subs.contacts.local; - subs.subs.insert(sub_id.clone(), sub_kind); + let filter_state = match accounts.get_selected_account().data.contacts.get_state() { + notedeck::ContactState::Unreceived => { + FilterState::fetching_remote(account_subs.contacts.remote.clone(), local) + } + notedeck::ContactState::Received { + contacts: _, + note_key: _, + } => FilterState::GotRemote(local), + }; - info!("fetching contact list from {}", relay.url()); - if let Err(err) = relay.subscribe(sub_id, filter) { - error!("error subscribing: {err}"); - } + timeline + .filter + .set_relay_state(relay_url.to_owned(), filter_state); } fn setup_initial_timeline( diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -116,7 +116,7 @@ pub fn render_profile_route( note_context: &mut NoteContext, jobs: &mut JobsCache, ) -> Option<RenderNavAction> { - let action = ProfileView::new( + let profile_view = ProfileView::new( pubkey, accounts, col, @@ -128,7 +128,7 @@ pub fn render_profile_route( ) .ui(ui); - if let Some(action) = action { + if let Some(action) = profile_view { match action { ui::profile::ProfileViewAction::EditProfile => accounts .get_full(pubkey) @@ -136,6 +136,12 @@ pub fn render_profile_route( ui::profile::ProfileViewAction::Note(note_action) => { Some(RenderNavAction::NoteAction(note_action)) } + ui::profile::ProfileViewAction::Follow(target_key) => Some( + RenderNavAction::ProfileAction(ProfileAction::Follow(target_key)), + ), + ui::profile::ProfileViewAction::Unfollow(target_key) => Some( + RenderNavAction::ProfileAction(ProfileAction::Unfollow(target_key)), + ), } } else { None diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -623,6 +623,7 @@ pub fn render_add_column_routes( ctx.pool, ctx.note_cache, app.since_optimize, + ctx.accounts, ); app.columns_mut(ctx.accounts) @@ -664,6 +665,7 @@ pub fn render_add_column_routes( ctx.pool, ctx.note_cache, app.since_optimize, + ctx.accounts, ); app.columns_mut(ctx.accounts) diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs b/crates/notedeck_columns/src/ui/note/custom_zap.rs @@ -9,9 +9,10 @@ use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ fonts::get_font_size, get_profile_url, name::get_display_name, Images, NotedeckTextStyle, }; -use notedeck_ui::{app_images, colors, profile::display_name_widget, AnimationHelper, ProfilePic}; - -use crate::ui::widgets::styled_button_toggleable; +use notedeck_ui::{ + app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable, + AnimationHelper, ProfilePic, +}; pub struct CustomZapView<'a> { images: &'a mut Images, diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -4,6 +4,7 @@ pub use edit::EditProfileView; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; +use notedeck_ui::profile::follow_button; use tracing::error; use crate::{ @@ -11,8 +12,8 @@ use crate::{ ui::timeline::{tabs_ui, TimelineTabView}, }; use notedeck::{ - name::get_display_name, profile::get_profile_url, Accounts, MuteFun, NoteAction, NoteContext, - NotedeckTextStyle, + name::get_display_name, profile::get_profile_url, Accounts, IsFollowing, MuteFun, NoteAction, + NoteContext, NotedeckTextStyle, }; use notedeck_ui::{ app_images, @@ -35,6 +36,8 @@ pub struct ProfileView<'a, 'd> { pub enum ProfileViewAction { EditProfile, Note(NoteAction), + Unfollow(Pubkey), + Follow(Pubkey), } impl<'a, 'd> ProfileView<'a, 'd> { @@ -79,8 +82,8 @@ impl<'a, 'd> ProfileView<'a, 'd> { .ndb .get_profile_by_pubkey(&txn, self.pubkey.bytes()) { - if self.profile_body(ui, profile) { - action = Some(ProfileViewAction::EditProfile); + if let Some(profile_view_action) = self.profile_body(ui, profile) { + action = Some(profile_view_action); } } let profile_timeline = self @@ -131,8 +134,12 @@ impl<'a, 'd> ProfileView<'a, 'd> { output.inner } - fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool { - let mut action = false; + fn profile_body( + &mut self, + ui: &mut egui::Ui, + profile: ProfileRecord<'_>, + ) -> Option<ProfileViewAction> { + let mut action = None; ui.vertical(|ui| { banner( ui, @@ -169,13 +176,49 @@ impl<'a, 'd> ProfileView<'a, 'd> { ui.ctx().copy_text(to_copy) } - if self.accounts.contains_full_kp(self.pubkey) { - ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| { - if ui.add(edit_profile_button()).clicked() { - action = true; + ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| { + ui.add_space(24.0); + + let target_key = self.pubkey; + let selected = self.accounts.get_selected_account(); + + let profile_type = if selected.key.secret_key.is_none() { + ProfileType::ReadOnly + } else if &selected.key.pubkey == self.pubkey { + ProfileType::MyProfile + } else { + ProfileType::Followable(selected.is_following(target_key)) + }; + + match profile_type { + ProfileType::MyProfile => { + if ui.add(edit_profile_button()).clicked() { + action = Some(ProfileViewAction::EditProfile); + } } - }); - } + ProfileType::Followable(is_following) => { + let follow_button = ui.add(follow_button(is_following)); + + if follow_button.clicked() { + action = match is_following { + IsFollowing::Unknown => { + // don't do anything, we don't have contact list + None + } + + IsFollowing::Yes => { + Some(ProfileViewAction::Unfollow(target_key.to_owned())) + } + + IsFollowing::No => { + Some(ProfileViewAction::Follow(target_key.to_owned())) + } + }; + } + } + ProfileType::ReadOnly => {} + } + }); }); ui.add_space(18.0); @@ -215,6 +258,12 @@ impl<'a, 'd> ProfileView<'a, 'd> { } } +enum ProfileType { + MyProfile, + ReadOnly, + Followable(IsFollowing), +} + fn handle_link(ui: &mut egui::Ui, website_url: &str) { let img = if ui.visuals().dark_mode { app_images::link_image() diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -154,6 +154,9 @@ fn timeline_ui( error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... + // + // NOTE (jb55): it can easily happen if you add a timeline column without calling + // add_new_timeline_column, since that sets up the initial subs, etc return None; }; diff --git a/crates/notedeck_columns/src/ui/widgets.rs b/crates/notedeck_columns/src/ui/widgets.rs @@ -1,49 +1,7 @@ -use egui::{Button, Widget}; -use notedeck::NotedeckTextStyle; +use egui::Widget; +use notedeck_ui::widgets::styled_button_toggleable; /// Sized and styled to match the figma design pub fn styled_button(text: &str, fill_color: egui::Color32) -> impl Widget + '_ { styled_button_toggleable(text, fill_color, true) } - -pub fn styled_button_toggleable( - text: &str, - fill_color: egui::Color32, - enabled: bool, -) -> impl Widget + '_ { - move |ui: &mut egui::Ui| -> egui::Response { - let painter = ui.painter(); - let text_color = if ui.visuals().dark_mode { - egui::Color32::WHITE - } else { - egui::Color32::BLACK - }; - - let galley = painter.layout( - text.to_owned(), - NotedeckTextStyle::Body.get_font_id(ui.ctx()), - text_color, - ui.available_width(), - ); - - let size = galley.rect.expand2(egui::vec2(16.0, 8.0)).size(); - let mut button = Button::new(galley).corner_radius(8.0); - - if !enabled { - button = button - .sense(egui::Sense::focusable_noninteractive()) - .fill(ui.visuals().noninteractive().bg_fill) - .stroke(ui.visuals().noninteractive().bg_stroke); - } else { - button = button.fill(fill_color); - } - - let mut resp = ui.add_sized(size, button); - - if !enabled { - resp = resp.on_hover_cursor(egui::CursorIcon::NotAllowed); - } - - resp - } -} diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -8,9 +8,9 @@ pub use picture::ProfilePic; pub use preview::ProfilePreview; use egui::{load::TexturePoll, Label, RichText}; -use notedeck::{NostrName, NotedeckTextStyle}; +use notedeck::{IsFollowing, NostrName, NotedeckTextStyle}; -use crate::app_images; +use crate::{app_images, colors, widgets::styled_button_toggleable}; pub fn display_name_widget<'a>( name: &'a NostrName<'a>, @@ -115,3 +115,16 @@ pub fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui: .unwrap_or_else(|| ui.label("")) }) } + +pub fn follow_button(following: IsFollowing) -> impl egui::Widget + 'static { + move |ui: &mut egui::Ui| -> egui::Response { + let (bg_color, text) = match following { + IsFollowing::Unknown => (ui.visuals().noninteractive().bg_fill, "Unknown"), + IsFollowing::Yes => (ui.visuals().widgets.inactive.bg_fill, "Unfollow"), + IsFollowing::No => (colors::PINK, "Follow"), + }; + + let enabled = following != IsFollowing::Unknown; + ui.add(styled_button_toggleable(text, bg_color, enabled)) + } +} diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs @@ -1,5 +1,6 @@ use crate::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; use egui::{emath::GuiRounding, Pos2, Stroke}; +use notedeck::NotedeckTextStyle; pub fn x_button(rect: egui::Rect) -> impl egui::Widget { move |ui: &mut egui::Ui| -> egui::Response { @@ -33,3 +34,46 @@ pub fn x_button(rect: egui::Rect) -> impl egui::Widget { helper.take_animation_response() } } + +/// Button styled in the Notedeck theme +pub fn styled_button_toggleable( + text: &str, + fill_color: egui::Color32, + enabled: bool, +) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let painter = ui.painter(); + let text_color = if ui.visuals().dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + let galley = painter.layout( + text.to_owned(), + NotedeckTextStyle::Button.get_font_id(ui.ctx()), + text_color, + ui.available_width(), + ); + + let size = galley.rect.expand2(egui::vec2(16.0, 8.0)).size(); + let mut button = egui::Button::new(galley).corner_radius(8.0); + + if !enabled { + button = button + .sense(egui::Sense::focusable_noninteractive()) + .fill(ui.visuals().noninteractive().bg_fill) + .stroke(ui.visuals().noninteractive().bg_stroke); + } else { + button = button.fill(fill_color); + } + + let mut resp = ui.add_sized(size, button); + + if !enabled { + resp = resp.on_hover_cursor(egui::CursorIcon::NotAllowed); + } + + resp + } +}