Add Slack-style thread side panel

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.
This commit is contained in:
Claude
2025-11-13 05:38:20 +00:00
parent 62d6c70c3d
commit a9ce1b06f9
3 changed files with 169 additions and 4 deletions

View File

@@ -52,6 +52,7 @@ pub struct Damus {
pub relay_config: crate::relay_config::RelayConfig, pub relay_config: crate::relay_config::RelayConfig,
pub channel_dialog: ui::ChannelDialog, pub channel_dialog: ui::ChannelDialog,
pub channel_switcher: ui::ChannelSwitcher, pub channel_switcher: ui::ChannelSwitcher,
pub thread_panel: ui::ThreadPanel,
pub view_state: ViewState, pub view_state: ViewState,
pub drafts: Drafts, pub drafts: Drafts,
pub timeline_cache: TimelineCache, pub timeline_cache: TimelineCache,
@@ -221,9 +222,11 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
// Handle keyboard shortcuts // Handle keyboard shortcuts
ctx.input(|i| { ctx.input(|i| {
// Escape to close dialogs // Escape to close dialogs and panels
if i.key_pressed(egui::Key::Escape) { 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(); damus.channel_dialog.close();
} else if damus.channel_switcher.is_open { } else if damus.channel_switcher.is_open {
damus.channel_switcher.close(); 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 // Cmd+N / Ctrl+N to open new channel dialog
let cmd_n = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::N); 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(); damus.channel_dialog.open();
} }
// Cmd+K / Ctrl+K to open channel switcher // Cmd+K / Ctrl+K to open channel switcher
let cmd_k = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::K); 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(); damus.channel_switcher.open();
} }
}); });
@@ -686,6 +689,7 @@ impl Damus {
relay_config, relay_config,
channel_dialog: ui::ChannelDialog::default(), channel_dialog: ui::ChannelDialog::default(),
channel_switcher: ui::ChannelSwitcher::default(), channel_switcher: ui::ChannelSwitcher::default(),
thread_panel: ui::ThreadPanel::default(),
unrecognized_args, unrecognized_args,
jobs, jobs,
threads, threads,
@@ -743,6 +747,7 @@ impl Damus {
relay_config, relay_config,
channel_dialog: ui::ChannelDialog::default(), channel_dialog: ui::ChannelDialog::default(),
channel_switcher: ui::ChannelSwitcher::default(), channel_switcher: ui::ChannelSwitcher::default(),
thread_panel: ui::ThreadPanel::default(),
unrecognized_args: BTreeSet::default(), unrecognized_args: BTreeSet::default(),
jobs: JobsCache::default(), jobs: JobsCache::default(),
threads: Threads::default(), threads: Threads::default(),

View File

@@ -22,6 +22,7 @@ pub mod settings;
pub mod side_panel; pub mod side_panel;
pub mod support; pub mod support;
pub mod thread; pub mod thread;
pub mod thread_panel;
pub mod timeline; pub mod timeline;
pub mod toolbar; pub mod toolbar;
pub mod wallet; pub mod wallet;
@@ -39,4 +40,5 @@ pub use relay::RelayView;
pub use settings::SettingsView; pub use settings::SettingsView;
pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use side_panel::{DesktopSidePanel, SidePanelAction};
pub use thread::ThreadView; pub use thread::ThreadView;
pub use thread_panel::{ThreadPanel, ThreadPanelAction};
pub use timeline::TimelineView; pub use timeline::TimelineView;

View File

@@ -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<ThreadPanelAction>, Option<NoteAction>) {
if !self.is_open || self.selected_thread_id.is_none() {
return (None, None);
}
let mut panel_action: Option<ThreadPanelAction> = None;
let mut note_action: Option<NoteAction> = 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()
}
}