mirror of
https://github.com/aljazceru/notedeck.git
synced 2025-12-17 00:44:18 +01:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user