Add relay-specific column with hashtag filtering

This commit adds the ability to create columns that display content from
a specific relay, with optional hashtag filtering.

Features:
- New TimelineKind::Relay variant storing relay URL and optional hashtags
- Relay-specific subscription support in RelayPool (subscribe_to method)
- UI for configuring relay URL and hashtag filters
- Filter generation for relay-specific queries
- Column header with relay icon
- Serialization/deserialization support for deck persistence

Implementation details:
- Extended RelayPool with subscribe_to() for relay-specific subscriptions
- Added TimelineSub::try_add_remote_with_relay() to handle targeted subscriptions
- Timeline cache automatically routes relay columns to specific relays
- UI validates relay URLs before creating columns
- Hashtag filtering is optional and space-separated

Usage:
Users can now add a "Relay" column from the column picker, enter a relay
URL (e.g., wss://relay.example.com), and optionally filter by hashtags.
This commit is contained in:
Claude
2025-11-12 13:11:36 +00:00
parent eac5d41e3c
commit 79a633d684
9 changed files with 271 additions and 4 deletions

View File

@@ -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<Filter>) {
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) {

View File

@@ -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,

View File

@@ -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();
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();
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 {

View File

@@ -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",

View File

@@ -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);
// 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

View File

@@ -215,6 +215,10 @@ pub enum TimelineKind {
Generic(u64),
Hashtag(Vec<String>),
/// Relay-specific timeline with optional hashtag filtering
/// Format: Relay(relay_url, optional_hashtags)
Relay(String, Option<Vec<String>>),
}
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::<Vec<_>>()
} 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::<Vec<_>>()
} 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())
}
}
}
}
}

View File

@@ -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)

View File

@@ -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<Id, String>,
pool: &RelayPool,
) -> Option<AddColumnResponse> {
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::<Vec<_>>(),
)
};
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::*;

View File

@@ -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) => {