diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index de44865..3277058 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -43,6 +43,8 @@ pub struct Damus { state: DamusState, pub decks_cache: DecksCache, + pub channels_cache: crate::channels::ChannelsCache, + pub relay_config: crate::relay_config::RelayConfig, pub view_state: ViewState, pub drafts: Drafts, pub timeline_cache: TimelineCache, @@ -521,6 +523,29 @@ impl Damus { let jobs = JobsCache::default(); let threads = Threads::default(); + // Load or create channels cache + let channels_cache = if let Some(channels_cache) = crate::storage::load_channels_cache( + app_context.path, + app_context.i18n, + ) { + info!("ChannelsCache: loading from disk"); + channels_cache + } else { + info!("ChannelsCache: creating new with default channels"); + crate::channels::ChannelsCache::default_channels_cache(app_context.i18n) + }; + + // Load or create relay config + let relay_config = if let Some(relay_config) = crate::storage::load_relay_config( + app_context.path, + ) { + info!("RelayConfig: loading from disk"); + relay_config + } else { + info!("RelayConfig: creating new with default relays"); + crate::relay_config::RelayConfig::default() + }; + Self { subscriptions, timeline_cache, @@ -532,6 +557,8 @@ impl Damus { view_state: ViewState::default(), support, decks_cache, + channels_cache, + relay_config, unrecognized_args, jobs, threads, @@ -564,6 +591,8 @@ impl Damus { pub fn mock>(data_path: P) -> Self { let mut i18n = Localization::default(); let decks_cache = DecksCache::default_decks_cache(&mut i18n); + let channels_cache = crate::channels::ChannelsCache::default_channels_cache(&mut i18n); + let relay_config = crate::relay_config::RelayConfig::default(); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache); @@ -583,6 +612,8 @@ impl Damus { support, options, decks_cache, + channels_cache, + relay_config, unrecognized_args: BTreeSet::default(), jobs: JobsCache::default(), threads: Threads::default(), diff --git a/crates/notedeck_columns/src/channels.rs b/crates/notedeck_columns/src/channels.rs new file mode 100644 index 0000000..83fa86b --- /dev/null +++ b/crates/notedeck_columns/src/channels.rs @@ -0,0 +1,299 @@ +use std::collections::HashMap; +use enostr::Pubkey; +use nostrdb::Transaction; +use notedeck::{tr, AppContext, Localization, FALLBACK_PUBKEY}; +use tracing::{error, info}; +use uuid::Uuid; + +use crate::{ + route::{Route, Router}, + timeline::{TimelineCache, TimelineKind}, + subscriptions::Subscriptions, +}; + +/// Represents a single channel (like a Slack channel) +/// Each channel filters notes by hashtag(s) +#[derive(Clone, Debug)] +pub struct Channel { + pub id: Uuid, + pub name: String, + pub hashtags: Vec, + pub timeline_kind: TimelineKind, + pub router: Router, + pub unread_count: usize, +} + +impl Channel { + pub fn new(name: String, hashtags: Vec) -> Self { + let id = Uuid::new_v4(); + let timeline_kind = TimelineKind::Hashtag(hashtags.clone()); + let router = Router::new(vec![Route::timeline(timeline_kind.clone())]); + + Self { + id, + name, + hashtags, + timeline_kind, + router, + unread_count: 0, + } + } + + pub fn with_id(id: Uuid, name: String, hashtags: Vec) -> Self { + let timeline_kind = TimelineKind::Hashtag(hashtags.clone()); + let router = Router::new(vec![Route::timeline(timeline_kind.clone())]); + + Self { + id, + name, + hashtags, + timeline_kind, + router, + unread_count: 0, + } + } + + pub fn router(&self) -> &Router { + &self.router + } + + pub fn router_mut(&mut self) -> &mut Router { + &mut self.router + } +} + +/// Contains all channels for a user +#[derive(Clone, Debug)] +pub struct ChannelList { + pub channels: Vec, + pub selected: usize, +} + +impl ChannelList { + pub fn new() -> Self { + Self { + channels: Vec::new(), + selected: 0, + } + } + + pub fn default_channels(i18n: &mut Localization) -> Self { + let mut list = Self::new(); + + // Add a default "general" channel + list.add_channel(Channel::new( + tr!(i18n, "General", "Default channel name").to_string(), + vec!["general".to_string()], + )); + + list + } + + pub fn add_channel(&mut self, channel: Channel) { + self.channels.push(channel); + } + + pub fn remove_channel(&mut self, index: usize) -> Option { + if index < self.channels.len() && self.channels.len() > 1 { + let removed = self.channels.remove(index); + + // Adjust selected index if needed + if self.selected >= self.channels.len() { + self.selected = self.channels.len() - 1; + } + + Some(removed) + } else { + None + } + } + + pub fn select_channel(&mut self, index: usize) { + if index < self.channels.len() { + self.selected = index; + } + } + + pub fn selected_channel(&self) -> Option<&Channel> { + self.channels.get(self.selected) + } + + pub fn selected_channel_mut(&mut self) -> Option<&mut Channel> { + self.channels.get_mut(self.selected) + } + + pub fn num_channels(&self) -> usize { + self.channels.len() + } + + pub fn get_channel(&self, index: usize) -> Option<&Channel> { + self.channels.get(index) + } + + pub fn get_channel_mut(&mut self, index: usize) -> Option<&mut Channel> { + self.channels.get_mut(index) + } + + /// Subscribe to all channels' timelines + pub fn subscribe_all( + &mut self, + subs: &mut Subscriptions, + timeline_cache: &mut TimelineCache, + ctx: &mut AppContext, + ) { + let txn = Transaction::new(ctx.ndb).unwrap(); + + for channel in &self.channels { + if let Some(_result) = timeline_cache.open( + subs, + ctx.ndb, + ctx.note_cache, + &txn, + ctx.pool, + &channel.timeline_kind, + ) { + // Process results if needed + } + } + } + + /// Unsubscribe from all channels + pub fn unsubscribe_all( + &mut self, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut enostr::RelayPool, + ) { + for channel in &self.channels { + if let Err(err) = timeline_cache.pop(&channel.timeline_kind, ndb, pool) { + error!("Failed to unsubscribe from channel timeline: {err}"); + } + } + } +} + +impl Default for ChannelList { + fn default() -> Self { + Self::new() + } +} + +/// Cache mapping users to their channel lists +pub struct ChannelsCache { + account_to_channels: HashMap, + fallback_pubkey: Pubkey, +} + +impl ChannelsCache { + pub fn new( + mut account_to_channels: HashMap, + i18n: &mut Localization, + ) -> Self { + let fallback_pubkey = FALLBACK_PUBKEY(); + account_to_channels + .entry(fallback_pubkey) + .or_insert_with(|| ChannelList::default_channels(i18n)); + + Self { + account_to_channels, + fallback_pubkey, + } + } + + pub fn default_channels_cache(i18n: &mut Localization) -> Self { + let mut account_to_channels: HashMap = Default::default(); + account_to_channels.insert(FALLBACK_PUBKEY(), ChannelList::default_channels(i18n)); + Self::new(account_to_channels, i18n) + } + + pub fn get_channels(&self, key: &Pubkey) -> &ChannelList { + self.account_to_channels + .get(key) + .unwrap_or_else(|| self.fallback()) + } + + pub fn get_channels_mut(&mut self, i18n: &mut Localization, key: &Pubkey) -> &mut ChannelList { + self.account_to_channels + .entry(*key) + .or_insert_with(|| ChannelList::default_channels(i18n)) + } + + pub fn active_channels(&self, accounts: ¬edeck::Accounts) -> &ChannelList { + let account = accounts.get_selected_account(); + self.get_channels(&account.key.pubkey) + } + + pub fn active_channels_mut( + &mut self, + i18n: &mut Localization, + accounts: ¬edeck::Accounts, + ) -> &mut ChannelList { + let account = accounts.get_selected_account(); + self.get_channels_mut(i18n, &account.key.pubkey) + } + + pub fn selected_channel(&self, accounts: ¬edeck::Accounts) -> Option<&Channel> { + self.active_channels(accounts).selected_channel() + } + + pub fn selected_channel_mut( + &mut self, + i18n: &mut Localization, + accounts: ¬edeck::Accounts, + ) -> Option<&mut Channel> { + self.active_channels_mut(i18n, accounts).selected_channel_mut() + } + + pub fn fallback(&self) -> &ChannelList { + self.account_to_channels + .get(&self.fallback_pubkey) + .expect("fallback channel list not found") + } + + pub fn fallback_mut(&mut self) -> &mut ChannelList { + self.account_to_channels + .get_mut(&self.fallback_pubkey) + .expect("fallback channel list not found") + } + + pub fn add_channel_for_account( + &mut self, + i18n: &mut Localization, + pubkey: Pubkey, + channel: Channel, + ) { + let channel_name = channel.name.clone(); + let channels = self.get_channels_mut(i18n, &pubkey); + channels.add_channel(channel); + info!("Added channel '{}' for {:?}", channel_name, pubkey); + } + + pub fn remove( + &mut self, + i18n: &mut Localization, + key: &Pubkey, + timeline_cache: &mut TimelineCache, + ndb: &mut nostrdb::Ndb, + pool: &mut enostr::RelayPool, + ) { + let Some(mut channels) = self.account_to_channels.remove(key) else { + return; + }; + info!("Removing channels for {:?}", key); + + channels.unsubscribe_all(timeline_cache, ndb, pool); + + if !self.account_to_channels.contains_key(&self.fallback_pubkey) { + self.account_to_channels + .insert(self.fallback_pubkey, ChannelList::default_channels(i18n)); + } + } + + pub fn get_fallback_pubkey(&self) -> &Pubkey { + &self.fallback_pubkey + } + + pub fn get_mapping(&self) -> &HashMap { + &self.account_to_channels + } +} diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs index 35e5451..794d1e1 100644 --- a/crates/notedeck_columns/src/lib.rs +++ b/crates/notedeck_columns/src/lib.rs @@ -8,10 +8,12 @@ pub mod actionbar; pub mod app_creation; mod app_style; mod args; +pub mod channels; pub mod column; mod deck_state; mod decks; mod draft; +pub mod relay_config; mod key_parsing; pub mod login_manager; mod media_upload; diff --git a/crates/notedeck_columns/src/relay_config.rs b/crates/notedeck_columns/src/relay_config.rs new file mode 100644 index 0000000..0de62f7 --- /dev/null +++ b/crates/notedeck_columns/src/relay_config.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use tracing::info; + +/// Global relay configuration (not tied to user accounts) +/// This determines which relays the app connects to for fetching events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayConfig { + /// List of relay URLs to monitor + pub relays: BTreeSet, +} + +impl RelayConfig { + pub fn new() -> Self { + Self { + relays: BTreeSet::new(), + } + } + + /// Create a default configuration with some popular relays + pub fn default_relays() -> Self { + let mut relays = BTreeSet::new(); + + // Add some default public relays + relays.insert("wss://relay.damus.io".to_string()); + relays.insert("wss://relay.nostr.band".to_string()); + relays.insert("wss://nos.lol".to_string()); + + Self { relays } + } + + pub fn add_relay(&mut self, url: String) -> bool { + let inserted = self.relays.insert(url.clone()); + if inserted { + info!("Added relay: {}", url); + } + inserted + } + + pub fn remove_relay(&mut self, url: &str) -> bool { + let removed = self.relays.remove(url); + if removed { + info!("Removed relay: {}", url); + } + removed + } + + pub fn has_relay(&self, url: &str) -> bool { + self.relays.contains(url) + } + + pub fn get_relays(&self) -> &BTreeSet { + &self.relays + } + + pub fn is_empty(&self) -> bool { + self.relays.is_empty() + } + + pub fn len(&self) -> usize { + self.relays.len() + } +} + +impl Default for RelayConfig { + fn default() -> Self { + Self::default_relays() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_config() { + let mut config = RelayConfig::new(); + assert!(config.is_empty()); + + config.add_relay("wss://relay.example.com".to_string()); + assert_eq!(config.len(), 1); + assert!(config.has_relay("wss://relay.example.com")); + + config.remove_relay("wss://relay.example.com"); + assert!(config.is_empty()); + } + + #[test] + fn test_default_relays() { + let config = RelayConfig::default_relays(); + assert!(!config.is_empty()); + assert!(config.has_relay("wss://relay.damus.io")); + } +} diff --git a/crates/notedeck_columns/src/storage/channels.rs b/crates/notedeck_columns/src/storage/channels.rs new file mode 100644 index 0000000..a362127 --- /dev/null +++ b/crates/notedeck_columns/src/storage/channels.rs @@ -0,0 +1,170 @@ +use std::collections::HashMap; + +use enostr::Pubkey; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::channels::{Channel, ChannelList, ChannelsCache}; + +use notedeck::{storage, DataPath, DataPathType, Directory, Localization}; + +pub static CHANNELS_CACHE_FILE: &str = "channels_cache.json"; + +pub fn load_channels_cache(path: &DataPath, i18n: &mut Localization) -> Option { + let data_path = path.path(DataPathType::Setting); + + let channels_cache_str = match Directory::new(data_path).get_file(CHANNELS_CACHE_FILE.to_owned()) { + Ok(s) => s, + Err(e) => { + error!( + "Could not read channels cache from file {}: {}", + CHANNELS_CACHE_FILE, e + ); + return None; + } + }; + + let serializable_channels_cache = + serde_json::from_str::(&channels_cache_str).ok()?; + + Some(serializable_channels_cache.channels_cache(i18n)) +} + +pub fn save_channels_cache(path: &DataPath, channels_cache: &ChannelsCache) { + let serialized_channels_cache = + match serde_json::to_string_pretty(&SerializableChannelsCache::to_serializable(channels_cache)) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize channels cache: {}", e); + return; + } + }; + + let data_path = path.path(DataPathType::Setting); + + if let Err(e) = storage::write_file( + &data_path, + CHANNELS_CACHE_FILE.to_string(), + &serialized_channels_cache, + ) { + error!( + "Could not write channels cache to file {}: {}", + CHANNELS_CACHE_FILE, e + ); + } else { + debug!("Successfully wrote channels cache to {}", CHANNELS_CACHE_FILE); + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableChannelsCache { + #[serde(serialize_with = "serialize_map", deserialize_with = "deserialize_map")] + channels_cache: HashMap, +} + +impl SerializableChannelsCache { + fn to_serializable(channels_cache: &ChannelsCache) -> Self { + SerializableChannelsCache { + channels_cache: channels_cache + .get_mapping() + .iter() + .map(|(k, v)| (*k, SerializableChannelList::from_channel_list(v))) + .collect(), + } + } + + pub fn channels_cache(self, i18n: &mut Localization) -> ChannelsCache { + let account_to_channels = self + .channels_cache + .into_iter() + .map(|(pubkey, serializable_channels)| { + (pubkey, serializable_channels.channel_list()) + }) + .collect(); + + ChannelsCache::new(account_to_channels, i18n) + } +} + +fn serialize_map( + map: &HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let stringified_map: HashMap = + map.iter().map(|(k, v)| (k.hex(), v)).collect(); + stringified_map.serialize(serializer) +} + +fn deserialize_map<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let stringified_map: HashMap = + HashMap::deserialize(deserializer)?; + + stringified_map + .into_iter() + .map(|(k, v)| { + let key = Pubkey::from_hex(&k).map_err(serde::de::Error::custom)?; + Ok((key, v)) + }) + .collect() +} + +#[derive(Serialize, Deserialize)] +struct SerializableChannelList { + channels: Vec, + selected: usize, +} + +impl SerializableChannelList { + pub fn from_channel_list(channel_list: &ChannelList) -> Self { + Self { + channels: channel_list + .channels + .iter() + .map(SerializableChannel::from_channel) + .collect(), + selected: channel_list.selected, + } + } + + fn channel_list(self) -> ChannelList { + ChannelList { + channels: self + .channels + .into_iter() + .map(|c| c.channel()) + .collect(), + selected: self.selected, + } + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableChannel { + id: String, + name: String, + hashtags: Vec, +} + +impl SerializableChannel { + pub fn from_channel(channel: &Channel) -> Self { + Self { + id: channel.id.to_string(), + name: channel.name.clone(), + hashtags: channel.hashtags.clone(), + } + } + + pub fn channel(self) -> Channel { + let id = Uuid::parse_str(&self.id).unwrap_or_else(|_| Uuid::new_v4()); + Channel::with_id(id, self.name, self.hashtags) + } +} diff --git a/crates/notedeck_columns/src/storage/mod.rs b/crates/notedeck_columns/src/storage/mod.rs index 58cc2e7..b81cabc 100644 --- a/crates/notedeck_columns/src/storage/mod.rs +++ b/crates/notedeck_columns/src/storage/mod.rs @@ -1,3 +1,7 @@ +mod channels; mod decks; +mod relay_config; +pub use channels::{load_channels_cache, save_channels_cache, CHANNELS_CACHE_FILE}; pub use decks::{load_decks_cache, save_decks_cache, DECKS_CACHE_FILE}; +pub use relay_config::{load_relay_config, save_relay_config, RELAY_CONFIG_FILE}; diff --git a/crates/notedeck_columns/src/storage/relay_config.rs b/crates/notedeck_columns/src/storage/relay_config.rs new file mode 100644 index 0000000..2196923 --- /dev/null +++ b/crates/notedeck_columns/src/storage/relay_config.rs @@ -0,0 +1,48 @@ +use notedeck::{storage, DataPath, DataPathType, Directory}; +use tracing::{debug, error}; + +use crate::relay_config::RelayConfig; + +pub static RELAY_CONFIG_FILE: &str = "relay_config.json"; + +pub fn load_relay_config(path: &DataPath) -> Option { + let data_path = path.path(DataPathType::Setting); + + let relay_config_str = match Directory::new(data_path).get_file(RELAY_CONFIG_FILE.to_owned()) { + Ok(s) => s, + Err(e) => { + error!( + "Could not read relay config from file {}: {}", + RELAY_CONFIG_FILE, e + ); + return None; + } + }; + + serde_json::from_str::(&relay_config_str).ok() +} + +pub fn save_relay_config(path: &DataPath, relay_config: &RelayConfig) { + let serialized_relay_config = match serde_json::to_string_pretty(relay_config) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize relay config: {}", e); + return; + } + }; + + let data_path = path.path(DataPathType::Setting); + + if let Err(e) = storage::write_file( + &data_path, + RELAY_CONFIG_FILE.to_string(), + &serialized_relay_config, + ) { + error!( + "Could not write relay config to file {}: {}", + RELAY_CONFIG_FILE, e + ); + } else { + debug!("Successfully wrote relay config to {}", RELAY_CONFIG_FILE); + } +}