Skip to content

Conversation

@aljazceru
Copy link

@aljazceru aljazceru commented Nov 12, 2025

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)

claude and others added 7 commits November 12, 2025 13:11
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.
@alltheseas
Copy link
Contributor

Yuge. Will test if I can run locally 👀

@alltheseas
Copy link
Contributor

@aljazceru what OS did you test on?

@aljazceru
Copy link
Author

@aljazceru what OS did you test on?

ubuntu 24.04

@alltheseas
Copy link
Contributor

This doesn't allow for adding relays that are not on my relay list. Otherwise the add relay column UI looks nice.
image

Let me try to add/fix.

@aljazceru
Copy link
Author

it allows you to add any relay, its just a text box
image

or do you mean the other way around, you'd like to have a dropdown of your relays there?

@aljazceru aljazceru changed the title Feature/relay based column [draft] Feature/relay based column Nov 12, 2025
@alltheseas
Copy link
Contributor

alltheseas commented Nov 12, 2025

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 🤔

Copy link

Copilot AI left a 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_ui function to handle user input for relay URL and hashtag filters
  • Implemented relay-specific subscription handling via subscribe_to method in RelayPool and new try_add_remote_with_relay method

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) {
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +240
error!("relay {} not found in pool", relay_url);
}
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +534 to +555
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)
Copy link

Copilot AI Nov 12, 2025

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.

Suggested change
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))

Copilot uses AI. Check for mistakes.
Filter::new()
.kinds([1])
.limit(filter::default_limit())
.tags([tag.to_lowercase().as_str()], 't')
Copy link

Copilot AI Nov 12, 2025

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

Suggested change
.tags([tag.to_lowercase().as_str()], 't')
.tags([tag.as_str()], 't')

Copilot uses AI. Check for mistakes.
error!("relay {} not found in pool", relay_url);
}

/// Keep relay connectiongs alive by pinging relays that haven't been
Copy link

Copilot AI Nov 12, 2025

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".

Suggested change
/// Keep relay connectiongs alive by pinging relays that haven't been
/// Keep relay connections alive by pinging relays that haven't been

Copilot uses AI. Check for mistakes.
Comment on lines +960 to +963
if !pool.is_valid_url(&relay_value) {
// Show error message - for now just don't process
return None;
}
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +968 to +974
Some(
hashtag_value
.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| sanitize_hashtag(s).to_lowercase().to_string())
.collect::<Vec<_>>(),
)
Copy link

Copilot AI Nov 12, 2025

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.

Suggested change
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) }

Copilot uses AI. Check for mistakes.
Comment on lines +712 to +718
TimelineKind::Relay(relay_url, hashtags) => {
if let Some(hashtags) = hashtags {
ColumnTitle::formatted(format!("{} ({})", relay_url, hashtags.join(" ")))
} else {
ColumnTitle::formatted(relay_url.to_string())
}
}
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +977 to +979
let resp = AddColumnResponse::Timeline(TimelineKind::Relay(
relay_value,
hashtags,
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +955 to +978
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,
Copy link

Copilot AI Nov 12, 2025

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants