mirror of
https://github.com/aljazceru/notedeck.git
synced 2025-12-17 08:44:20 +01:00
Fix critical issues from code review
Removed: - Channel switcher feature (Cmd+K) - removed entire feature as requested Fixed channel dialog: - Auto-focus on name field now works correctly - Escape key now closes dialog - Enter key submits form (when not in hashtags field) - Made hashtags optional (only channel name required) Fixed error handling: - Replaced .expect() and .unwrap() with proper error handling - Transaction creation failures now log errors instead of panicking - System time errors handled gracefully - Added safety comments for fallback channel access Improved internationalization: - format_timestamp() now uses tr!() macro for all strings - Supports localization for "Just now", "ago", "Yesterday", etc. Refactored code: - Extracted inline action handling to process_chat_action() function - Cleaner separation of concerns - More testable and maintainable Thread panel: - ThreadView handles missing threads gracefully (no extra validation needed) Build status: - Compiles successfully with only unused field warnings - All functionality tested
This commit is contained in:
@@ -52,7 +52,6 @@ pub struct Damus {
|
|||||||
pub channels_cache: crate::channels::ChannelsCache,
|
pub channels_cache: crate::channels::ChannelsCache,
|
||||||
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 thread_panel: ui::ThreadPanel,
|
pub thread_panel: ui::ThreadPanel,
|
||||||
pub view_state: ViewState,
|
pub view_state: ViewState,
|
||||||
pub drafts: Drafts,
|
pub drafts: Drafts,
|
||||||
@@ -229,22 +228,14 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
|
|||||||
damus.thread_panel.close();
|
damus.thread_panel.close();
|
||||||
} else if damus.channel_dialog.is_open {
|
} else if damus.channel_dialog.is_open {
|
||||||
damus.channel_dialog.close();
|
damus.channel_dialog.close();
|
||||||
} else if damus.channel_switcher.is_open {
|
|
||||||
damus.channel_switcher.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 && !damus.thread_panel.is_open {
|
if cmd_n && !damus.channel_dialog.is_open && !damus.thread_panel.is_open {
|
||||||
damus.channel_dialog.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 && !damus.thread_panel.is_open {
|
|
||||||
damus.channel_switcher.open();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if damus.columns(app_ctx.accounts).columns().is_empty() {
|
if damus.columns(app_ctx.accounts).columns().is_empty() {
|
||||||
@@ -446,7 +437,13 @@ fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::
|
|||||||
storage::save_channels_cache(app_ctx.path, &damus.channels_cache);
|
storage::save_channels_cache(app_ctx.path, &damus.channels_cache);
|
||||||
|
|
||||||
// Subscribe to the new channel
|
// Subscribe to the new channel
|
||||||
let txn = nostrdb::Transaction::new(app_ctx.ndb).unwrap();
|
let txn = match nostrdb::Transaction::new(app_ctx.ndb) {
|
||||||
|
Ok(txn) => txn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to create transaction for channel subscription: {}", e);
|
||||||
|
return app_resp;
|
||||||
|
}
|
||||||
|
};
|
||||||
let channel_count = damus.channels_cache.active_channels(app_ctx.accounts).num_channels();
|
let channel_count = damus.channels_cache.active_channels(app_ctx.accounts).num_channels();
|
||||||
if let Some(channel) = damus.channels_cache.active_channels_mut(app_ctx.i18n, app_ctx.accounts).get_channel_mut(channel_count - 1) {
|
if let Some(channel) = damus.channels_cache.active_channels_mut(app_ctx.i18n, app_ctx.accounts).get_channel_mut(channel_count - 1) {
|
||||||
if !channel.subscribed {
|
if !channel.subscribed {
|
||||||
@@ -476,30 +473,6 @@ fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show channel switcher (Cmd+K)
|
|
||||||
if let Some(switcher_action) = damus.channel_switcher.show(
|
|
||||||
ui.ctx(),
|
|
||||||
app_ctx.i18n,
|
|
||||||
&damus.channels_cache,
|
|
||||||
app_ctx.accounts,
|
|
||||||
) {
|
|
||||||
match switcher_action {
|
|
||||||
ui::ChannelSwitcherAction::SelectChannel(idx) => {
|
|
||||||
// Select the channel
|
|
||||||
damus
|
|
||||||
.channels_cache
|
|
||||||
.active_channels_mut(app_ctx.i18n, app_ctx.accounts)
|
|
||||||
.select_channel(idx);
|
|
||||||
|
|
||||||
// Save channel state
|
|
||||||
storage::save_channels_cache(app_ctx.path, &damus.channels_cache);
|
|
||||||
}
|
|
||||||
ui::ChannelSwitcherAction::Close => {
|
|
||||||
// Switcher was closed, nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show thread panel if open
|
// Show thread panel if open
|
||||||
if damus.thread_panel.is_open {
|
if damus.thread_panel.is_open {
|
||||||
let mut note_context = notedeck::NoteContext {
|
let mut note_context = notedeck::NoteContext {
|
||||||
@@ -730,7 +703,6 @@ impl Damus {
|
|||||||
channels_cache,
|
channels_cache,
|
||||||
relay_config,
|
relay_config,
|
||||||
channel_dialog: ui::ChannelDialog::default(),
|
channel_dialog: ui::ChannelDialog::default(),
|
||||||
channel_switcher: ui::ChannelSwitcher::default(),
|
|
||||||
thread_panel: ui::ThreadPanel::default(),
|
thread_panel: ui::ThreadPanel::default(),
|
||||||
unrecognized_args,
|
unrecognized_args,
|
||||||
jobs,
|
jobs,
|
||||||
@@ -788,7 +760,6 @@ impl Damus {
|
|||||||
channels_cache,
|
channels_cache,
|
||||||
relay_config,
|
relay_config,
|
||||||
channel_dialog: ui::ChannelDialog::default(),
|
channel_dialog: ui::ChannelDialog::default(),
|
||||||
channel_switcher: ui::ChannelSwitcher::default(),
|
|
||||||
thread_panel: ui::ThreadPanel::default(),
|
thread_panel: ui::ThreadPanel::default(),
|
||||||
unrecognized_args: BTreeSet::default(),
|
unrecognized_args: BTreeSet::default(),
|
||||||
jobs: JobsCache::default(),
|
jobs: JobsCache::default(),
|
||||||
@@ -1029,6 +1000,64 @@ fn render_damus_desktop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Process chat view actions like Reply, React, Repost
|
||||||
|
fn process_chat_action(
|
||||||
|
action: NoteAction,
|
||||||
|
app: &mut Damus,
|
||||||
|
ctx: &mut AppContext<'_>,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
) {
|
||||||
|
match action {
|
||||||
|
NoteAction::Note { note_id, .. } => {
|
||||||
|
// Open thread panel for viewing threads
|
||||||
|
app.thread_panel.open(*note_id.bytes());
|
||||||
|
}
|
||||||
|
NoteAction::Reply(note_id) => {
|
||||||
|
// Open thread panel for replying
|
||||||
|
app.thread_panel.open(*note_id.bytes());
|
||||||
|
}
|
||||||
|
NoteAction::React(react_action) => {
|
||||||
|
// Handle reaction (like) - send to relays
|
||||||
|
if let Some(filled) = ctx.accounts.selected_filled() {
|
||||||
|
let txn = match Transaction::new(ctx.ndb) {
|
||||||
|
Ok(txn) => txn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to create transaction for reaction: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send reaction event using existing infrastructure
|
||||||
|
if let Err(err) = crate::actionbar::send_reaction_event(
|
||||||
|
ctx.ndb,
|
||||||
|
&txn,
|
||||||
|
ctx.pool,
|
||||||
|
filled,
|
||||||
|
&react_action,
|
||||||
|
) {
|
||||||
|
error!("Failed to send reaction: {err}");
|
||||||
|
} else {
|
||||||
|
// Mark reaction as sent in UI
|
||||||
|
ui.ctx().data_mut(|d| {
|
||||||
|
use notedeck::note::reaction_sent_id;
|
||||||
|
d.insert_temp(
|
||||||
|
reaction_sent_id(&filled.pubkey, react_action.note_id.bytes()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NoteAction::Repost(note_id) => {
|
||||||
|
// For now, open thread panel - could add repost dialog later
|
||||||
|
app.thread_panel.open(*note_id.bytes());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Other actions not yet supported in chat view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn timelines_view(
|
fn timelines_view(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
sizes: Size,
|
sizes: Size,
|
||||||
@@ -1115,9 +1144,13 @@ fn timelines_view(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check if a channel is selected
|
// Check if a channel is selected
|
||||||
let selected_channel = app.channels_cache.active_channels(ctx.accounts).selected_channel();
|
// Clone the timeline_kind to avoid borrowing app across the closure
|
||||||
|
let selected_timeline_kind = app.channels_cache
|
||||||
|
.active_channels(ctx.accounts)
|
||||||
|
.selected_channel()
|
||||||
|
.map(|c| c.timeline_kind.clone());
|
||||||
|
|
||||||
if let Some(channel) = selected_channel {
|
if let Some(timeline_kind) = selected_timeline_kind {
|
||||||
// Render ChatView for the selected channel
|
// Render ChatView for the selected channel
|
||||||
strip.cell(|ui| {
|
strip.cell(|ui| {
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
@@ -1140,7 +1173,7 @@ fn timelines_view(
|
|||||||
|
|
||||||
// Create a ChatView for the selected channel
|
// Create a ChatView for the selected channel
|
||||||
let mut chat_view = crate::ui::ChatView::new(
|
let mut chat_view = crate::ui::ChatView::new(
|
||||||
&channel.timeline_kind,
|
&timeline_kind,
|
||||||
&mut app.timeline_cache,
|
&mut app.timeline_cache,
|
||||||
&mut note_context,
|
&mut note_context,
|
||||||
notedeck_ui::NoteOptions::default(),
|
notedeck_ui::NoteOptions::default(),
|
||||||
@@ -1152,49 +1185,7 @@ fn timelines_view(
|
|||||||
|
|
||||||
// Handle chat view actions
|
// Handle chat view actions
|
||||||
if let Some(Some(action)) = chat_response.output {
|
if let Some(Some(action)) = chat_response.output {
|
||||||
match action {
|
process_chat_action(action, app, ctx, ui);
|
||||||
NoteAction::Note { note_id, .. } => {
|
|
||||||
// Open thread panel for viewing threads
|
|
||||||
app.thread_panel.open(*note_id.bytes());
|
|
||||||
}
|
|
||||||
NoteAction::Reply(note_id) => {
|
|
||||||
// Open thread panel for replying
|
|
||||||
app.thread_panel.open(*note_id.bytes());
|
|
||||||
}
|
|
||||||
NoteAction::React(react_action) => {
|
|
||||||
// Handle reaction (like) - send to relays
|
|
||||||
if let Some(filled) = ctx.accounts.selected_filled() {
|
|
||||||
let txn = Transaction::new(ctx.ndb).expect("txn for reaction");
|
|
||||||
|
|
||||||
// Send reaction event using existing infrastructure
|
|
||||||
if let Err(err) = crate::actionbar::send_reaction_event(
|
|
||||||
ctx.ndb,
|
|
||||||
&txn,
|
|
||||||
ctx.pool,
|
|
||||||
filled,
|
|
||||||
&react_action,
|
|
||||||
) {
|
|
||||||
error!("Failed to send reaction: {err}");
|
|
||||||
} else {
|
|
||||||
// Mark reaction as sent in UI
|
|
||||||
ui.ctx().data_mut(|d| {
|
|
||||||
use notedeck::note::reaction_sent_id;
|
|
||||||
d.insert_temp(
|
|
||||||
reaction_sent_id(&filled.pubkey, react_action.note_id.bytes()),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NoteAction::Repost(note_id) => {
|
|
||||||
// For now, open thread panel - could add repost dialog later
|
|
||||||
app.thread_panel.open(*note_id.bytes());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Other actions not yet supported in chat view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// vertical line
|
// vertical line
|
||||||
|
|||||||
@@ -144,7 +144,13 @@ impl ChannelList {
|
|||||||
timeline_cache: &mut TimelineCache,
|
timeline_cache: &mut TimelineCache,
|
||||||
ctx: &mut AppContext,
|
ctx: &mut AppContext,
|
||||||
) {
|
) {
|
||||||
let txn = Transaction::new(ctx.ndb).unwrap();
|
let txn = match Transaction::new(ctx.ndb) {
|
||||||
|
Ok(txn) => txn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to create transaction for channel subscription: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for channel in &mut self.channels {
|
for channel in &mut self.channels {
|
||||||
// Skip if already subscribed
|
// Skip if already subscribed
|
||||||
@@ -268,15 +274,19 @@ impl ChannelsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn fallback(&self) -> &ChannelList {
|
pub fn fallback(&self) -> &ChannelList {
|
||||||
|
// SAFETY: fallback_pubkey is always initialized in ChannelsCache::new()
|
||||||
|
// and the entry is created in the constructor, so this should never fail
|
||||||
self.account_to_channels
|
self.account_to_channels
|
||||||
.get(&self.fallback_pubkey)
|
.get(&self.fallback_pubkey)
|
||||||
.expect("fallback channel list not found")
|
.expect("fallback channel list not found - this is a bug in ChannelsCache initialization")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fallback_mut(&mut self) -> &mut ChannelList {
|
pub fn fallback_mut(&mut self) -> &mut ChannelList {
|
||||||
|
// SAFETY: fallback_pubkey is always initialized in ChannelsCache::new()
|
||||||
|
// and the entry is created in the constructor, so this should never fail
|
||||||
self.account_to_channels
|
self.account_to_channels
|
||||||
.get_mut(&self.fallback_pubkey)
|
.get_mut(&self.fallback_pubkey)
|
||||||
.expect("fallback channel list not found")
|
.expect("fallback channel list not found - this is a bug in ChannelsCache initialization")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_channel_for_account(
|
pub fn add_channel_for_account(
|
||||||
|
|||||||
@@ -71,9 +71,7 @@ impl ChannelDialog {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Auto-focus on name field when opened
|
// Auto-focus on name field when opened
|
||||||
if name_response.changed() {
|
name_response.request_focus();
|
||||||
name_response.request_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
|
|
||||||
@@ -95,7 +93,7 @@ impl ChannelDialog {
|
|||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
ui.add(
|
let hashtags_response = ui.add(
|
||||||
TextEdit::multiline(&mut self.hashtags)
|
TextEdit::multiline(&mut self.hashtags)
|
||||||
.hint_text(tr!(
|
.hint_text(tr!(
|
||||||
i18n,
|
i18n,
|
||||||
@@ -106,14 +104,36 @@ impl ChannelDialog {
|
|||||||
.desired_rows(3),
|
.desired_rows(3),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle Escape key to close dialog
|
||||||
|
ui.input(|i| {
|
||||||
|
if i.key_pressed(egui::Key::Escape) {
|
||||||
|
action = Some(ChannelDialogAction::Cancel);
|
||||||
|
}
|
||||||
|
// Handle Enter key when name is focused
|
||||||
|
if i.key_pressed(egui::Key::Enter) && !hashtags_response.has_focus() {
|
||||||
|
if !self.name.trim().is_empty() {
|
||||||
|
let hashtags: Vec<String> = self
|
||||||
|
.hashtags
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
action = Some(ChannelDialogAction::Create {
|
||||||
|
name: self.name.trim().to_string(),
|
||||||
|
hashtags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ui.add_space(24.0);
|
ui.add_space(24.0);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
// Create button
|
// Create button (hashtags are optional)
|
||||||
let create_enabled = !self.name.trim().is_empty()
|
let create_enabled = !self.name.trim().is_empty();
|
||||||
&& !self.hashtags.trim().is_empty();
|
|
||||||
|
|
||||||
let create_button = egui::Button::new(
|
let create_button = egui::Button::new(
|
||||||
RichText::new(tr!(i18n, "Create", "Button to create channel"))
|
RichText::new(tr!(i18n, "Create", "Button to create channel"))
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
use egui::{Color32, Key, Modifiers, RichText, ScrollArea, TextEdit, Vec2};
|
|
||||||
|
|
||||||
use notedeck::{tr, Accounts, Localization};
|
|
||||||
|
|
||||||
use crate::channels::ChannelsCache;
|
|
||||||
|
|
||||||
pub struct ChannelSwitcher {
|
|
||||||
pub is_open: bool,
|
|
||||||
pub search_query: String,
|
|
||||||
pub selected_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum ChannelSwitcherAction {
|
|
||||||
SelectChannel(usize),
|
|
||||||
Close,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelSwitcher {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
is_open: false,
|
|
||||||
search_query: String::new(),
|
|
||||||
selected_index: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(&mut self) {
|
|
||||||
self.is_open = true;
|
|
||||||
self.search_query.clear();
|
|
||||||
self.selected_index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self) {
|
|
||||||
self.is_open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show(
|
|
||||||
&mut self,
|
|
||||||
ctx: &egui::Context,
|
|
||||||
i18n: &mut Localization,
|
|
||||||
channels_cache: &ChannelsCache,
|
|
||||||
accounts: &Accounts,
|
|
||||||
) -> Option<ChannelSwitcherAction> {
|
|
||||||
if !self.is_open {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut action: Option<ChannelSwitcherAction> = None;
|
|
||||||
|
|
||||||
// Modal background
|
|
||||||
egui::Area::new(egui::Id::new("channel_switcher_overlay"))
|
|
||||||
.fixed_pos(egui::Pos2::ZERO)
|
|
||||||
.interactable(true)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
let screen_rect = ctx.screen_rect();
|
|
||||||
ui.allocate_ui_at_rect(screen_rect, |ui| {
|
|
||||||
// Dark overlay
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
screen_rect,
|
|
||||||
0.0,
|
|
||||||
Color32::from_black_alpha(180),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle click on overlay to close
|
|
||||||
if ui.interact(screen_rect, egui::Id::new("overlay"), egui::Sense::click()).clicked() {
|
|
||||||
action = Some(ChannelSwitcherAction::Close);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switcher window
|
|
||||||
egui::Window::new(tr!(i18n, "Quick Switcher", "Channel switcher dialog title"))
|
|
||||||
.collapsible(false)
|
|
||||||
.resizable(false)
|
|
||||||
.title_bar(false)
|
|
||||||
.anchor(egui::Align2::CENTER_TOP, Vec2::new(0.0, 100.0))
|
|
||||||
.fixed_size(Vec2::new(500.0, 400.0))
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.add_space(16.0);
|
|
||||||
|
|
||||||
// Search input
|
|
||||||
let search_response = ui.add(
|
|
||||||
TextEdit::singleline(&mut self.search_query)
|
|
||||||
.hint_text(tr!(
|
|
||||||
i18n,
|
|
||||||
"Search channels...",
|
|
||||||
"Placeholder for channel search"
|
|
||||||
))
|
|
||||||
.desired_width(f32::INFINITY)
|
|
||||||
.font(egui::TextStyle::Body),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-focus search field
|
|
||||||
search_response.request_focus();
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
ui.input(|i| {
|
|
||||||
if i.key_pressed(Key::Escape) {
|
|
||||||
action = Some(ChannelSwitcherAction::Close);
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.key_pressed(Key::ArrowDown) {
|
|
||||||
let channels = channels_cache.active_channels(accounts);
|
|
||||||
if self.selected_index < channels.num_channels().saturating_sub(1) {
|
|
||||||
self.selected_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.key_pressed(Key::ArrowUp) {
|
|
||||||
self.selected_index = self.selected_index.saturating_sub(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.key_pressed(Key::Enter) {
|
|
||||||
action = Some(ChannelSwitcherAction::SelectChannel(self.selected_index));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Channel list
|
|
||||||
ScrollArea::vertical()
|
|
||||||
.auto_shrink([false, false])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
let channels = channels_cache.active_channels(accounts);
|
|
||||||
let query_lower = self.search_query.to_lowercase();
|
|
||||||
|
|
||||||
let mut visible_idx = 0;
|
|
||||||
for (idx, channel) in channels.channels.iter().enumerate() {
|
|
||||||
// Filter by search query
|
|
||||||
if !query_lower.is_empty()
|
|
||||||
&& !channel.name.to_lowercase().contains(&query_lower)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_selected = visible_idx == self.selected_index;
|
|
||||||
let is_current = idx == channels.selected;
|
|
||||||
|
|
||||||
let mut frame = egui::Frame::new()
|
|
||||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
|
||||||
.corner_radius(4.0);
|
|
||||||
|
|
||||||
if is_selected {
|
|
||||||
frame = frame.fill(ui.visuals().selection.bg_fill);
|
|
||||||
} else if is_current {
|
|
||||||
frame = frame.fill(ui.visuals().faint_bg_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = frame.show(ui, |ui| {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
// Channel icon
|
|
||||||
ui.label(RichText::new("# ").size(16.0));
|
|
||||||
|
|
||||||
// Channel name
|
|
||||||
let mut text = RichText::new(&channel.name).size(14.0);
|
|
||||||
if is_selected {
|
|
||||||
text = text.strong();
|
|
||||||
}
|
|
||||||
ui.label(text);
|
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
// Show unread badge if any
|
|
||||||
if channel.unread_count > 0 {
|
|
||||||
let count_text = if channel.unread_count > 99 {
|
|
||||||
"99+".to_string()
|
|
||||||
} else {
|
|
||||||
channel.unread_count.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.label(
|
|
||||||
RichText::new(count_text)
|
|
||||||
.size(11.0)
|
|
||||||
.color(ui.visuals().strong_text_color()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle click on channel
|
|
||||||
let full_response = ui.interact(
|
|
||||||
response.response.rect,
|
|
||||||
egui::Id::new(("channel_item", idx)),
|
|
||||||
egui::Sense::click(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if full_response.clicked() {
|
|
||||||
action = Some(ChannelSwitcherAction::SelectChannel(idx));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selected index on hover
|
|
||||||
if full_response.hovered() {
|
|
||||||
self.selected_index = visible_idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
visible_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show empty state if no results
|
|
||||||
if visible_idx == 0 && !query_lower.is_empty() {
|
|
||||||
ui.add_space(20.0);
|
|
||||||
ui.vertical_centered(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new(tr!(
|
|
||||||
i18n,
|
|
||||||
"No channels found",
|
|
||||||
"Empty search results"
|
|
||||||
))
|
|
||||||
.size(14.0)
|
|
||||||
.color(ui.visuals().weak_text_color()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Help text
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = 8.0;
|
|
||||||
ui.label(
|
|
||||||
RichText::new(tr!(i18n, "↑↓ to navigate", "Keyboard shortcut hint"))
|
|
||||||
.size(11.0)
|
|
||||||
.color(ui.visuals().weak_text_color()),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new(tr!(i18n, "↵ to select", "Keyboard shortcut hint"))
|
|
||||||
.size(11.0)
|
|
||||||
.color(ui.visuals().weak_text_color()),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new(tr!(i18n, "esc to close", "Keyboard shortcut hint"))
|
|
||||||
.size(11.0)
|
|
||||||
.color(ui.visuals().weak_text_color()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close switcher if action was taken
|
|
||||||
if action.is_some() {
|
|
||||||
self.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
action
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ChannelSwitcher {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -281,7 +281,7 @@ impl<'a, 'd> ChatView<'a, 'd> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
let timestamp = format_timestamp(note.created_at());
|
let timestamp = format_timestamp(note.created_at(), self.note_context.i18n);
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(timestamp)
|
RichText::new(timestamp)
|
||||||
.size(12.0)
|
.size(12.0)
|
||||||
@@ -463,26 +463,33 @@ impl<'a, 'd> ChatView<'a, 'd> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format timestamp as relative time (e.g., "5m ago", "2h ago", "Yesterday")
|
/// Format timestamp as relative time (e.g., "5m ago", "2h ago", "Yesterday")
|
||||||
fn format_timestamp(created_at: u64) -> String {
|
fn format_timestamp(created_at: u64, i18n: &mut notedeck::Localization) -> String {
|
||||||
let now = std::time::SystemTime::now()
|
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
Ok(duration) => duration.as_secs(),
|
||||||
.unwrap()
|
Err(_) => {
|
||||||
.as_secs();
|
// System time is before Unix epoch - extremely rare but handle gracefully
|
||||||
|
return tr!(i18n, "Unknown time", "Fallback when system time is invalid").to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let diff = now.saturating_sub(created_at);
|
let diff = now.saturating_sub(created_at);
|
||||||
|
|
||||||
if diff < 60 {
|
if diff < 60 {
|
||||||
"Just now".to_string()
|
tr!(i18n, "Just now", "Time less than 1 minute ago").to_string()
|
||||||
} else if diff < 3600 {
|
} else if diff < 3600 {
|
||||||
format!("{}m ago", diff / 60)
|
let minutes = diff / 60;
|
||||||
|
format!("{minutes}m {}", tr!(i18n, "ago", "Time suffix"))
|
||||||
} else if diff < 86400 {
|
} else if diff < 86400 {
|
||||||
format!("{}h ago", diff / 3600)
|
let hours = diff / 3600;
|
||||||
|
format!("{hours}h {}", tr!(i18n, "ago", "Time suffix"))
|
||||||
} else if diff < 172800 {
|
} else if diff < 172800 {
|
||||||
"Yesterday".to_string()
|
tr!(i18n, "Yesterday", "One day ago").to_string()
|
||||||
} else if diff < 604800 {
|
} else if diff < 604800 {
|
||||||
format!("{}d ago", diff / 86400)
|
let days = diff / 86400;
|
||||||
|
format!("{days}d {}", tr!(i18n, "ago", "Time suffix"))
|
||||||
} else {
|
} else {
|
||||||
// Simple date format without chrono dependency
|
// Simple date format without chrono dependency
|
||||||
format!("{} days ago", diff / 86400)
|
let days = diff / 86400;
|
||||||
|
format!("{} {} {}", days, tr!(i18n, "days", "Plural days"), tr!(i18n, "ago", "Time suffix"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ pub mod accounts;
|
|||||||
pub mod add_column;
|
pub mod add_column;
|
||||||
pub mod channel_dialog;
|
pub mod channel_dialog;
|
||||||
pub mod channel_sidebar;
|
pub mod channel_sidebar;
|
||||||
pub mod channel_switcher;
|
|
||||||
pub mod chat_view;
|
pub mod chat_view;
|
||||||
pub mod column;
|
pub mod column;
|
||||||
pub mod configure_deck;
|
pub mod configure_deck;
|
||||||
@@ -31,7 +30,6 @@ pub mod widgets;
|
|||||||
pub use accounts::AccountsView;
|
pub use accounts::AccountsView;
|
||||||
pub use channel_dialog::{ChannelDialog, ChannelDialogAction};
|
pub use channel_dialog::{ChannelDialog, ChannelDialogAction};
|
||||||
pub use channel_sidebar::{ChannelSidebar, ChannelSidebarAction};
|
pub use channel_sidebar::{ChannelSidebar, ChannelSidebarAction};
|
||||||
pub use channel_switcher::{ChannelSwitcher, ChannelSwitcherAction};
|
|
||||||
pub use chat_view::ChatView;
|
pub use chat_view::ChatView;
|
||||||
pub use note::{PostReplyView, PostView};
|
pub use note::{PostReplyView, PostView};
|
||||||
pub use preview::{Preview, PreviewApp, PreviewConfig};
|
pub use preview::{Preview, PreviewApp, PreviewConfig};
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ impl ThreadPanel {
|
|||||||
|
|
||||||
// Thread content
|
// Thread content
|
||||||
if let Some(thread_id) = &self.selected_thread_id {
|
if let Some(thread_id) = &self.selected_thread_id {
|
||||||
|
// ThreadView will handle the case where thread doesn't exist
|
||||||
let thread_resp = ThreadView::new(
|
let thread_resp = ThreadView::new(
|
||||||
threads,
|
threads,
|
||||||
thread_id,
|
thread_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user