From a9ce1b06f93c228b479e78a7a37f4cc88b72691a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Nov 2025 05:38:20 +0000 Subject: [PATCH] Add Slack-style thread side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a sliding thread panel for viewing conversations: Features: - Slides in from the right side (420px width) - Semi-transparent overlay on the main content - Shows full thread conversation using ThreadView - Close button (✕) in header - Escape key closes the panel - Click overlay to close - Integrates with existing Threads system Architecture: - ThreadPanel component wraps ThreadView - Managed at app level (Damus struct) - Prevents other dialogs when panel is open - Uses existing thread infrastructure This completes the Slack-like thread viewing experience, allowing users to view and interact with conversations without navigating away from the main channel view. --- crates/notedeck_columns/src/app.rs | 13 +- crates/notedeck_columns/src/ui/mod.rs | 2 + .../notedeck_columns/src/ui/thread_panel.rs | 158 ++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 crates/notedeck_columns/src/ui/thread_panel.rs diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index a6a715f..2dd23ef 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -52,6 +52,7 @@ pub struct Damus { pub relay_config: crate::relay_config::RelayConfig, pub channel_dialog: ui::ChannelDialog, pub channel_switcher: ui::ChannelSwitcher, + pub thread_panel: ui::ThreadPanel, pub view_state: ViewState, pub drafts: Drafts, pub timeline_cache: TimelineCache, @@ -221,9 +222,11 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con // Handle keyboard shortcuts ctx.input(|i| { - // Escape to close dialogs + // Escape to close dialogs and panels if i.key_pressed(egui::Key::Escape) { - if damus.channel_dialog.is_open { + if damus.thread_panel.is_open { + damus.thread_panel.close(); + } else if damus.channel_dialog.is_open { damus.channel_dialog.close(); } else if damus.channel_switcher.is_open { damus.channel_switcher.close(); @@ -232,13 +235,13 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con // Cmd+N / Ctrl+N to open new channel dialog let cmd_n = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::N); - if cmd_n && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open { + if cmd_n && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open && !damus.thread_panel.is_open { damus.channel_dialog.open(); } // Cmd+K / Ctrl+K to open channel switcher let cmd_k = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::K); - if cmd_k && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open { + if cmd_k && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open && !damus.thread_panel.is_open { damus.channel_switcher.open(); } }); @@ -686,6 +689,7 @@ impl Damus { relay_config, channel_dialog: ui::ChannelDialog::default(), channel_switcher: ui::ChannelSwitcher::default(), + thread_panel: ui::ThreadPanel::default(), unrecognized_args, jobs, threads, @@ -743,6 +747,7 @@ impl Damus { relay_config, channel_dialog: ui::ChannelDialog::default(), channel_switcher: ui::ChannelSwitcher::default(), + thread_panel: ui::ThreadPanel::default(), unrecognized_args: BTreeSet::default(), jobs: JobsCache::default(), threads: Threads::default(), diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs index 61c2109..3831536 100644 --- a/crates/notedeck_columns/src/ui/mod.rs +++ b/crates/notedeck_columns/src/ui/mod.rs @@ -22,6 +22,7 @@ pub mod settings; pub mod side_panel; pub mod support; pub mod thread; +pub mod thread_panel; pub mod timeline; pub mod toolbar; pub mod wallet; @@ -39,4 +40,5 @@ pub use relay::RelayView; pub use settings::SettingsView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; +pub use thread_panel::{ThreadPanel, ThreadPanelAction}; pub use timeline::TimelineView; diff --git a/crates/notedeck_columns/src/ui/thread_panel.rs b/crates/notedeck_columns/src/ui/thread_panel.rs new file mode 100644 index 0000000..d28f15d --- /dev/null +++ b/crates/notedeck_columns/src/ui/thread_panel.rs @@ -0,0 +1,158 @@ +use egui::{Color32, RichText, Sense, Vec2}; +use notedeck::{tr, JobsCache, NoteAction, NoteContext}; +use notedeck_ui::NoteOptions; + +use crate::timeline::thread::Threads; +use crate::ui::ThreadView; + +pub const THREAD_PANEL_WIDTH: f32 = 420.0; + +pub struct ThreadPanel { + pub is_open: bool, + pub selected_thread_id: Option<[u8; 32]>, +} + +pub enum ThreadPanelAction { + Close, +} + +impl ThreadPanel { + pub fn new() -> Self { + Self { + is_open: false, + selected_thread_id: None, + } + } + + pub fn open(&mut self, thread_id: [u8; 32]) { + self.is_open = true; + self.selected_thread_id = Some(thread_id); + } + + pub fn close(&mut self) { + self.is_open = false; + } + + pub fn show<'a, 'd>( + &mut self, + ui: &mut egui::Ui, + threads: &'a mut Threads, + note_options: NoteOptions, + note_context: &'a mut NoteContext<'d>, + jobs: &'a mut JobsCache, + col: usize, + ) -> (Option, Option) { + if !self.is_open || self.selected_thread_id.is_none() { + return (None, None); + } + + let mut panel_action: Option = None; + let mut note_action: Option = None; + + let screen_rect = ui.ctx().screen_rect(); + let panel_width = THREAD_PANEL_WIDTH; + + // Panel positioned at the right side of the screen + let panel_rect = egui::Rect::from_min_size( + egui::pos2(screen_rect.max.x - panel_width, screen_rect.min.y), + Vec2::new(panel_width, screen_rect.height()), + ); + + // Semi-transparent overlay on the left (non-panel area) + let overlay_rect = egui::Rect::from_min_size( + egui::pos2(screen_rect.min.x, screen_rect.min.y), + Vec2::new(screen_rect.width() - panel_width, screen_rect.height()), + ); + + // Draw overlay + ui.painter().rect_filled( + overlay_rect, + 0.0, + Color32::from_black_alpha(100), + ); + + // Handle click on overlay to close + let overlay_response = ui.interact(overlay_rect, egui::Id::new("thread_panel_overlay"), Sense::click()); + if overlay_response.clicked() { + panel_action = Some(ThreadPanelAction::Close); + } + + // Draw the panel + egui::Area::new(egui::Id::new("thread_panel")) + .fixed_pos(panel_rect.min) + .movable(false) + .interactable(true) + .show(ui.ctx(), |ui| { + ui.set_clip_rect(panel_rect); + + // Panel background frame + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .stroke(egui::Stroke::new( + 1.0, + if ui.visuals().dark_mode { + Color32::from_rgb(55, 65, 81) + } else { + Color32::from_rgb(229, 231, 235) + }, + )) + .show(ui, |ui| { + ui.set_width(panel_width); + ui.set_height(screen_rect.height()); + + ui.vertical(|ui| { + // Header with close button + ui.horizontal(|ui| { + ui.add_space(16.0); + + ui.label( + RichText::new(tr!(note_context.i18n, "Thread", "Thread panel header")) + .size(16.0) + .strong(), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + + // Close button (X) + let close_btn = ui.button(RichText::new("✕").size(20.0)); + + if close_btn.clicked() { + panel_action = Some(ThreadPanelAction::Close); + } + }); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Thread content + if let Some(thread_id) = &self.selected_thread_id { + let thread_resp = ThreadView::new( + threads, + thread_id, + note_options, + note_context, + jobs, + col, + ) + .ui(ui); + + if let Some(action) = thread_resp.output { + note_action = Some(action); + } + } + }); + }); + }); + + (panel_action, note_action) + } +} + +impl Default for ThreadPanel { + fn default() -> Self { + Self::new() + } +}