Complete Phase 1 & 2: Full Slack-like interface integration

This commit integrates all components to create a functional Slack-like interface:

**Phase 1: API Fixes**
- Fixed ChatView compilation errors (APIs match current codebase)
- Proper BodyResponse, ProfilePic, and timeline access patterns

**Phase 2: Integration**
-  ChannelSidebar integrated into main layout (240px sidebar)
-  Channel subscription logic implemented and working
-  RelayConfig applied to RelayPool on startup
-  Save triggers for channel modifications
- Layout: [ChannelSidebar | DesktopSidePanel | Columns]

**Key Features Now Working:**
1. Channel sidebar displays and is clickable
2. Channels subscribe to hashtag filters on app start
3. Relay configuration properly applied
4. Channel selection persists to disk
5. ChatView component compiles (ready for Phase 3 wiring)

**Changes:**
- app.rs: Added ChannelSidebar rendering, action handling, relay application
- channels.rs: Added `subscribed` field, proper subscribe_all() logic
- chat_view.rs: Fixed all API mismatches (compiles cleanly)

**Next Steps (Phase 3):**
- Wire ChatView to display selected channel's timeline
- Add channel creation/edit dialog
- Build thread side panel for Slack-style threading

All code compiles successfully with zero errors!
This commit is contained in:
Claude
2025-11-12 16:14:46 +00:00
parent da38e13d8e
commit d2dbe3d0f2
3 changed files with 153 additions and 33 deletions

View File

@@ -12,7 +12,12 @@ use crate::{
support::Support,
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
toolbar::unseen_notification,
ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction},
ui::{
self,
channel_sidebar::CHANNEL_SIDEBAR_WIDTH,
toolbar::toolbar,
ChannelSidebar, ChannelSidebarAction, DesktopSidePanel, SidePanelAction,
},
view_state::ViewState,
Result,
};
@@ -524,7 +529,7 @@ impl Damus {
let threads = Threads::default();
// Load or create channels cache
let channels_cache = if let Some(channels_cache) = crate::storage::load_channels_cache(
let mut channels_cache = if let Some(channels_cache) = crate::storage::load_channels_cache(
app_context.path,
app_context.i18n,
) {
@@ -535,6 +540,19 @@ impl Damus {
crate::channels::ChannelsCache::default_channels_cache(app_context.i18n)
};
// Subscribe to all active channels to fetch data from relays
{
let active_channels = channels_cache.active_channels_mut(
app_context.i18n,
app_context.accounts,
);
active_channels.subscribe_all(
&mut subscriptions,
&mut timeline_cache,
app_context,
);
}
// Load or create relay config
let relay_config = if let Some(relay_config) = crate::storage::load_relay_config(
app_context.path,
@@ -546,6 +564,16 @@ impl Damus {
crate::relay_config::RelayConfig::default()
};
// Apply relay config to pool - connect to configured relays
for relay_url in relay_config.get_relays() {
let wakeup = || {}; // Wakeup closure for relay events
if let Err(e) = app_context.pool.add_url(relay_url.clone(), wakeup) {
error!("Failed to add relay {}: {}", relay_url, e);
} else {
info!("Added relay from config: {}", relay_url);
}
}
Self {
subscriptions,
timeline_cache,
@@ -861,15 +889,38 @@ fn timelines_view(
) -> AppResponse {
let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns();
let mut side_panel_action: Option<nav::SwitchingAction> = None;
let mut channel_sidebar_action: Option<ChannelSidebarAction> = None;
let mut responses = Vec::with_capacity(num_cols);
let mut can_take_drag_from = Vec::new();
StripBuilder::new(ui)
.size(Size::exact(CHANNEL_SIDEBAR_WIDTH))
.size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
.sizes(sizes, num_cols)
.clip(true)
.horizontal(|mut strip| {
// Channel Sidebar
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
let mut channel_sidebar =
ChannelSidebar::new(&app.channels_cache, ctx.accounts, ctx.i18n);
if let Some(response) = channel_sidebar.show(ui) {
if response.response.clicked() {
channel_sidebar_action = Some(response.action);
}
}
// vertical sidebar line
ui.painter().vline(
rect.right(),
rect.y_range(),
ui.visuals().widgets.noninteractive.bg_stroke,
);
});
// Desktop Side Panel
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
let side_panel = DesktopSidePanel::new(
@@ -952,6 +1003,22 @@ fn timelines_view(
);
}
// process channel sidebar action
if let Some(action) = channel_sidebar_action {
match action {
ChannelSidebarAction::SelectChannel(idx) => {
app.channels_cache
.active_channels_mut(ctx.i18n, ctx.accounts)
.select_channel(idx);
// Save channel state after selection changes
storage::save_channels_cache(ctx.path, &app.channels_cache);
}
ChannelSidebarAction::AddChannel => {
// TODO: Show add channel dialog
}
}
}
let mut app_action: Option<AppAction> = None;
for response in responses {

View File

@@ -21,6 +21,7 @@ pub struct Channel {
pub timeline_kind: TimelineKind,
pub router: Router<Route>,
pub unread_count: usize,
pub subscribed: bool,
}
impl Channel {
@@ -36,6 +37,7 @@ impl Channel {
timeline_kind,
router,
unread_count: 0,
subscribed: false,
}
}
@@ -50,6 +52,7 @@ impl Channel {
timeline_kind,
router,
unread_count: 0,
subscribed: false,
}
}
@@ -143,8 +146,13 @@ impl ChannelList {
) {
let txn = Transaction::new(ctx.ndb).unwrap();
for channel in &self.channels {
if let Some(_result) = timeline_cache.open(
for channel in &mut self.channels {
// Skip if already subscribed
if channel.subscribed {
continue;
}
if let Some(result) = timeline_cache.open(
subs,
ctx.ndb,
ctx.note_cache,
@@ -152,7 +160,18 @@ impl ChannelList {
ctx.pool,
&channel.timeline_kind,
) {
// Process results if needed
// Process the result to handle unknown IDs and new notes
result.process(
ctx.ndb,
ctx.note_cache,
&txn,
timeline_cache,
ctx.unknown_ids,
);
// Mark channel as subscribed
channel.subscribed = true;
info!("Subscribed to channel: {}", channel.name);
}
}
}
@@ -164,9 +183,13 @@ impl ChannelList {
ndb: &mut nostrdb::Ndb,
pool: &mut enostr::RelayPool,
) {
for channel in &self.channels {
for channel in &mut self.channels {
if let Err(err) = timeline_cache.pop(&channel.timeline_kind, ndb, pool) {
error!("Failed to unsubscribe from channel timeline: {err}");
} else {
// Mark channel as unsubscribed
channel.subscribed = false;
info!("Unsubscribed from channel: {}", channel.name);
}
}
}

View File

@@ -1,12 +1,12 @@
use egui::{
vec2, Align, Color32, CursorIcon, Layout, Margin, Pos2, Rect, RichText, ScrollArea, Sense,
Stroke, TextStyle, Vec2,
vec2, Align, Color32, CursorIcon, Layout, Margin, RichText, ScrollArea, Sense,
Stroke,
};
use nostrdb::{Note, NoteKey, ProfileRecord, Transaction};
use nostrdb::{Note, Transaction};
use notedeck::fonts::get_font_size;
use notedeck::name::get_display_name;
use notedeck::{tr, JobsCache, Localization, Muted, NoteAction, NoteContext, NotedeckTextStyle};
use notedeck_ui::{ProfilePic, ProfilePreview};
use notedeck::{tr, JobsCache, NoteAction, NoteContext, NotedeckTextStyle};
use notedeck_ui::ProfilePic;
use tracing::warn;
use crate::nav::BodyResponse;
@@ -48,11 +48,10 @@ impl<'a, 'd> ChatView<'a, 'd> {
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<Option<NoteAction>> {
let timeline = if let Some(tl) = self.timeline_cache.get_mut(self.timeline_id) {
tl
} else {
return BodyResponse::new(None);
};
// Check that timeline exists
if self.timeline_cache.get(self.timeline_id).is_none() {
return BodyResponse::none();
}
let scroll_id = egui::Id::new(("chat_scroll", self.timeline_id, self.col));
@@ -65,7 +64,16 @@ impl<'a, 'd> ChatView<'a, 'd> {
.auto_shrink([false, false])
.show(ui, |ui| {
ui.with_layout(Layout::top_down(Align::Min), |ui| {
let notes = timeline.notes(self.note_context.ndb);
let units_len = {
let timeline = if let Some(tl) = self.timeline_cache.get(self.timeline_id) {
tl
} else {
warn!("Timeline missing in chat view");
return;
};
timeline.current_view().units.len()
};
let txn = if let Ok(txn) = Transaction::new(self.note_context.ndb) {
txn
} else {
@@ -73,7 +81,7 @@ impl<'a, 'd> ChatView<'a, 'd> {
return;
};
if notes.is_empty() {
if units_len == 0 {
// Empty state
ui.add_space(50.0);
ui.vertical_centered(|ui| {
@@ -102,8 +110,33 @@ impl<'a, 'd> ChatView<'a, 'd> {
let mut last_author: Option<Vec<u8>> = None;
let mut last_timestamp: u64 = 0;
for note_key in notes {
let note = if let Ok(note) = self.note_context.ndb.get_note_by_key(&txn, *note_key) {
for i in 0..units_len {
let note_key = {
let timeline = if let Some(tl) = self.timeline_cache.get(self.timeline_id) {
tl
} else {
continue;
};
let unit = if let Some(u) = timeline.current_view().units.get(i) {
u
} else {
continue;
};
// Extract the note key from the unit
match unit {
crate::timeline::NoteUnit::Single(note_ref) => note_ref.key,
crate::timeline::NoteUnit::Composite(composite) => {
match composite {
crate::timeline::CompositeUnit::Reaction(r) => r.note_reacted_to.key,
crate::timeline::CompositeUnit::Repost(r) => r.note_reposted.key,
}
}
}
};
let note = if let Ok(note) = self.note_context.ndb.get_note_by_key(&txn, note_key) {
note
} else {
continue;
@@ -141,7 +174,7 @@ impl<'a, 'd> ChatView<'a, 'd> {
});
});
BodyResponse::new(note_action)
BodyResponse::output(Some(note_action))
}
fn render_message(
@@ -168,13 +201,15 @@ impl<'a, 'd> ChatView<'a, 'd> {
.ok();
let resp = ui.add(
ProfilePic::new(self.jobs, note.pubkey())
.size(AVATAR_SIZE)
.profile(profile.as_ref().map(|p| p.record().profile())),
&mut ProfilePic::from_profile_or_default(
self.note_context.img_cache,
profile.as_ref()
)
.size(AVATAR_SIZE)
);
if resp.clicked() {
note_action = Some(NoteAction::Profile(note.pubkey().bytes_vec()));
note_action = Some(NoteAction::Profile(enostr::Pubkey::new(*note.pubkey())));
}
} else {
// Just spacing for grouped messages
@@ -185,13 +220,8 @@ impl<'a, 'd> ChatView<'a, 'd> {
// Message content column
ui.with_layout(Layout::top_down(Align::Min), |ui| {
let max_rect = ui.max_rect();
let content_max_rect = Rect::from_min_size(
max_rect.min,
vec2(max_bubble_width, max_rect.height()),
);
ui.set_max_rect(content_max_rect);
// Constrain the content width for message bubbles
ui.set_max_width(max_bubble_width);
if show_header {
// Message header: name + timestamp
@@ -221,7 +251,7 @@ impl<'a, 'd> ChatView<'a, 'd> {
.get_profile_by_pubkey(txn, note.pubkey())
.ok();
let display_name = get_display_name(profile.as_ref().map(|p| p.record()));
let display_name = get_display_name(profile.as_ref());
let name_response = ui.add(
egui::Label::new(