diff --git a/crates/enostr/src/relay/pool.rs b/crates/enostr/src/relay/pool.rs index ecf43ca..1150e15 100644 --- a/crates/enostr/src/relay/pool.rs +++ b/crates/enostr/src/relay/pool.rs @@ -219,6 +219,26 @@ impl RelayPool { } } + /// Subscribe to a specific relay by URL + pub fn subscribe_to(&mut self, relay_url: &str, subid: String, filter: Vec) { + for relay in &mut self.relays { + if relay.url() == relay_url { + if let Some(debug) = &mut self.debug { + debug.send_cmd( + relay.url().to_owned(), + &ClientMessage::req(subid.clone(), filter.clone()), + ); + } + + if let Err(err) = relay.send(&ClientMessage::req(subid, filter)) { + error!("error subscribing to {}: {err}", relay.url()); + } + return; + } + } + error!("relay {} not found in pool", relay_url); + } + /// Keep relay connectiongs alive by pinging relays that haven't been /// pinged in awhile. Adjust ping rate with [`ping_rate`]. pub fn keepalive_ping(&mut self, wakeup: impl Fn() + Send + Sync + Clone + 'static) { diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index a128053..dac9122 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -763,6 +763,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { TimelineKind::Universe => true, TimelineKind::Generic(_) => true, TimelineKind::Hashtag(_) => true, + TimelineKind::Relay(_, _) => true, // no! TimelineKind::Search(_) => false, diff --git a/crates/notedeck_columns/src/multi_subscriber.rs b/crates/notedeck_columns/src/multi_subscriber.rs index fc63399..567052c 100644 --- a/crates/notedeck_columns/src/multi_subscriber.rs +++ b/crates/notedeck_columns/src/multi_subscriber.rs @@ -384,11 +384,24 @@ impl TimelineSub { } pub fn try_add_remote(&mut self, pool: &mut RelayPool, filter: &HybridFilter) { + self.try_add_remote_with_relay(pool, filter, None); + } + + pub fn try_add_remote_with_relay( + &mut self, + pool: &mut RelayPool, + filter: &HybridFilter, + relay_url: Option<&str>, + ) { let before = self.state.clone(); match &mut self.state { SubState::NoSub { dependers } => { let subid = subscriptions::new_sub_id(); - pool.subscribe(subid.clone(), filter.remote().to_vec()); + if let Some(relay) = relay_url { + pool.subscribe_to(relay, subid.clone(), filter.remote().to_vec()); + } else { + pool.subscribe(subid.clone(), filter.remote().to_vec()); + } self.filter = Some(filter.to_owned()); self.state = SubState::RemoteOnly { remote: subid, @@ -397,7 +410,11 @@ impl TimelineSub { } SubState::LocalOnly { local, dependers } => { let subid = subscriptions::new_sub_id(); - pool.subscribe(subid.clone(), filter.remote().to_vec()); + if let Some(relay) = relay_url { + pool.subscribe_to(relay, subid.clone(), filter.remote().to_vec()); + } else { + pool.subscribe(subid.clone(), filter.remote().to_vec()); + } self.filter = Some(filter.to_owned()); self.state = SubState::Unified { unified: UnifiedSubscription { diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs index 7ae1e34..731528e 100644 --- a/crates/notedeck_columns/src/route.rs +++ b/crates/notedeck_columns/src/route.rs @@ -336,6 +336,11 @@ impl Route { "Add Hashtag Column", "Column title for adding hashtag column" )), + AddColumnRoute::Relay => ColumnTitle::formatted(tr!( + i18n, + "Add Relay Column", + "Column title for adding relay column" + )), AddColumnRoute::UndecidedIndividual => ColumnTitle::formatted(tr!( i18n, "Subscribe to someone's notes", diff --git a/crates/notedeck_columns/src/timeline/cache.rs b/crates/notedeck_columns/src/timeline/cache.rs index 36c5816..cf20e06 100644 --- a/crates/notedeck_columns/src/timeline/cache.rs +++ b/crates/notedeck_columns/src/timeline/cache.rs @@ -220,7 +220,16 @@ impl TimelineCache { if let Some(filter) = timeline.filter.get_any_ready() { debug!("got open with *new* subscription for {:?}", &timeline.kind); timeline.subscription.try_add_local(ndb, filter); - timeline.subscription.try_add_remote(pool, filter); + + // Check if this is a relay-specific timeline + match &timeline.kind { + TimelineKind::Relay(relay_url, _) => { + timeline.subscription.try_add_remote_with_relay(pool, filter, Some(relay_url)); + } + _ => { + timeline.subscription.try_add_remote(pool, filter); + } + } } else { // This should never happen reasoning, self.notes would have // failed above if the filter wasn't ready diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs index 4c95f1c..2a4496f 100644 --- a/crates/notedeck_columns/src/timeline/kind.rs +++ b/crates/notedeck_columns/src/timeline/kind.rs @@ -215,6 +215,10 @@ pub enum TimelineKind { Generic(u64), Hashtag(Vec), + + /// Relay-specific timeline with optional hashtag filtering + /// Format: Relay(relay_url, optional_hashtags) + Relay(String, Option>), } const NOTIFS_TOKEN_DEPRECATED: &str = "notifs"; @@ -315,6 +319,7 @@ impl TimelineKind { TimelineKind::Universe => None, TimelineKind::Generic(_) => None, TimelineKind::Hashtag(_ht) => None, + TimelineKind::Relay(_, _) => None, TimelineKind::Search(query) => query.author(), } } @@ -330,6 +335,7 @@ impl TimelineKind { TimelineKind::Universe => true, TimelineKind::Generic(_) => true, TimelineKind::Hashtag(_ht) => true, + TimelineKind::Relay(_, _) => true, TimelineKind::Search(_q) => true, } } @@ -363,6 +369,15 @@ impl TimelineKind { writer.write_token("hashtag"); writer.write_token(&ht.join(" ")); } + TimelineKind::Relay(relay_url, hashtags) => { + writer.write_token("relay"); + writer.write_token(relay_url); + if let Some(ht) = hashtags { + writer.write_token(&ht.join(" ")); + } else { + writer.write_token(""); + } + } } } @@ -428,6 +443,23 @@ impl TimelineKind { let search_query = SearchQuery::parse_from_tokens(p)?; Ok(TimelineKind::Search(search_query)) }, + |p| { + p.parse_token("relay")?; + let relay_url = p.pull_token()?.to_string(); + let hashtags_str = p.pull_token()?; + let hashtags = if hashtags_str.is_empty() { + None + } else { + Some( + hashtags_str + .split_whitespace() + .filter(|s| !s.is_empty()) + .map(|s| s.to_lowercase().to_string()) + .collect(), + ) + }; + Ok(TimelineKind::Relay(relay_url, hashtags)) + }, ], ) } @@ -492,6 +524,31 @@ impl TimelineKind { FilterState::ready(filters) } + TimelineKind::Relay(_relay_url, hashtags) => { + let filters = if let Some(hashtags) = hashtags { + // Filter by hashtags if provided + hashtags + .iter() + .filter(|tag| !tag.is_empty()) + .map(|tag| { + Filter::new() + .kinds([1]) + .limit(filter::default_limit()) + .tags([tag.to_lowercase().as_str()], 't') + .build() + }) + .collect::>() + } else { + // Otherwise show all notes from the relay + vec![Filter::new() + .kinds([1]) + .limit(filter::default_limit()) + .build()] + }; + + FilterState::ready(filters) + } + TimelineKind::Algo(algo_timeline) => match algo_timeline { AlgoTimeline::LastPerPubkey(list_k) => match list_k { ListKind::Contact(pubkey) => last_per_pubkey_filter_state(ndb, pubkey), @@ -583,6 +640,33 @@ impl TimelineKind { TimelineKind::Hashtag(hashtag) => Some(Timeline::hashtag(hashtag)), + TimelineKind::Relay(relay_url, hashtags) => { + let filters = if let Some(ref hashtags) = hashtags { + hashtags + .iter() + .filter(|tag| !tag.is_empty()) + .map(|tag| { + Filter::new() + .kinds([1]) + .limit(filter::default_limit()) + .tags([tag.as_str()], 't') + .build() + }) + .collect::>() + } else { + vec![Filter::new() + .kinds([1]) + .limit(filter::default_limit()) + .build()] + }; + + Some(Timeline::new( + TimelineKind::Relay(relay_url, hashtags), + FilterState::ready(filters), + TimelineTab::only_notes_and_replies(), + )) + } + TimelineKind::List(ListKind::Contact(pk)) => Some(Timeline::new( TimelineKind::contact_list(pk), contact_filter_state(txn, ndb, &pk), @@ -619,6 +703,13 @@ impl TimelineKind { ColumnTitle::formatted(tr!(i18n, "Custom", "Column title for custom timelines")) } TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.join(" ").to_string()), + TimelineKind::Relay(relay_url, hashtags) => { + if let Some(hashtags) = hashtags { + ColumnTitle::formatted(format!("{} ({})", relay_url, hashtags.join(" "))) + } else { + ColumnTitle::formatted(relay_url.to_string()) + } + } } } } diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs index 386259f..7dd3eee 100644 --- a/crates/notedeck_columns/src/timeline/route.rs +++ b/crates/notedeck_columns/src/timeline/route.rs @@ -28,6 +28,7 @@ pub fn render_timeline_route( | TimelineKind::Notifications(_) | TimelineKind::Universe | TimelineKind::Hashtag(_) + | TimelineKind::Relay(_, _) | TimelineKind::Generic(_) => { let resp = ui::TimelineView::new(kind, timeline_cache, note_context, note_options, jobs, col) diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs index 28658cc..8f33bd7 100644 --- a/crates/notedeck_columns/src/ui/add_column.rs +++ b/crates/notedeck_columns/src/ui/add_column.rs @@ -5,7 +5,7 @@ use egui::{ pos2, vec2, Align, Color32, FontId, Id, Image, Margin, Pos2, Rect, RichText, ScrollArea, Separator, Ui, Vec2, Widget, }; -use enostr::Pubkey; +use enostr::{Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use tracing::error; @@ -29,6 +29,7 @@ pub enum AddColumnResponse { UndecidedNotification, ExternalNotification, Hashtag, + Relay, Algo(AlgoOption), UndecidedIndividual, ExternalIndividual, @@ -59,6 +60,7 @@ enum AddColumnOption { Notification(PubkeySource), Contacts(PubkeySource), UndecidedHashtag, + UndecidedRelay, UndecidedIndividual, ExternalIndividual, Individual(PubkeySource), @@ -77,6 +79,7 @@ pub enum AddColumnRoute { UndecidedNotification, ExternalNotification, Hashtag, + Relay, Algo(AddAlgoRoute), UndecidedIndividual, ExternalIndividual, @@ -105,6 +108,7 @@ impl AddColumnRoute { Self::UndecidedIndividual => &["column", "individual_selection"], Self::ExternalIndividual => &["column", "external_individual_selection"], Self::Hashtag => &["column", "hashtag"], + Self::Relay => &["column", "relay"], Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"], Self::Algo(AddAlgoRoute::LastPerPubkey) => { &["column", "algo_selection", "last_per_pubkey"] @@ -132,6 +136,7 @@ impl TokenSerializable for AddColumnRoute { |p| parse_column_route(p, AddColumnRoute::UndecidedIndividual), |p| parse_column_route(p, AddColumnRoute::ExternalIndividual), |p| parse_column_route(p, AddColumnRoute::Hashtag), + |p| parse_column_route(p, AddColumnRoute::Relay), |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)), |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)), ], @@ -153,6 +158,7 @@ impl AddColumnOption { ), AddColumnOption::ExternalNotification => AddColumnResponse::ExternalNotification, AddColumnOption::UndecidedHashtag => AddColumnResponse::Hashtag, + AddColumnOption::UndecidedRelay => AddColumnResponse::Relay, AddColumnOption::UndecidedIndividual => AddColumnResponse::UndecidedIndividual, AddColumnOption::ExternalIndividual => AddColumnResponse::ExternalIndividual, AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline( @@ -515,6 +521,16 @@ impl<'a> AddColumnView<'a> { icon: app_images::hashtag_image(), option: AddColumnOption::UndecidedHashtag, }); + vec.push(ColumnOptionData { + title: tr!(self.i18n, "Relay", "Title for relay column"), + description: tr!( + self.i18n, + "View content from a specific relay", + "Description for relay column" + ), + icon: app_images::add_relay_image(), + option: AddColumnOption::UndecidedRelay, + }); vec.push(ColumnOptionData { title: tr!(self.i18n, "Individual", "Title for individual user column"), description: tr!( @@ -684,6 +700,7 @@ pub fn render_add_column_routes( AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), + AddColumnRoute::Relay => relay_ui(ui, ctx.i18n, &mut app.view_state.id_string_map, ctx.pool), AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), }; @@ -790,6 +807,12 @@ pub fn render_add_column_routes( .router_mut() .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); } + AddColumnResponse::Relay => { + app.columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut() + .route_to(crate::route::Route::AddColumn(AddColumnRoute::Relay)); + } AddColumnResponse::UndecidedIndividual => { app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) @@ -869,6 +892,102 @@ fn sanitize_hashtag(raw_hashtag: &str) -> String { .collect() } +pub fn relay_ui( + ui: &mut Ui, + i18n: &mut Localization, + id_string_map: &mut HashMap, + pool: &RelayPool, +) -> Option { + padding(16.0, ui, |ui| { + let relay_id = ui.id().with("relay_url"); + let hashtag_id = ui.id().with("relay_hashtags"); + + // Relay URL input + ui.label(tr!(i18n, "Relay URL", "Label for relay URL input")); + let relay_buffer = id_string_map.entry(relay_id).or_default(); + let relay_edit = egui::TextEdit::singleline(relay_buffer) + .hint_text( + RichText::new(tr!( + i18n, + "wss://relay.example.com", + "Placeholder for relay URL input field" + )) + .text_style(NotedeckTextStyle::Body.text_style()), + ) + .vertical_align(Align::Center) + .desired_width(f32::INFINITY) + .min_size(Vec2::new(0.0, 40.0)) + .margin(Margin::same(12)); + ui.add(relay_edit); + + ui.add_space(8.0); + + // Hashtag input (optional) + ui.label(tr!(i18n, "Hashtags (optional)", "Label for hashtag filter input")); + let hashtag_buffer = id_string_map.entry(hashtag_id).or_default(); + let hashtag_edit = egui::TextEdit::singleline(hashtag_buffer) + .hint_text( + RichText::new(tr!( + i18n, + "Enter hashtags to filter (space-separated)", + "Placeholder for hashtag filter input field" + )) + .text_style(NotedeckTextStyle::Body.text_style()), + ) + .vertical_align(Align::Center) + .desired_width(f32::INFINITY) + .min_size(Vec2::new(0.0, 40.0)) + .margin(Margin::same(12)); + ui.add(hashtag_edit); + + ui.add_space(8.0); + + let mut handle_user_input = false; + if ui.input(|i| i.key_released(egui::Key::Enter)) + || ui + .add_sized(egui::vec2(50.0, 40.0), add_column_button(i18n)) + .clicked() + { + handle_user_input = true; + } + + // Clone values before validation to avoid borrow issues + let relay_value = id_string_map.get(&relay_id).map(|s| s.clone()).unwrap_or_default(); + let hashtag_value = id_string_map.get(&hashtag_id).map(|s| s.clone()).unwrap_or_default(); + + if handle_user_input && !relay_value.is_empty() { + // Validate relay URL + if !pool.is_valid_url(&relay_value) { + // Show error message - for now just don't process + return None; + } + + let hashtags = if hashtag_value.is_empty() { + None + } else { + Some( + hashtag_value + .split_whitespace() + .filter(|s| !s.is_empty()) + .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) + .collect::>(), + ) + }; + + let resp = AddColumnResponse::Timeline(TimelineKind::Relay( + relay_value, + hashtags, + )); + id_string_map.remove(&relay_id); + id_string_map.remove(&hashtag_id); + Some(resp) + } else { + None + } + }) + .inner +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs index 7ebed2f..05351d9 100644 --- a/crates/notedeck_columns/src/ui/column/header.rs +++ b/crates/notedeck_columns/src/ui/column/header.rs @@ -460,6 +460,10 @@ impl<'a> NavTitle<'a> { app_images::hashtag_image().fit_to_exact_size(egui::vec2(pfp_size, pfp_size)), )), + TimelineKind::Relay(_, _) => Some(ui.add( + app_images::add_relay_image().fit_to_exact_size(egui::vec2(pfp_size, pfp_size)), + )), + TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), TimelineKind::Search(_sq) => {