-
Notifications
You must be signed in to change notification settings - Fork 49
[draft] Feature/relay based column #1206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
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.
…-011CV42PmoSSTR5ZeRCGFcYJ Add relay-specific column with hashtag filtering
This fix addresses the "got unknown eose subid" warnings that were appearing in the logs when EOSE (End Of Stored Events) messages arrived from relays. The issue was that when `TimelineSub::try_add_remote()` and `TimelineSub::try_add_remote_with_relay()` created new subscription IDs, they were not being tracked in the `Subscriptions.subs` HashMap. When EOSE messages arrived for these subscription IDs, the `handle_eose()` function couldn't find them in the HashMap, causing the "unknown eose subid" warnings. Changes: - Modified `try_add_remote()` and `try_add_remote_with_relay()` to accept `&mut Subscriptions` and `&TimelineKind` parameters - Added subscription tracking by inserting subscription IDs with `SubKind::Timeline(timeline_kind)` into the Subscriptions HashMap - Updated all call sites throughout the codebase to pass the required parameters, including: - TimelineCache::open() - DecksCache::add_deck_default() - DecksCache::new_with_demo_config() - is_timeline_ready() - execute_note_action() - execute_and_process_note_action() - add_demo_columns() - demo_decks() This ensures all subscription IDs are properly tracked, eliminating the unknown EOSE warnings and allowing proper handling of EOSE messages.
The relay URL "wss://wot.nostr.net" was being incorrectly parsed as
Relay("wss", Some(["//wot.nostr.net"])) when the app restarted and
loaded from the decks cache.
Root cause:
The TokenWriter uses ":" as the default delimiter. When relay URLs
containing ":" (like "wss://wot.nostr.net") were serialized, the
colons were not escaped, causing the URL to be split into multiple
tokens during parsing:
- Token 1: "relay" (type)
- Token 2: "wss" (mistakenly read as relay_url)
- Token 3: "//wot.nostr.net" (mistakenly read as hashtags)
Solution:
- URL-encode relay URLs when serializing (using urlencoding crate)
- URL-decode relay URLs when parsing
- Added backward compatibility: if decoding fails (old format),
fall back to using the token as-is
This ensures relay URLs with colons are correctly preserved across
app restarts.
When decks_cache.json was saved with the old format, relay URLs like "wss://wot.nostr.net" were split by the ":" delimiter into separate tokens: ["wss", "//wot.nostr.net"]. This commit adds migration logic to detect and reconstruct these corrupted URLs during parsing. Detection heuristic: - If the relay_url token is just "wss" or "ws" (without "://") - AND the next token (hashtags_str) starts with "//" - Then reconstruct the full URL as "wss://..." or "ws://..." - And pull the next token as the actual hashtags This allows existing users with corrupted decks_cache.json files to automatically have their relay URLs fixed on next app restart, without needing to manually delete the cache file.
Since users can delete the corrupted decks_cache.json file, the migration logic is no longer necessary. The URL encoding/decoding ensures all new cache files will be saved correctly going forward.
…1CV48fL6X3HX1KA4facmw5 fix unknown eose
|
Yuge. Will test if I can run locally 👀 |
|
@aljazceru what OS did you test on? |
ubuntu 24.04 |
|
hm you are right, now adding other relays works, not sure what I did earlier to miss that. I am trying to compare with https://jumble.social/ by adding the same relay in notedeck, and in jumble e.g. wss://theforest.nostr1.com There is some overlap, and there is a lot of inconsistencies between the two apps 🤔 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR adds a new relay-based column feature that allows users to create timeline columns filtered to show content from a specific relay, optionally filtered by hashtags. The implementation introduces a new TimelineKind::Relay variant and associated UI for relay column creation.
Key Changes
- Introduced
TimelineKind::Relay(String, Option<Vec<String>>)variant to support relay-specific timelines with optional hashtag filtering - Added
relay_uifunction to handle user input for relay URL and hashtag filters - Implemented relay-specific subscription handling via
subscribe_tomethod inRelayPooland newtry_add_remote_with_relaymethod
Reviewed Changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/notedeck_columns/src/timeline/kind.rs | Adds TimelineKind::Relay variant with filter logic, serialization, and title generation |
| crates/notedeck_columns/src/ui/add_column.rs | Implements relay_ui function and adds relay column option to the add column flow |
| crates/enostr/src/relay/pool.rs | Adds subscribe_to method for relay-specific subscriptions |
| crates/notedeck_columns/src/multi_subscriber.rs | Updates subscription methods to support relay-specific subscriptions |
| crates/notedeck_columns/src/timeline/cache.rs | Adds logic to handle relay-specific timeline subscriptions |
| crates/notedeck_columns/src/timeline/mod.rs | Updates is_timeline_ready signature to accept subscriptions parameter |
| crates/notedeck_columns/src/ui/column/header.rs | Adds relay icon to column header for relay timelines |
| crates/notedeck_columns/src/route.rs | Adds column title for relay column route |
| crates/notedeck_columns/src/nav.rs | Threads subscription parameter through navigation actions |
| crates/notedeck_columns/src/decks.rs | Updates deck initialization to pass subscriptions parameter |
| crates/notedeck_columns/src/column.rs | Updates add_new_timeline_column to accept subscriptions parameter |
| crates/notedeck_columns/src/app.rs | Initializes subscriptions and threads them through timeline operations |
| crates/notedeck_columns/src/actionbar.rs | Updates note action execution to accept subscriptions parameter |
| crates/notedeck_chrome/src/chrome.rs | Passes subscriptions to action execution calls |
| crates/notedeck_columns/src/timeline/route.rs | Adds relay timeline to route rendering match |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if handle_user_input && !relay_value.is_empty() { | ||
| // Validate relay URL | ||
| if !pool.is_valid_url(&relay_value) { |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The is_valid_url method checks if the relay already exists in the pool and returns false if it does (line 318). This means users cannot create a relay column for an existing relay. The validation should only check URL format validity, not whether the relay is already in the pool. Consider using Url::parse(url).is_ok() instead, or creating a separate validation method that doesn't check for relay existence.
| error!("relay {} not found in pool", relay_url); | ||
| } |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subscribe_to method logs an error when a relay is not found in the pool (line 239), but the subscription state is still updated as if the subscription succeeded. This could lead to inconsistent state where the UI thinks a subscription is active but no actual subscription was made. Consider returning a Result or handling the case where the relay doesn't exist in the pool before updating subscription state.
| 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) |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter construction logic for relay timelines with hashtags is duplicated in two places: here (lines 534-556) in the filter method and in the into_timeline method (lines 650-667). Consider extracting this logic into a helper function to avoid duplication and make maintenance easier.
| 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) | |
| FilterState::ready(relay_hashtag_filters(hashtags)) |
| Filter::new() | ||
| .kinds([1]) | ||
| .limit(filter::default_limit()) | ||
| .tags([tag.to_lowercase().as_str()], 't') |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent hashtag handling: At line 543, hashtags are converted to lowercase (tag.to_lowercase().as_str()) when building the filter, but at line 658 in the into_timeline method, they're used as-is (tag.as_str()). This inconsistency could lead to different filtering behavior. The hashtags should already be normalized to lowercase when stored (as done in the UI at line 972), so using them as-is should be sufficient in both places, similar to how Timeline::hashtag handles it (line 279).
| .tags([tag.to_lowercase().as_str()], 't') | |
| .tags([tag.as_str()], 't') |
| error!("relay {} not found in pool", relay_url); | ||
| } | ||
|
|
||
| /// Keep relay connectiongs alive by pinging relays that haven't been |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in comment: "connectiongs" should be "connections".
| /// Keep relay connectiongs alive by pinging relays that haven't been | |
| /// Keep relay connections alive by pinging relays that haven't been |
| if !pool.is_valid_url(&relay_value) { | ||
| // Show error message - for now just don't process | ||
| return None; | ||
| } |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing user feedback: When relay URL validation fails, the function silently returns None without providing any feedback to the user about why their input was rejected. Consider displaying an error message explaining that the relay URL is invalid or already exists in the pool.
| Some( | ||
| hashtag_value | ||
| .split_whitespace() | ||
| .filter(|s| !s.is_empty()) | ||
| .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) | ||
| .collect::<Vec<_>>(), | ||
| ) |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential empty hashtag vector: If the user enters only whitespace or special characters in the hashtag field, after sanitization the resulting vector could be empty. Consider checking if the hashtag vector is empty after filtering and treating it as None rather than Some(vec![]) to maintain consistency with the case where no hashtags are entered.
| Some( | |
| hashtag_value | |
| .split_whitespace() | |
| .filter(|s| !s.is_empty()) | |
| .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) | |
| .collect::<Vec<_>>(), | |
| ) | |
| let tags: Vec<_> = hashtag_value | |
| .split_whitespace() | |
| .filter(|s| !s.is_empty()) | |
| .map(|s| sanitize_hashtag(s).to_lowercase().to_string()) | |
| .filter(|s| !s.is_empty()) | |
| .collect(); | |
| if tags.is_empty() { None } else { Some(tags) } |
| TimelineKind::Relay(relay_url, hashtags) => { | ||
| if let Some(hashtags) = hashtags { | ||
| ColumnTitle::formatted(format!("{} ({})", relay_url, hashtags.join(" "))) | ||
| } else { | ||
| ColumnTitle::formatted(relay_url.to_string()) | ||
| } | ||
| } |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Long relay URLs in column titles: The relay URL is displayed directly in the column title without any truncation or shortening. Long relay URLs (e.g., wss://relay.very-long-domain-name.example.com) could make the column title unwieldy and affect UI layout. Consider displaying a shortened version of the URL (e.g., just the domain name without the protocol and path) or truncating it with ellipsis.
| let resp = AddColumnResponse::Timeline(TimelineKind::Relay( | ||
| relay_value, | ||
| hashtags, |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential URL matching issue: The relay URL stored in TimelineKind::Relay is not canonicalized before being passed to subscribe_to. In the subscribe_to method, the URL is compared directly with relay.url() (pool.rs:225), which likely returns a canonicalized URL (one with standardized format, trailing slashes, etc.). This could cause the relay lookup to fail even when the relay exists in the pool. Consider canonicalizing the relay URL before storing it in the TimelineKind::Relay variant, using the same canonicalize_url logic used in add_url.
| 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, |
Copilot
AI
Nov 12, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing input sanitization: The relay URL input is not trimmed before validation and storage. Users may accidentally include leading or trailing whitespace, which would cause URL parsing to fail. Consider calling .trim() on relay_value before validation at line 960 and before storing it at line 977.


Adds a new option to column creation - specifying a relay and optional hashtag so that the content in the column is just from that relay(and hashtag) combination
its mostly vibed, i'm not a rust dev but i've tested it (i've created it because I use it)