Add Channel and RelayConfig data structures for Slack-like interface

This commit lays the foundation for the Slack-like interface redesign:

- Add Channel data structure: represents a channel that filters notes by hashtags
- Add ChannelList: manages multiple channels per user
- Add ChannelsCache: maps users to their channel lists (stored per-account)
- Add RelayConfig: global relay configuration (not tied to user profiles)
- Add persistence: save/load channels and relay config to disk
- Update Damus app state to include channels_cache and relay_config

Next steps:
- Create sidebar UI with channel list
- Build main chat view with message bubbles
- Add thread side panel
- Update main layout to use new components
This commit is contained in:
Claude
2025-11-12 15:31:20 +00:00
parent 6c07edced3
commit e4fcf15fcb
7 changed files with 648 additions and 0 deletions

View File

@@ -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<P: AsRef<Path>>(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(),

View File

@@ -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<String>,
pub timeline_kind: TimelineKind,
pub router: Router<Route>,
pub unread_count: usize,
}
impl Channel {
pub fn new(name: String, hashtags: Vec<String>) -> 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<String>) -> 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<Route> {
&self.router
}
pub fn router_mut(&mut self) -> &mut Router<Route> {
&mut self.router
}
}
/// Contains all channels for a user
#[derive(Clone, Debug)]
pub struct ChannelList {
pub channels: Vec<Channel>,
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<Channel> {
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<Pubkey, ChannelList>,
fallback_pubkey: Pubkey,
}
impl ChannelsCache {
pub fn new(
mut account_to_channels: HashMap<Pubkey, ChannelList>,
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<Pubkey, ChannelList> = 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: &notedeck::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: &notedeck::Accounts,
) -> &mut ChannelList {
let account = accounts.get_selected_account();
self.get_channels_mut(i18n, &account.key.pubkey)
}
pub fn selected_channel(&self, accounts: &notedeck::Accounts) -> Option<&Channel> {
self.active_channels(accounts).selected_channel()
}
pub fn selected_channel_mut(
&mut self,
i18n: &mut Localization,
accounts: &notedeck::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<Pubkey, ChannelList> {
&self.account_to_channels
}
}

View File

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

View File

@@ -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<String>,
}
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<String> {
&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"));
}
}

View File

@@ -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<ChannelsCache> {
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::<SerializableChannelsCache>(&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<Pubkey, SerializableChannelList>,
}
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<S>(
map: &HashMap<Pubkey, SerializableChannelList>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let stringified_map: HashMap<String, &SerializableChannelList> =
map.iter().map(|(k, v)| (k.hex(), v)).collect();
stringified_map.serialize(serializer)
}
fn deserialize_map<'de, D>(
deserializer: D,
) -> Result<HashMap<Pubkey, SerializableChannelList>, D::Error>
where
D: serde::Deserializer<'de>,
{
let stringified_map: HashMap<String, SerializableChannelList> =
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<SerializableChannel>,
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<String>,
}
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)
}
}

View File

@@ -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};

View File

@@ -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<RelayConfig> {
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::<RelayConfig>(&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);
}
}