diff --git a/assets/icons/search.svg b/assets/icons/search.svg
new file mode 100644
index 0000000..49527da
--- /dev/null
+++ b/assets/icons/search.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/nostr.rs b/src/nostr.rs
index 638c963..050c238 100644
--- a/src/nostr.rs
+++ b/src/nostr.rs
@@ -1,10 +1,13 @@
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Debug;
+use std::str::FromStr;
use std::time::Duration;
+use fedimint_core::config::FederationId;
+use fedimint_core::invite_code::InviteCode;
use iced::Subscription;
use nostr_relay_pool::RelayStatus;
-use nostr_sdk::Url;
+use nostr_sdk::{Alphabet, EventSource, Filter, Kind, PublicKey, SingleLetterTag, TagKind, Url};
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct NostrState {
@@ -69,6 +72,53 @@ impl NostrModule {
)
}
+ pub async fn find_federations(
+ &self,
+ ) -> Result<
+ BTreeMap, BTreeSet)>,
+ nostr_sdk::client::Error,
+ > {
+ let fedimint_recommendation_events = self
+ .client
+ .get_events_of(
+ vec![Filter::new()
+ .kind(Kind::Custom(38_000))
+ .custom_tag(SingleLetterTag::lowercase(Alphabet::K), vec!["38173"])
+ .custom_tag(SingleLetterTag::lowercase(Alphabet::N), vec!["mainnet"])],
+ EventSource::both(None),
+ )
+ .await?;
+
+ let mut federations = BTreeMap::new();
+
+ for recommendation_event in &fedimint_recommendation_events {
+ for d_tag in recommendation_event.get_tags_content(TagKind::SingleLetter(
+ SingleLetterTag::lowercase(Alphabet::D),
+ )) {
+ if let Ok(federation_id) = FederationId::from_str(d_tag) {
+ let (recommenders, invite_codes) = federations
+ .entry(federation_id)
+ .or_insert_with(|| (BTreeSet::new(), BTreeSet::new()));
+
+ recommenders.insert(recommendation_event.pubkey);
+ for u_tag in recommendation_event.get_tags_content(TagKind::SingleLetter(
+ SingleLetterTag::lowercase(Alphabet::U),
+ )) {
+ if let Ok(invite_code) = InviteCode::from_str(u_tag) {
+ if invite_code.federation_id() == federation_id {
+ invite_codes.insert(invite_code);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ federations.retain(|_, (_, invite_codes)| !invite_codes.is_empty());
+
+ Ok(federations)
+ }
+
/// Fetches the current state of the Nostr SDK client.
/// Note: This is async because it's grabbing read locks
/// on the relay `RwLock`s. No network requests are made.
diff --git a/src/routes/bitcoin_wallet/add.rs b/src/routes/bitcoin_wallet/add.rs
index 13a3ba8..cf1f8be 100644
--- a/src/routes/bitcoin_wallet/add.rs
+++ b/src/routes/bitcoin_wallet/add.rs
@@ -1,18 +1,23 @@
-use std::str::FromStr;
-use std::sync::Arc;
+use std::collections::BTreeSet;
+use std::{collections::BTreeMap, str::FromStr};
+use fedimint_core::config::FederationId;
use fedimint_core::{
config::{ClientConfig, META_FEDERATION_NAME_KEY},
invite_code::InviteCode,
};
+use iced::widget::{container::Style, Container};
use iced::{
+ futures::{stream::FuturesUnordered, StreamExt},
widget::{text_input, Column, Text},
- Task,
+ Border, Element, Shadow, Task,
};
+use iced::{Length, Theme};
+use nostr_sdk::PublicKey;
+use crate::util::lighten;
use crate::{
app,
- fedimint::Wallet,
routes::{self, container, ConnectedState, Loadable},
ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus},
util::truncate_text,
@@ -35,12 +40,19 @@ pub enum Message {
JoinFederation(InviteCode),
JoinedFederation(InviteCode),
+
+ LoadNip87Federations,
+ LoadedNip87Federations(BTreeMap, BTreeSet)>),
}
pub struct Page {
- wallet: Arc,
+ connected_state: ConnectedState,
invite_code_input: String,
parsed_invite_code_state_or: Option,
+ // TODO: Simplify this type and remove the clippy warning.
+ #[allow(clippy::type_complexity)]
+ nip_87_data_or:
+ Option, Vec)>>>,
}
struct ParsedInviteCodeState {
@@ -51,9 +63,10 @@ struct ParsedInviteCodeState {
impl Page {
pub fn new(connected_state: &ConnectedState) -> Self {
Self {
- wallet: connected_state.wallet.clone(),
+ connected_state: connected_state.clone(),
invite_code_input: String::new(),
parsed_invite_code_state_or: None,
+ nip_87_data_or: None,
}
}
@@ -102,10 +115,15 @@ impl Page {
{
// If the invite code has changed since the request was made, ignore the response.
if &invite_code == parsed_invite_code {
- *loadable_federation_config = Loadable::Loaded(config);
+ *loadable_federation_config = Loadable::Loaded(config.clone());
}
}
+ self.handle_client_config_outcome_for_invite_code(
+ &invite_code,
+ &Loadable::Loaded(config),
+ );
+
Task::none()
}
Message::FailedToLoadFederationConfigFromInviteCode { invite_code } => {
@@ -123,11 +141,13 @@ impl Page {
}
}
+ self.handle_client_config_outcome_for_invite_code(&invite_code, &Loadable::Failed);
+
// TODO: Show toast instead of returning an empty task.
Task::none()
}
Message::JoinFederation(invite_code) => {
- let wallet = self.wallet.clone();
+ let wallet = self.connected_state.wallet.clone();
Task::stream(async_stream::stream! {
match wallet.join_federation(invite_code.clone()).await {
@@ -164,9 +184,92 @@ impl Page {
Task::none()
}
+ Message::LoadNip87Federations => {
+ self.nip_87_data_or = Some(Loadable::Loading);
+
+ let nostr_module = self.connected_state.nostr_module.clone();
+
+ Task::future(async move {
+ match nostr_module.find_federations().await {
+ Ok(federations) => {
+ app::Message::Routes(routes::Message::BitcoinWalletPage(
+ super::Message::Add(Message::LoadedNip87Federations(federations)),
+ ))
+ }
+ Err(_err) => app::Message::AddToast(Toast {
+ title: "Failed to discover federations".to_string(),
+ body: "Nostr NIP-87 federation discovery failed.".to_string(),
+ status: ToastStatus::Bad,
+ }),
+ }
+ })
+ }
+ Message::LoadedNip87Federations(nip_87_data) => {
+ // Only set the state to loaded if the user requested the data.
+ // This prevents the data from being displayed if the user
+ // navigates away from the page and back before the data is loaded.
+ if matches!(self.nip_87_data_or, Some(Loadable::Loading)) {
+ self.nip_87_data_or = Some(Loadable::Loaded(
+ nip_87_data
+ .clone()
+ .into_iter()
+ .map(|(federation_id, (pubkeys, invite_codes))| {
+ (
+ federation_id,
+ (
+ pubkeys,
+ invite_codes
+ .into_iter()
+ .map(|invite_code| ParsedInviteCodeState {
+ invite_code,
+ loadable_federation_config: Loadable::Loading,
+ })
+ .collect(),
+ ),
+ )
+ })
+ .collect(),
+ ));
+ }
+
+ Task::stream(async_stream::stream! {
+ let mut futures = FuturesUnordered::new();
+
+ for (_, (_, invite_codes)) in nip_87_data {
+ if let Some(invite_code) = invite_codes.first().cloned() {
+ futures.push(async move {
+ match fedimint_api_client::download_from_invite_code(&invite_code).await {
+ Ok(config) => {
+ app::Message::Routes(routes::Message::BitcoinWalletPage(
+ super::Message::Add(Message::LoadedFederationConfigFromInviteCode {
+ invite_code: invite_code.clone(),
+ config,
+ }),
+ ))
+ }
+ // TODO: Include error in message and display it in the UI.
+ Err(_err) => {
+ app::Message::Routes(routes::Message::BitcoinWalletPage(
+ super::Message::Add(Message::FailedToLoadFederationConfigFromInviteCode {
+ invite_code: invite_code.clone(),
+ }),
+ ))
+ }
+ }
+ });
+ }
+ }
+
+ while let Some(result) = futures.next().await {
+ yield result;
+ }
+ })
+ }
}
}
+ // TODO: Remove this clippy exception.
+ #[allow(clippy::too_many_lines)]
pub fn view<'a>(&self) -> Column<'a, app::Message> {
let mut container = container("Join Federation")
.push(
@@ -239,6 +342,161 @@ impl Page {
}
}
+ let nip_87_view: Element = match &self.nip_87_data_or {
+ None => icon_button(
+ "Find Federations",
+ SvgIcon::Search,
+ PaletteColor::Background,
+ )
+ .on_press(app::Message::Routes(routes::Message::BitcoinWalletPage(
+ super::Message::Add(Message::LoadNip87Federations),
+ )))
+ .into(),
+ Some(Loadable::Loading) => Text::new("Loading...").into(),
+ Some(Loadable::Loaded(federation_data)) => {
+ let mut column = Column::new().spacing(10);
+
+ let mut federation_data_sorted_by_recommendations: Vec<_> = federation_data
+ .iter()
+ .map(|(federation_id, (pubkeys, invite_codes))| {
+ (federation_id, pubkeys, invite_codes)
+ })
+ .collect();
+
+ federation_data_sorted_by_recommendations
+ .sort_by_key(|(_, pubkeys, _)| pubkeys.len());
+ federation_data_sorted_by_recommendations.reverse();
+
+ // Filter out federations that we're already connected to.
+ if let Loadable::Loaded(wallet_view) = &self.connected_state.loadable_wallet_view {
+ let connected_federation_ids =
+ wallet_view.federations.keys().collect::>();
+
+ federation_data_sorted_by_recommendations.retain(|(federation_id, _, _)| {
+ !connected_federation_ids.contains(federation_id)
+ });
+ }
+
+ for (federation_id, pubkeys, invite_codes) in
+ federation_data_sorted_by_recommendations
+ {
+ let mut sub_column = Column::new()
+ .push(Text::new(format!("Federation ID: {federation_id}")))
+ .push(Text::new(format!("{} recommendations", pubkeys.len())));
+
+ let mut loading_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new();
+ let mut loaded_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new();
+ let mut errored_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new();
+ for invite_code in invite_codes {
+ match &invite_code.loadable_federation_config {
+ Loadable::Loading => {
+ loading_invite_codes.push(invite_code);
+ }
+ Loadable::Loaded(_) => {
+ loaded_invite_codes.push(invite_code);
+ }
+ Loadable::Failed => {
+ errored_invite_codes.push(invite_code);
+ }
+ }
+ }
+
+ let mut most_progressed_invite_code_or = None;
+ // The order of priority is errored, loading, loaded.
+ // This is important because we don't want to consider a
+ // federation as errored if one of its invite codes is loading, and
+ // we don't want to consider a federation as loading if one of its
+ // invite codes has successfully loaded.
+ if !errored_invite_codes.is_empty() {
+ most_progressed_invite_code_or = Some(errored_invite_codes[0]);
+ } else if !loading_invite_codes.is_empty() {
+ most_progressed_invite_code_or = Some(loading_invite_codes[0]);
+ } else if !loaded_invite_codes.is_empty() {
+ most_progressed_invite_code_or = Some(loaded_invite_codes[0]);
+ }
+
+ if let Some(most_progressed_invite_code) = most_progressed_invite_code_or {
+ match &most_progressed_invite_code.loadable_federation_config {
+ Loadable::Loading => {
+ sub_column = sub_column.push(Text::new("Loading client config..."));
+ }
+ Loadable::Loaded(client_config) => {
+ sub_column = sub_column
+ .push(Text::new("Federation Name").size(25))
+ .push(Text::new(
+ client_config
+ .meta::(META_FEDERATION_NAME_KEY)
+ .ok()
+ .flatten()
+ .unwrap_or_default(),
+ ))
+ .push(Text::new("Modules").size(25))
+ .push(Text::new(
+ client_config
+ .modules
+ .values()
+ .map(|module| module.kind().to_string())
+ .collect::>()
+ .join(", "),
+ ))
+ .push(Text::new("Guardians").size(25));
+ for peer_url in client_config.global.api_endpoints.values() {
+ sub_column = sub_column.push(Text::new(format!(
+ "{} ({})",
+ peer_url.name, peer_url.url
+ )));
+ }
+
+ sub_column = sub_column.push(
+ icon_button(
+ "Join Federation",
+ SvgIcon::Groups,
+ PaletteColor::Primary,
+ )
+ .on_press(
+ app::Message::Routes(routes::Message::BitcoinWalletPage(
+ super::Message::Add(Message::JoinFederation(
+ most_progressed_invite_code.invite_code.clone(),
+ )),
+ )),
+ ),
+ );
+ }
+ Loadable::Failed => {
+ sub_column =
+ sub_column.push(Text::new("Failed to load client config"));
+ }
+ }
+ }
+
+ column = column.push(
+ Container::new(sub_column)
+ .padding(10)
+ .width(Length::Fill)
+ .style(|theme: &Theme| -> Style {
+ Style {
+ text_color: None,
+ background: Some(
+ lighten(theme.palette().background, 0.05).into(),
+ ),
+ border: Border {
+ color: iced::Color::WHITE,
+ width: 0.0,
+ radius: (8.0).into(),
+ },
+ shadow: Shadow::default(),
+ }
+ }),
+ );
+ }
+
+ column.into()
+ }
+ Some(Loadable::Failed) => Text::new("Failed to load NIP-87 data").into(),
+ };
+
+ container = container.push(nip_87_view);
+
container = container.push(
icon_button("Back", SvgIcon::ArrowBack, PaletteColor::Background).on_press(
app::Message::Routes(routes::Message::Navigate(super::RouteName::BitcoinWallet(
@@ -249,4 +507,24 @@ impl Page {
container
}
+
+ /// Handle the outcome of a client config request from a given invite code.
+ fn handle_client_config_outcome_for_invite_code(
+ &mut self,
+ invite_code: &InviteCode,
+ loadable_client_config: &Loadable,
+ ) {
+ if let Some(Loadable::Loaded(nip_87_data)) = &mut self.nip_87_data_or {
+ for (_, nip_87_invite_codes) in nip_87_data.values_mut() {
+ for nip_87_invite_code in nip_87_invite_codes {
+ if &nip_87_invite_code.invite_code == invite_code
+ && nip_87_invite_code.loadable_federation_config == Loadable::Loading
+ {
+ nip_87_invite_code.loadable_federation_config =
+ loadable_client_config.clone();
+ }
+ }
+ }
+ }
+ }
}
diff --git a/src/ui_components/icon.rs b/src/ui_components/icon.rs
index 09efada..7c448a5 100644
--- a/src/ui_components/icon.rs
+++ b/src/ui_components/icon.rs
@@ -30,6 +30,7 @@ pub enum SvgIcon {
Lock,
LockOpen,
Save,
+ Search,
Send,
Settings,
ThumbDown,
@@ -68,6 +69,7 @@ impl SvgIcon {
Self::Lock => icon_handle!("lock.svg"),
Self::LockOpen => icon_handle!("lock_open.svg"),
Self::Save => icon_handle!("save.svg"),
+ Self::Search => icon_handle!("search.svg"),
Self::Send => icon_handle!("send.svg"),
Self::Settings => icon_handle!("settings.svg"),
Self::ThumbDown => icon_handle!("thumb_down.svg"),