feat(tui): compose and read private messages
Add private messaging to the TUI: - 'm' opens the command bar in message mode; type '<recipient> <text>' and Enter sends it via the client; - incoming messages are polled each tick into an inbox; - 'i' toggles a centered inbox popup listing sent/received messages. The shared command bar now carries a mode (search vs message) so the prefix, submit action and shortcut hints follow what is being typed.
This commit is contained in:
@@ -38,7 +38,7 @@ experience. For me, this is a good balance between learning and practicality.
|
||||
- [ ] Share files
|
||||
- [ ] Browse user(s) files
|
||||
- [ ] Chat in chatrooms
|
||||
- [ ] Private messaging
|
||||
- [x] Private messaging
|
||||
- [ ] Headless mode daemon mode with remote control
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -172,7 +172,9 @@ fn send_private_message(
|
||||
|
||||
client
|
||||
.send_private_message(recipient, message)
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to send message: {}", e))?;
|
||||
.map_err(|e| {
|
||||
color_eyre::eyre::eyre!("Failed to send message: {}", e)
|
||||
})?;
|
||||
|
||||
// The send is dispatched asynchronously; give the server actor a moment to
|
||||
// flush it to the socket before we drop the client and exit.
|
||||
|
||||
@@ -32,6 +32,29 @@ pub enum FocusedPane {
|
||||
Downloads,
|
||||
}
|
||||
|
||||
/// What the shared command bar is currently capturing input for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandBarMode {
|
||||
Search,
|
||||
Message,
|
||||
}
|
||||
|
||||
/// Direction of a private message relative to the local user.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageDirection {
|
||||
Incoming,
|
||||
Outgoing,
|
||||
}
|
||||
|
||||
/// A private message shown in the inbox popup.
|
||||
pub struct ChatMessage {
|
||||
pub direction: MessageDirection,
|
||||
/// The other party: the sender for incoming, the recipient for outgoing.
|
||||
pub peer: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct AppState {
|
||||
// Searches
|
||||
pub searches: Vec<SearchEntry>,
|
||||
@@ -62,6 +85,11 @@ pub struct AppState {
|
||||
pub command_bar_active: bool,
|
||||
pub command_bar_input: String,
|
||||
pub command_bar_cursor_position: usize,
|
||||
pub command_bar_mode: CommandBarMode,
|
||||
|
||||
// Private messages
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub show_messages: bool,
|
||||
|
||||
// Pane areas for mouse interaction
|
||||
pub searches_pane_area: Option<Rect>,
|
||||
@@ -104,6 +132,10 @@ impl AppState {
|
||||
command_bar_active: false,
|
||||
command_bar_input: String::new(),
|
||||
command_bar_cursor_position: 0,
|
||||
command_bar_mode: CommandBarMode::Search,
|
||||
|
||||
messages: Vec::new(),
|
||||
show_messages: false,
|
||||
|
||||
searches_pane_area: None,
|
||||
results_pane_area: None,
|
||||
|
||||
@@ -2,6 +2,7 @@ mod app_state;
|
||||
mod file_display_data;
|
||||
|
||||
pub use app_state::{
|
||||
AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus,
|
||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||
MessageDirection, SearchEntry, SearchStatus,
|
||||
};
|
||||
pub use file_display_data::FileDisplayData;
|
||||
|
||||
+151
-15
@@ -1,6 +1,6 @@
|
||||
use crate::models::{
|
||||
AppState, DownloadEntry, FileDisplayData, FocusedPane, SearchEntry,
|
||||
SearchStatus,
|
||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData,
|
||||
FocusedPane, MessageDirection, SearchEntry, SearchStatus,
|
||||
};
|
||||
use crate::ui::panes::{
|
||||
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
|
||||
@@ -31,6 +31,14 @@ use std::{
|
||||
};
|
||||
|
||||
const COMMAND_BAR_PREFIX: &str = "search: ";
|
||||
const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): ";
|
||||
|
||||
const fn command_bar_prefix(mode: CommandBarMode) -> &'static str {
|
||||
match mode {
|
||||
CommandBarMode::Search => COMMAND_BAR_PREFIX,
|
||||
CommandBarMode::Message => MESSAGE_BAR_PREFIX,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MainTui {
|
||||
client: Arc<Client>,
|
||||
@@ -86,6 +94,9 @@ impl MainTui {
|
||||
// Poll for download updates
|
||||
self.update_downloads();
|
||||
|
||||
// Poll for incoming private messages
|
||||
self.poll_private_messages();
|
||||
|
||||
// Update spinner
|
||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||
|
||||
@@ -233,26 +244,76 @@ impl MainTui {
|
||||
} else {
|
||||
self.render_shortcuts(frame, main_chunks[2]);
|
||||
}
|
||||
|
||||
// Messages inbox overlays everything when open.
|
||||
if self.state.show_messages {
|
||||
self.render_messages_popup(frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_messages_popup(&self, frame: &mut Frame) {
|
||||
let area = centered_rect(70, 60, frame.area());
|
||||
|
||||
let lines: Vec<ratatui::text::Line> = if self.state.messages.is_empty()
|
||||
{
|
||||
vec![ratatui::text::Line::from(
|
||||
"No messages yet. Press 'm' to send one.",
|
||||
)]
|
||||
} else {
|
||||
self.state
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (arrow, who) = match m.direction {
|
||||
MessageDirection::Incoming => ("⇦ from", &m.peer),
|
||||
MessageDirection::Outgoing => ("⇨ to ", &m.peer),
|
||||
};
|
||||
ratatui::text::Line::from(format!(
|
||||
"{arrow} {who}: {}",
|
||||
m.text
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let popup = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
.border_type(border_type(true))
|
||||
.title(" Messages (m: compose, i/Esc: close) "),
|
||||
);
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
frame.render_widget(popup, area);
|
||||
}
|
||||
|
||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
||||
let shortcuts = if self.state.command_bar_active {
|
||||
vec![
|
||||
("Type", "search term"),
|
||||
("←→", "move cursor"),
|
||||
("Backspace/Del", "edit"),
|
||||
("Enter", "search"),
|
||||
("Esc", "cancel"),
|
||||
]
|
||||
match self.state.command_bar_mode {
|
||||
CommandBarMode::Search => vec![
|
||||
("Type", "search term"),
|
||||
("←→", "move cursor"),
|
||||
("Backspace/Del", "edit"),
|
||||
("Enter", "search"),
|
||||
("Esc", "cancel"),
|
||||
],
|
||||
CommandBarMode::Message => vec![
|
||||
("Type", "recipient then message"),
|
||||
("Enter", "send"),
|
||||
("Esc", "cancel"),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
match self.state.focused_pane {
|
||||
FocusedPane::Searches => vec![
|
||||
("s", "search"),
|
||||
("m", "message"),
|
||||
("i", "inbox"),
|
||||
("1-3", "focus pane"),
|
||||
("↑↓", "navigate"),
|
||||
("Enter", "view results"),
|
||||
("d", "remove search"),
|
||||
("C", "clear all"),
|
||||
("q", "quit"),
|
||||
],
|
||||
FocusedPane::Results if self.state.results_is_filtering => {
|
||||
@@ -296,15 +357,16 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let prefix = command_bar_prefix(self.state.command_bar_mode);
|
||||
let content_width = area.width.saturating_sub(2);
|
||||
let prefix_width = COMMAND_BAR_PREFIX.chars().count() as u16;
|
||||
let prefix_width = prefix.chars().count() as u16;
|
||||
let input_width = content_width.saturating_sub(prefix_width);
|
||||
let (visible_input, cursor_column) = visible_input_at_cursor(
|
||||
&self.state.command_bar_input,
|
||||
self.state.command_bar_cursor_position,
|
||||
input_width,
|
||||
);
|
||||
let command_text = format!("{COMMAND_BAR_PREFIX}{visible_input}");
|
||||
let command_text = format!("{prefix}{visible_input}");
|
||||
|
||||
let paragraph = Paragraph::new(command_text).block(
|
||||
Block::default()
|
||||
@@ -332,6 +394,14 @@ impl MainTui {
|
||||
return self.handle_command_bar_input(key);
|
||||
}
|
||||
|
||||
// Messages popup: any of i/Esc/q closes it.
|
||||
if self.state.show_messages {
|
||||
if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) {
|
||||
self.state.show_messages = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter mode in Results pane
|
||||
if self.state.results_is_filtering
|
||||
&& self.state.focused_pane == FocusedPane::Results
|
||||
@@ -359,10 +429,22 @@ impl MainTui {
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.state.command_bar_active = true;
|
||||
self.state.command_bar_mode = CommandBarMode::Search;
|
||||
self.state.command_bar_input.clear();
|
||||
self.state.command_bar_cursor_position = 0;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
self.state.command_bar_active = true;
|
||||
self.state.command_bar_mode = CommandBarMode::Message;
|
||||
self.state.command_bar_input.clear();
|
||||
self.state.command_bar_cursor_position = 0;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
self.state.show_messages = true;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -382,9 +464,14 @@ impl MainTui {
|
||||
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
let query = self.state.command_bar_input.trim().to_string();
|
||||
if !query.is_empty() {
|
||||
self.start_search(query);
|
||||
let input = self.state.command_bar_input.trim().to_string();
|
||||
if !input.is_empty() {
|
||||
match self.state.command_bar_mode {
|
||||
CommandBarMode::Search => self.start_search(input),
|
||||
CommandBarMode::Message => {
|
||||
self.send_message_from_input(&input);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.command_bar_active = false;
|
||||
self.state.command_bar_input.clear();
|
||||
@@ -840,6 +927,39 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any private messages received since the last tick into the inbox.
|
||||
fn poll_private_messages(&mut self) {
|
||||
for msg in self.client.take_private_messages() {
|
||||
self.state.messages.push(ChatMessage {
|
||||
direction: MessageDirection::Incoming,
|
||||
peer: msg.username().to_string(),
|
||||
text: msg.message().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `<recipient> <text>` compose line and send it.
|
||||
fn send_message_from_input(&mut self, input: &str) {
|
||||
let Some((recipient, text)) = input.split_once(char::is_whitespace)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let recipient = recipient.trim();
|
||||
let text = text.trim();
|
||||
if recipient.is_empty() || text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.client.send_private_message(recipient, text) {
|
||||
Ok(()) => self.state.messages.push(ChatMessage {
|
||||
direction: MessageDirection::Outgoing,
|
||||
peer: recipient.to_string(),
|
||||
text: text.to_string(),
|
||||
}),
|
||||
Err(e) => eprintln!("Failed to send message: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_search(&mut self, query: String) {
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let search_entry = SearchEntry {
|
||||
@@ -1053,6 +1173,22 @@ pub fn launch_main_tui(
|
||||
tui.run(terminal)
|
||||
}
|
||||
|
||||
/// A `Rect` centered within `area`, sized to the given percentages.
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::vertical([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(area);
|
||||
Layout::horizontal([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
const fn clamp_cursor_to_char_boundary(
|
||||
input: &str,
|
||||
cursor_position: usize,
|
||||
|
||||
Reference in New Issue
Block a user