use chrono::{DateTime, Local, Utc};
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Instant;
use crate::db::Database;
use crate::image_render;
use crate::image_render::ImageProtocol;
use crate::input::{self, InputAction, COMMANDS};
use crate::keybindings::{self, BindingMode, KeyAction, KeyBindings};
use crate::theme::{self, Theme};
use crate::signal::types::{Contact, Group, IdentityInfo, LinkPreview, Mention, MessageStatus, PollData, PollOption, PollVote, Reaction, SignalEvent, SignalMessage, StyleType, TextStyle, TrustLevel};
fn next_char_pos(buf: &str, pos: usize) -> usize {
if pos >= buf.len() { return buf.len(); }
pos + buf[pos..].chars().next().map_or(1, |c| c.len_utf8())
}
fn prev_char_pos(buf: &str, pos: usize) -> usize {
if pos == 0 { return 0; }
pos - buf[..pos].chars().next_back().map_or(1, |c| c.len_utf8())
}
fn floor_char_boundary(buf: &str, pos: usize) -> usize {
let pos = pos.min(buf.len());
if buf.is_char_boundary(pos) { return pos; }
let mut p = pos;
while p > 0 && !buf.is_char_boundary(p) { p -= 1; }
p
}
fn db_warn<T>(result: Result<T, impl std::fmt::Display>, context: &str) {
if let Err(e) = result {
crate::debug_log::logf(format_args!("db {context}: {e}"));
}
}
impl App {
fn db_warn_visible<T>(&mut self, result: Result<T, impl std::fmt::Display>, context: &str) {
if let Err(e) = result {
crate::debug_log::logf(format_args!("db {context}: {e}"));
self.status_message = format!("DB error ({context}): {e}");
}
}
}
fn show_desktop_notification(sender: &str, body: &str, is_group: bool, group_name: Option<&str>, preview_level: &str) {
let (title, preview) = match preview_level {
"minimal" => ("New message".to_string(), String::new()),
"sender" => {
let t = if is_group {
match group_name {
Some(gn) => format!("{} — {}", gn, sender),
None => sender.to_string(),
}
} else {
sender.to_string()
};
(t, "New message".to_string())
}
_ => {
let t = if is_group {
match group_name {
Some(gn) => format!("{} — {}", gn, sender),
None => sender.to_string(),
}
} else {
sender.to_string()
};
(t, body.chars().take(100).collect())
}
};
tokio::task::spawn_blocking(move || {
let _ = notify_rust::Notification::new()
.summary(&title)
.body(&preview)
.timeout(notify_rust::Timeout::Milliseconds(5000))
.show();
});
}
#[derive(PartialEq, Eq)]
pub struct VisibleImage {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
pub full_height: u16,
pub crop_top: u16,
pub path: String,
}
pub struct ImageRenderResult {
pub conv_id: String,
pub timestamp_ms: i64,
pub is_preview: bool,
pub lines: Option<Vec<Line<'static>>>,
pub image_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Insert,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteMode {
Command,
Mention,
Join,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupMenuState {
Menu, Members, AddMember, RemoveMember, Rename, Create, LeaveConfirm, }
pub struct MenuAction {
pub label: &'static str,
pub key_hint: &'static str,
pub nerd_icon: &'static str,
}
#[derive(Debug, Clone)]
pub struct Quote {
pub author: String,
pub body: String,
pub timestamp_ms: i64,
pub author_id: String,
}
pub struct PinPending {
pub conv_id: String,
pub is_group: bool,
pub target_author: String,
pub target_timestamp: i64,
}
pub struct PollVotePending {
pub conv_id: String,
pub is_group: bool,
pub poll_author: String,
pub poll_timestamp: i64,
pub allow_multiple: bool,
pub options: Vec<PollOption>,
}
#[derive(Debug, Clone)]
pub struct DisplayMessage {
pub sender: String,
pub timestamp: DateTime<Utc>,
pub body: String,
pub is_system: bool,
pub image_lines: Option<Vec<Line<'static>>>,
pub image_path: Option<String>,
pub status: Option<MessageStatus>,
pub timestamp_ms: i64,
pub reactions: Vec<Reaction>,
pub mention_ranges: Vec<(usize, usize)>,
pub style_ranges: Vec<(usize, usize, StyleType)>,
pub quote: Option<Quote>,
pub is_edited: bool,
pub is_deleted: bool,
pub is_pinned: bool,
pub sender_id: String,
pub expires_in_seconds: i64,
pub expiration_start_ms: i64,
pub poll_data: Option<PollData>,
pub poll_votes: Vec<PollVote>,
pub preview: Option<LinkPreview>,
pub preview_image_lines: Option<Vec<Line<'static>>>,
pub preview_image_path: Option<String>,
}
impl DisplayMessage {
pub fn format_time(&self) -> String {
let local: DateTime<Local> = self.timestamp.with_timezone(&Local);
local.format("%H:%M").to_string()
}
}
#[derive(Debug, Clone)]
pub struct Conversation {
pub name: String,
pub id: String,
pub messages: Vec<DisplayMessage>,
pub unread: usize,
pub is_group: bool,
pub expiration_timer: i64,
pub accepted: bool,
}
impl Conversation {
fn find_msg_idx(&self, ts: i64) -> Option<usize> {
let end = self.messages.partition_point(|m| m.timestamp_ms <= ts);
if end > 0 && self.messages[end - 1].timestamp_ms == ts {
Some(end - 1)
} else {
None
}
}
}
pub struct App {
pub conversations: HashMap<String, Conversation>,
pub conversation_order: Vec<String>,
pub active_conversation: Option<String>,
pub input_buffer: String,
pub input_cursor: usize,
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub history_draft: String,
pub sidebar_visible: bool,
pub scroll_offset: usize,
pub scroll_positions: HashMap<String, (usize, Option<usize>)>,
pub status_message: String,
pub should_quit: bool,
pub quit_confirm: bool,
#[allow(dead_code)]
pub account: String,
pub sidebar_width: u16,
pub sidebar_on_right: bool,
pub typing_indicators: HashMap<String, (String, Instant)>,
pub last_read_index: HashMap<String, usize>,
pub connected: bool,
pub loading: bool,
pub startup_status: String,
pub spinner_tick: usize,
pub mode: InputMode,
pub db: Database,
pub connection_error: Option<String>,
pub contact_names: HashMap<String, String>,
pub pending_bell: bool,
pub notify_direct: bool,
pub notify_group: bool,
pub desktop_notifications: bool,
pub notification_preview: String,
pub clipboard_clear_seconds: u64,
pub clipboard_set_at: Option<std::time::Instant>,
pub muted_conversations: HashSet<String>,
pub blocked_conversations: HashSet<String>,
pub autocomplete_visible: bool,
pub autocomplete_candidates: Vec<usize>,
pub autocomplete_index: usize,
pub show_settings: bool,
pub settings_index: usize,
pub show_help: bool,
pub show_contacts: bool,
pub contacts_index: usize,
pub contacts_filter: String,
pub contacts_filtered: Vec<(String, String)>,
pub show_verify: bool,
pub verify_index: usize,
pub verify_identities: Vec<IdentityInfo>,
pub identity_trust: HashMap<String, TrustLevel>,
pub verify_confirming: bool,
pub inline_images: bool,
pub show_link_previews: bool,
pub link_regions: Vec<crate::ui::LinkRegion>,
pub link_url_map: HashMap<String, String>,
pub image_protocol: ImageProtocol,
pub visible_images: Vec<VisibleImage>,
pub prev_visible_images: Vec<VisibleImage>,
pub native_images: bool,
pub native_image_cache: HashMap<String, (String, u32, u32)>,
pub prev_active_conversation: Option<String>,
pub incognito: bool,
pub has_more_messages: HashSet<String>,
pub at_scroll_top: bool,
pub date_separators: bool,
pub show_receipts: bool,
pub color_receipts: bool,
pub nerd_fonts: bool,
pub pending_sends: HashMap<String, (String, i64)>,
pub pending_receipts: Vec<(String, String, Vec<i64>)>,
pub focused_message_time: Option<DateTime<Utc>>,
pub focused_msg_index: Option<usize>,
pub show_reaction_picker: bool,
pub reaction_picker_index: usize,
pub reaction_verbose: bool,
pub groups: HashMap<String, Group>,
pub uuid_to_name: HashMap<String, String>,
pub number_to_uuid: HashMap<String, String>,
pub autocomplete_mode: AutocompleteMode,
pub mention_candidates: Vec<(String, String, Option<String>)>,
pub join_candidates: Vec<(String, String)>,
pub mention_trigger_pos: usize,
pub pending_mentions: Vec<(String, Option<String>)>,
pub is_demo: bool,
pub show_file_browser: bool,
pub file_browser_dir: PathBuf,
pub file_browser_entries: Vec<(String, bool, u64)>,
pub file_browser_index: usize,
pub file_browser_filter: String,
pub file_browser_filtered: Vec<usize>,
pub file_browser_error: Option<String>,
pub pending_attachment: Option<PathBuf>,
pub paste_temp_path: PathBuf,
pub reply_target: Option<(String, String, i64)>,
pub show_delete_confirm: bool,
pub editing_message: Option<(i64, String)>,
pub show_search: bool,
pub search_query: String,
pub search_results: Vec<SearchResult>,
pub search_index: usize,
pub typing_sent: bool,
pub typing_last_keypress: Option<Instant>,
pub pending_typing_stop: Option<SendRequest>,
pub send_read_receipts: bool,
pub pending_read_receipts: Vec<(String, Vec<i64>)>,
pub show_action_menu: bool,
pub action_menu_index: usize,
pub show_forward: bool,
pub forward_index: usize,
pub forward_filter: String,
pub forward_filtered: Vec<(String, String)>,
pub forward_body: String,
pub group_menu_state: Option<GroupMenuState>,
pub group_menu_index: usize,
pub group_menu_filter: String,
pub group_menu_filtered: Vec<(String, String)>,
pub group_menu_input: String,
pub show_message_request: bool,
pub mouse_sidebar_inner: Option<Rect>,
pub mouse_messages_area: Rect,
pub mouse_input_area: Rect,
pub mouse_input_prefix_len: u16,
pub mouse_enabled: bool,
pub pending_mouse_toggle: Option<bool>,
pub theme: Theme,
pub show_theme_picker: bool,
pub theme_index: usize,
pub available_themes: Vec<Theme>,
pub keybindings: KeyBindings,
pub show_keybindings: bool,
pub keybindings_index: usize,
pub keybindings_capturing: bool,
pub keybindings_conflict: Option<(KeyAction, keybindings::KeyCombo)>,
pub keybindings_profile_picker: bool,
pub keybindings_profile_index: usize,
pub available_kb_profiles: Vec<String>,
pub show_pin_duration: bool,
pub pin_duration_index: usize,
pub pin_pending: Option<PinPending>,
pub show_poll_vote: bool,
pub poll_vote_index: usize,
pub poll_vote_selections: Vec<bool>,
pub poll_vote_pending: Option<PollVotePending>,
pub pending_polls: HashMap<(String, i64), PollData>,
pub expiring_msg_count: usize,
pub show_about: bool,
pub show_profile: bool,
pub profile_index: usize,
pub profile_editing: bool,
pub profile_fields: [String; 4],
pub profile_edit_buffer: String,
pub next_kitty_image_id: u32,
pub kitty_image_ids: HashMap<String, u32>,
pub kitty_transmitted: HashSet<u32>,
pub kitty_pending_transmits: Vec<(u32, String, u16, u16)>,
pub iterm2_crop_cache: HashMap<(String, u16, u16), String>,
pub settings_profile_name: String,
pub show_settings_profile_manager: bool,
pub settings_profile_manager_index: usize,
pub available_settings_profiles: Vec<crate::settings_profile::SettingsProfile>,
pub settings_profile_save_as: bool,
pub settings_profile_save_as_input: String,
pub settings_mouse_snapshot: bool,
pub image_render_tx: mpsc::Sender<ImageRenderResult>,
pub image_render_rx: mpsc::Receiver<ImageRenderResult>,
pub image_render_in_flight: HashSet<(String, i64, bool)>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub sender: String,
pub body: String,
pub timestamp_ms: i64,
pub conv_id: String,
pub conv_name: String,
}
pub const QUICK_REACTIONS: &[&str] = &["\u{1f44d}", "\u{1f44e}", "\u{2764}\u{fe0f}", "\u{1f602}", "\u{1f62e}", "\u{1f622}", "\u{1f64f}", "\u{1f525}"];
pub const PIN_DURATIONS: &[(i64, &str)] = &[
(-1, "Forever"),
(86400, "24 hours"),
(604800, "7 days"),
(2592000, "30 days"),
];
pub enum SendRequest {
Message {
recipient: String,
body: String,
is_group: bool,
local_ts_ms: i64,
mentions: Vec<(usize, String)>,
attachment: Option<PathBuf>,
quote_timestamp: Option<i64>,
quote_author: Option<String>,
quote_body: Option<String>,
},
Reaction {
conv_id: String,
emoji: String,
is_group: bool,
target_author: String,
target_timestamp: i64,
remove: bool,
},
Edit {
recipient: String,
body: String,
is_group: bool,
edit_timestamp: i64,
local_ts_ms: i64,
mentions: Vec<(usize, String)>,
quote_timestamp: Option<i64>,
quote_author: Option<String>,
quote_body: Option<String>,
},
RemoteDelete {
recipient: String,
is_group: bool,
target_timestamp: i64,
},
Typing {
recipient: String,
is_group: bool,
stop: bool,
},
ReadReceipt {
recipient: String,
timestamps: Vec<i64>,
},
UpdateExpiration {
conv_id: String,
is_group: bool,
seconds: i64,
},
CreateGroup {
name: String,
},
AddGroupMembers {
group_id: String,
members: Vec<String>,
},
RemoveGroupMembers {
group_id: String,
members: Vec<String>,
},
RenameGroup {
group_id: String,
name: String,
},
LeaveGroup {
group_id: String,
},
MessageRequestResponse {
recipient: String,
is_group: bool,
response_type: String,
},
Block {
recipient: String,
is_group: bool,
},
Unblock {
recipient: String,
is_group: bool,
},
Pin {
recipient: String,
is_group: bool,
target_author: String,
target_timestamp: i64,
pin_duration: i64,
},
Unpin {
recipient: String,
is_group: bool,
target_author: String,
target_timestamp: i64,
},
PollCreate {
recipient: String,
is_group: bool,
question: String,
options: Vec<String>,
allow_multiple: bool,
local_ts_ms: i64,
},
PollVote {
recipient: String,
is_group: bool,
poll_author: String,
poll_timestamp: i64,
option_indexes: Vec<i64>,
vote_count: i64,
},
PollTerminate {
recipient: String,
is_group: bool,
poll_timestamp: i64,
},
ListIdentities,
TrustIdentity {
recipient: String,
safety_number: String,
},
UpdateProfile {
given_name: String,
family_name: String,
about: String,
about_emoji: String,
},
}
pub struct SettingDef {
pub label: &'static str,
pub hint: &'static str,
get: fn(&App) -> bool,
set: fn(&mut App, bool),
save: Option<fn(&mut crate::config::Config, bool)>,
on_toggle: Option<fn(&mut App)>,
}
pub const SETTINGS: &[SettingDef] = &[
SettingDef {
label: "Direct message notifications",
hint: "Play a sound for incoming direct messages",
get: |a| a.notify_direct,
set: |a, v| a.notify_direct = v,
save: Some(|c, v| c.notify_direct = v),
on_toggle: None,
},
SettingDef {
label: "Group message notifications",
hint: "Play a sound for incoming group messages",
get: |a| a.notify_group,
set: |a, v| a.notify_group = v,
save: Some(|c, v| c.notify_group = v),
on_toggle: None,
},
SettingDef {
label: "Desktop notifications",
hint: "Show system notifications for new messages",
get: |a| a.desktop_notifications,
set: |a, v| a.desktop_notifications = v,
save: Some(|c, v| c.desktop_notifications = v),
on_toggle: None,
},
SettingDef {
label: "Sidebar visible",
hint: "Show the conversation list sidebar",
get: |a| a.sidebar_visible,
set: |a, v| a.sidebar_visible = v,
save: None, on_toggle: None,
},
SettingDef {
label: "Inline image previews",
hint: "Render image attachments as previews in chat",
get: |a| a.inline_images,
set: |a, v| a.inline_images = v,
save: Some(|c, v| c.inline_images = v),
on_toggle: None, },
SettingDef {
label: "Link previews",
hint: "Show title and thumbnail for URLs",
get: |a| a.show_link_previews,
set: |a, v| a.show_link_previews = v,
save: Some(|c, v| c.show_link_previews = v),
on_toggle: None, },
SettingDef {
label: "Native images (experimental)",
hint: "Requires Kitty, Ghostty, WezTerm, or iTerm2",
get: |a| a.native_images,
set: |a, v| a.native_images = v,
save: Some(|c, v| c.native_images = v),
on_toggle: None,
},
SettingDef {
label: "Date separators",
hint: "Show date lines between messages from different days",
get: |a| a.date_separators,
set: |a, v| a.date_separators = v,
save: Some(|c, v| c.date_separators = v),
on_toggle: None,
},
SettingDef {
label: "Read receipts",
hint: "Show delivery and read status on messages",
get: |a| a.show_receipts,
set: |a, v| a.show_receipts = v,
save: Some(|c, v| c.show_receipts = v),
on_toggle: None,
},
SettingDef {
label: "Receipt colors",
hint: "Colorize receipt indicators",
get: |a| a.color_receipts,
set: |a, v| a.color_receipts = v,
save: Some(|c, v| c.color_receipts = v),
on_toggle: None,
},
SettingDef {
label: "Nerd Font icons",
hint: "Use Nerd Font glyphs (requires a Nerd Font)",
get: |a| a.nerd_fonts,
set: |a, v| a.nerd_fonts = v,
save: Some(|c, v| c.nerd_fonts = v),
on_toggle: None,
},
SettingDef {
label: "Verbose reactions",
hint: "Show names instead of just emoji counts",
get: |a| a.reaction_verbose,
set: |a, v| a.reaction_verbose = v,
save: Some(|c, v| c.reaction_verbose = v),
on_toggle: None,
},
SettingDef {
label: "Send read receipts",
hint: "Let contacts know when you read messages",
get: |a| a.send_read_receipts,
set: |a, v| a.send_read_receipts = v,
save: Some(|c, v| c.send_read_receipts = v),
on_toggle: None,
},
SettingDef {
label: "Mouse support",
hint: "Enable mouse click and scroll support",
get: |a| a.mouse_enabled,
set: |a, v| a.mouse_enabled = v,
save: Some(|c, v| c.mouse_enabled = v),
on_toggle: Some(|a| { a.pending_mouse_toggle = Some(a.mouse_enabled); }),
},
SettingDef {
label: "Sidebar on right",
hint: "Move the sidebar to the right side",
get: |a| a.sidebar_on_right,
set: |a, v| a.sidebar_on_right = v,
save: Some(|c, v| c.sidebar_on_right = v),
on_toggle: None,
},
];
impl App {
pub fn toggle_setting(&mut self, index: usize) {
if let Some(def) = SETTINGS.get(index) {
let cur = (def.get)(self);
(def.set)(self, !cur);
if let Some(hook) = def.on_toggle {
hook(self);
}
}
}
pub fn setting_value(&self, index: usize) -> bool {
SETTINGS.get(index).is_some_and(|def| (def.get)(self))
}
fn save_settings(&self) {
if self.is_demo {
return;
}
let mut config = crate::config::Config::load(None).unwrap_or_default();
config.account = self.account.clone();
config.theme = self.theme.name.clone();
config.keybinding_profile = self.keybindings.profile_name.clone();
config.settings_profile = self.settings_profile_name.clone();
config.notification_preview = self.notification_preview.clone();
for def in SETTINGS {
if let Some(save_fn) = def.save {
save_fn(&mut config, (def.get)(self));
}
}
if let Err(e) = config.save() {
crate::debug_log::logf(format_args!("settings save error: {e}"));
}
let overrides = self.keybindings.diff_from_profile();
keybindings::save_overrides(&overrides);
}
pub fn ensure_active_images(&mut self) -> bool {
let mut drained = false;
while let Ok(result) = self.image_render_rx.try_recv() {
self.image_render_in_flight.remove(&(
result.conv_id.clone(),
result.timestamp_ms,
result.is_preview,
));
if let Some(conv) = self.conversations.get_mut(&result.conv_id) {
if let Some(idx) = conv.find_msg_idx(result.timestamp_ms) {
if result.is_preview {
conv.messages[idx].preview_image_lines =
Some(result.lines.unwrap_or_default());
if let Some(p) = result.image_path {
conv.messages[idx].preview_image_path = Some(p);
}
} else {
conv.messages[idx].image_lines =
Some(result.lines.unwrap_or_default());
}
drained = true;
}
}
}
if !self.inline_images {
return drained;
}
let Some(ref id) = self.active_conversation else { return drained };
let id = id.clone();
let Some(conv) = self.conversations.get(&id) else { return drained };
let len = conv.messages.len();
if len == 0 {
return drained;
}
let end = len.saturating_sub(self.scroll_offset.saturating_sub(5)).min(len);
let start = end.saturating_sub(60);
let mut work: Vec<(i64, String, u32, bool)> = Vec::new();
for msg in &conv.messages[start..end] {
if self.image_render_in_flight.len() + work.len() >= 4 {
break;
}
if msg.body.starts_with("[image:") && msg.image_lines.is_none() {
if let Some(ref p) = msg.image_path {
let key = (id.clone(), msg.timestamp_ms, false);
if !self.image_render_in_flight.contains(&key) {
work.push((msg.timestamp_ms, p.clone(), 40, false));
}
}
}
if self.show_link_previews && msg.preview_image_lines.is_none() {
if let Some(ref preview) = msg.preview {
if let Some(ref p) = preview.image_path {
let key = (id.clone(), msg.timestamp_ms, true);
if !self.image_render_in_flight.contains(&key) {
work.push((msg.timestamp_ms, p.clone(), 30, true));
}
}
}
}
}
for (ts, path, max_width, is_preview) in work {
self.image_render_in_flight
.insert((id.clone(), ts, is_preview));
let tx = self.image_render_tx.clone();
let cid = id.clone();
tokio::task::spawn_blocking(move || {
let lines = image_render::render_image(Path::new(&path), max_width);
let _ = tx.send(ImageRenderResult {
conv_id: cid,
timestamp_ms: ts,
is_preview,
lines,
image_path: if is_preview { Some(path) } else { None },
});
});
}
drained
}
pub fn handle_settings_key(&mut self, code: KeyCode) {
let preview_index = SETTINGS.len();
let theme_index = SETTINGS.len() + 1;
let kb_index = SETTINGS.len() + 2;
let profile_index = SETTINGS.len() + 3;
let max_index = profile_index;
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.settings_index < max_index {
self.settings_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.settings_index = self.settings_index.saturating_sub(1);
}
KeyCode::Char('h') | KeyCode::Left if self.settings_index == profile_index => {
self.cycle_settings_profile(false);
}
KeyCode::Char('l') | KeyCode::Right if self.settings_index == profile_index => {
self.cycle_settings_profile(true);
}
KeyCode::Char(' ') | KeyCode::Enter | KeyCode::Tab => {
if self.settings_index == preview_index {
self.notification_preview = match self.notification_preview.as_str() {
"full" => "sender".to_string(),
"sender" => "minimal".to_string(),
_ => "full".to_string(),
};
} else if self.settings_index == theme_index {
self.show_settings = false;
self.save_settings();
self.show_theme_picker = true;
self.theme_index = self.available_themes.iter()
.position(|t| t.name == self.theme.name)
.unwrap_or(0);
} else if self.settings_index == kb_index {
self.show_settings = false;
self.save_settings();
self.show_keybindings = true;
self.keybindings_index = 0;
} else if self.settings_index == profile_index {
self.show_settings = false;
self.save_settings();
self.open_settings_profile_manager();
} else {
self.toggle_setting(self.settings_index);
}
}
KeyCode::Esc | KeyCode::Char('q') => {
self.show_settings = false;
self.save_settings();
self.fire_deferred_settings_hooks();
}
_ => {}
}
}
fn cycle_settings_profile(&mut self, forward: bool) {
if self.available_settings_profiles.is_empty() {
return;
}
let current_idx = self.available_settings_profiles.iter()
.position(|p| p.name == self.settings_profile_name)
.unwrap_or(0);
let new_idx = if forward {
(current_idx + 1) % self.available_settings_profiles.len()
} else {
(current_idx + self.available_settings_profiles.len() - 1)
% self.available_settings_profiles.len()
};
let profile = self.available_settings_profiles[new_idx].clone();
self.apply_settings_profile_deferred(&profile);
}
fn apply_settings_profile_deferred(&mut self, profile: &crate::settings_profile::SettingsProfile) {
profile.apply_to(self);
self.settings_profile_name = profile.name.clone();
}
fn fire_deferred_settings_hooks(&mut self) {
if self.mouse_enabled != self.settings_mouse_snapshot {
self.pending_mouse_toggle = Some(self.mouse_enabled);
}
}
fn open_settings_profile_manager(&mut self) {
self.available_settings_profiles = crate::settings_profile::all_settings_profiles();
self.settings_profile_manager_index = self.available_settings_profiles.iter()
.position(|p| p.name == self.settings_profile_name)
.unwrap_or(0);
self.show_settings_profile_manager = true;
self.settings_profile_save_as = false;
self.settings_profile_save_as_input.clear();
}
pub fn handle_settings_profile_manager_key(&mut self, code: KeyCode) {
if self.settings_profile_save_as {
match code {
KeyCode::Enter => {
let name = self.settings_profile_save_as_input.trim().to_string();
if name.is_empty() {
self.status_message = "Profile name cannot be empty".to_string();
} else if crate::settings_profile::is_builtin(&name) {
self.status_message = "Cannot overwrite built-in profile".to_string();
} else {
let profile = crate::settings_profile::SettingsProfile::from_app(self, name.clone());
match crate::settings_profile::save_custom_profile(&profile) {
Ok(()) => {
self.settings_profile_name = name;
self.available_settings_profiles = crate::settings_profile::all_settings_profiles();
self.settings_profile_manager_index = self.available_settings_profiles.iter()
.position(|p| p.name == self.settings_profile_name)
.unwrap_or(0);
self.save_settings();
self.status_message = "Profile saved".to_string();
}
Err(e) => {
self.status_message = format!("Save failed: {e}");
}
}
self.settings_profile_save_as = false;
}
}
KeyCode::Esc => {
self.settings_profile_save_as = false;
}
KeyCode::Backspace => {
self.settings_profile_save_as_input.pop();
}
KeyCode::Char(c) => {
if self.settings_profile_save_as_input.len() < 30 {
self.settings_profile_save_as_input.push(c);
}
}
_ => {}
}
return;
}
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.settings_profile_manager_index < self.available_settings_profiles.len().saturating_sub(1) {
self.settings_profile_manager_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.settings_profile_manager_index = self.settings_profile_manager_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(profile) = self.available_settings_profiles.get(self.settings_profile_manager_index).cloned() {
self.apply_settings_profile_deferred(&profile);
self.save_settings();
self.status_message = format!("Loaded profile: {}", profile.name);
}
}
KeyCode::Char('s') => {
if let Some(profile) = self.available_settings_profiles.get(self.settings_profile_manager_index) {
if crate::settings_profile::is_builtin(&profile.name) {
return;
}
if profile.matches_app(self) {
return;
}
let updated = crate::settings_profile::SettingsProfile::from_app(self, profile.name.clone());
match crate::settings_profile::save_custom_profile(&updated) {
Ok(()) => {
self.settings_profile_name = updated.name.clone();
self.available_settings_profiles = crate::settings_profile::all_settings_profiles();
self.settings_profile_manager_index = self.available_settings_profiles.iter()
.position(|p| p.name == self.settings_profile_name)
.unwrap_or(0);
self.save_settings();
self.status_message = "Profile saved".to_string();
}
Err(e) => {
self.status_message = format!("Save failed: {e}");
}
}
}
}
KeyCode::Char('S') => {
let has_changes = !self.available_settings_profiles.iter()
.any(|p| p.name == self.settings_profile_name && p.matches_app(self));
if has_changes {
self.settings_profile_save_as = true;
self.settings_profile_save_as_input.clear();
}
}
KeyCode::Char('d') => {
if let Some(profile) = self.available_settings_profiles.get(self.settings_profile_manager_index) {
if crate::settings_profile::is_builtin(&profile.name) {
return;
}
let name = profile.name.clone();
match crate::settings_profile::delete_custom_profile(&name) {
Ok(()) => {
if self.settings_profile_name == name {
self.settings_profile_name = "Default".to_string();
}
self.available_settings_profiles = crate::settings_profile::all_settings_profiles();
if self.settings_profile_manager_index >= self.available_settings_profiles.len() {
self.settings_profile_manager_index = self.available_settings_profiles.len().saturating_sub(1);
}
self.save_settings();
self.status_message = format!("Deleted profile: {name}");
}
Err(e) => {
self.status_message = format!("Delete failed: {e}");
}
}
}
}
KeyCode::Esc | KeyCode::Char('q') => {
self.show_settings_profile_manager = false;
self.fire_deferred_settings_hooks();
}
_ => {}
}
}
pub fn handle_theme_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.theme_index < self.available_themes.len().saturating_sub(1) {
self.theme_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.theme_index = self.theme_index.saturating_sub(1);
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some(selected) = self.available_themes.get(self.theme_index) {
self.theme = selected.clone();
self.save_settings();
}
self.show_theme_picker = false;
}
KeyCode::Esc | KeyCode::Char('q') => {
self.show_theme_picker = false;
}
_ => {}
}
}
pub fn handle_keybindings_key(&mut self, code: KeyCode) {
if self.keybindings_profile_picker {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.keybindings_profile_index < self.available_kb_profiles.len().saturating_sub(1) {
self.keybindings_profile_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.keybindings_profile_index = self.keybindings_profile_index.saturating_sub(1);
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some(name) = self.available_kb_profiles.get(self.keybindings_profile_index) {
let mut kb = keybindings::find_profile(name);
let overrides = keybindings::load_overrides();
kb.apply_overrides(&overrides);
self.keybindings = kb;
self.save_settings();
}
self.keybindings_profile_picker = false;
}
KeyCode::Esc => {
self.keybindings_profile_picker = false;
}
_ => {}
}
return;
}
if let Some((displaced_action, _combo)) = self.keybindings_conflict.take() {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.status_message = format!("{} is now unbound", keybindings::action_label(displaced_action));
}
_ => {
let (mode, action) = self.keybindings_overlay_item(self.keybindings_index);
if let Some(action) = action {
self.keybindings.reset_action(mode, action);
self.keybindings.reset_action(mode, displaced_action);
}
self.status_message.clear();
}
}
return;
}
let total = self.keybindings_overlay_total();
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.keybindings_index < total.saturating_sub(1) {
self.keybindings_index += 1;
}
while self.keybindings_index < total && self.keybindings_overlay_item(self.keybindings_index).1.is_none() {
self.keybindings_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.keybindings_index = self.keybindings_index.saturating_sub(1);
while self.keybindings_index > 0 && self.keybindings_overlay_item(self.keybindings_index).1.is_none() {
self.keybindings_index = self.keybindings_index.saturating_sub(1);
}
}
KeyCode::Enter => {
if self.keybindings_index == 0 {
self.keybindings_profile_picker = true;
self.keybindings_profile_index = self.available_kb_profiles.iter()
.position(|n| *n == self.keybindings.profile_name)
.unwrap_or(0);
} else {
let (_, action) = self.keybindings_overlay_item(self.keybindings_index);
if action.is_some() {
self.keybindings_capturing = true;
self.status_message = "Press a key combo...".to_string();
}
}
}
KeyCode::Backspace => {
let (mode, action) = self.keybindings_overlay_item(self.keybindings_index);
if let Some(action) = action {
self.keybindings.reset_action(mode, action);
self.status_message = format!("Reset {}", keybindings::action_label(action));
}
}
KeyCode::Esc | KeyCode::Char('q') => {
self.show_keybindings = false;
self.save_settings();
}
_ => {}
}
}
pub fn handle_keybinding_capture(&mut self, modifiers: KeyModifiers, code: KeyCode) {
if code == KeyCode::Esc && modifiers == KeyModifiers::NONE {
self.keybindings_capturing = false;
self.status_message.clear();
return;
}
let (mode, action) = self.keybindings_overlay_item(self.keybindings_index);
let Some(action) = action else {
self.keybindings_capturing = false;
return;
};
let modifiers = if matches!(code, KeyCode::Char(_)) {
modifiers - KeyModifiers::SHIFT
} else {
modifiers
};
let combo = keybindings::KeyCombo { modifiers, code };
let displaced = self.keybindings.rebind(mode, action, combo.clone());
self.keybindings_capturing = false;
if let Some(displaced_action) = displaced {
if displaced_action != action {
self.status_message = format!(
"'{}' was bound to {}. Accept? (y/n)",
keybindings::format_key_combo(&combo),
keybindings::action_label(displaced_action)
);
self.keybindings_conflict = Some((displaced_action, combo));
return;
}
}
self.status_message = format!(
"{} → {}",
keybindings::action_label(action),
keybindings::format_key_combo(&combo)
);
}
pub fn keybindings_overlay_total(&self) -> usize {
1 + 1 + keybindings::GLOBAL_ACTIONS.len()
+ 1 + keybindings::NORMAL_ACTIONS.len()
+ 1 + keybindings::INSERT_ACTIONS.len()
}
pub fn keybindings_overlay_item(&self, index: usize) -> (BindingMode, Option<KeyAction>) {
if index == 0 {
return (BindingMode::Global, None); }
let mut i = 1;
if index == i { return (BindingMode::Global, None); }
i += 1;
if index < i + keybindings::GLOBAL_ACTIONS.len() {
return (BindingMode::Global, Some(keybindings::GLOBAL_ACTIONS[index - i]));
}
i += keybindings::GLOBAL_ACTIONS.len();
if index == i { return (BindingMode::Normal, None); }
i += 1;
if index < i + keybindings::NORMAL_ACTIONS.len() {
return (BindingMode::Normal, Some(keybindings::NORMAL_ACTIONS[index - i]));
}
i += keybindings::NORMAL_ACTIONS.len();
if index == i { return (BindingMode::Insert, None); }
i += 1;
if index < i + keybindings::INSERT_ACTIONS.len() {
return (BindingMode::Insert, Some(keybindings::INSERT_ACTIONS[index - i]));
}
(BindingMode::Insert, None)
}
pub fn refresh_contacts_filter(&mut self) {
let filter_lower = self.contacts_filter.to_lowercase();
let mut contacts: Vec<(String, String)> = self
.contact_names
.iter()
.filter(|(_, name)| !name.is_empty())
.filter(|(number, name)| {
if filter_lower.is_empty() {
return true;
}
name.to_lowercase().contains(&filter_lower)
|| number.to_lowercase().contains(&filter_lower)
})
.map(|(number, name)| (number.clone(), name.clone()))
.collect();
contacts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
self.contacts_filtered = contacts;
if self.contacts_filtered.is_empty() {
self.contacts_index = 0;
} else if self.contacts_index >= self.contacts_filtered.len() {
self.contacts_index = self.contacts_filtered.len() - 1;
}
}
pub fn group_menu_items(&self) -> Vec<MenuAction> {
let is_group = self.active_conversation.as_ref()
.and_then(|id| self.conversations.get(id))
.is_some_and(|c| c.is_group);
if is_group {
vec![
MenuAction { label: "Members", key_hint: "m", nerd_icon: "\u{f0849}" },
MenuAction { label: "Add member", key_hint: "a", nerd_icon: "\u{f0234}" },
MenuAction { label: "Remove member", key_hint: "r", nerd_icon: "\u{f0235}" },
MenuAction { label: "Rename", key_hint: "n", nerd_icon: "\u{f03eb}" },
MenuAction { label: "Leave", key_hint: "l", nerd_icon: "\u{f0a79}" },
]
} else {
vec![
MenuAction { label: "Create group", key_hint: "c", nerd_icon: "\u{f0234}" },
]
}
}
pub fn refresh_group_add_filter(&mut self) {
let filter_lower = self.group_menu_filter.to_lowercase();
let existing_members: HashSet<&str> = self.active_conversation.as_ref()
.and_then(|id| self.groups.get(id))
.map(|g| g.members.iter().map(|s| s.as_str()).collect())
.unwrap_or_default();
let mut contacts: Vec<(String, String)> = self
.contact_names
.iter()
.filter(|(_, name)| !name.is_empty())
.filter(|(number, _)| !existing_members.contains(number.as_str()))
.filter(|(number, name)| {
if filter_lower.is_empty() {
return true;
}
name.to_lowercase().contains(&filter_lower)
|| number.to_lowercase().contains(&filter_lower)
})
.map(|(number, name)| (number.clone(), name.clone()))
.collect();
contacts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
self.group_menu_filtered = contacts;
if self.group_menu_filtered.is_empty() {
self.group_menu_index = 0;
} else if self.group_menu_index >= self.group_menu_filtered.len() {
self.group_menu_index = self.group_menu_filtered.len() - 1;
}
}
pub fn refresh_group_remove_filter(&mut self) {
let filter_lower = self.group_menu_filter.to_lowercase();
let members: Vec<String> = self.active_conversation.as_ref()
.and_then(|id| self.groups.get(id))
.map(|g| g.members.clone())
.unwrap_or_default();
let mut result: Vec<(String, String)> = members
.into_iter()
.filter(|phone| *phone != self.account)
.map(|phone| {
let name = self.contact_names.get(&phone)
.cloned()
.unwrap_or_else(|| phone.clone());
(phone, name)
})
.filter(|(phone, name)| {
if filter_lower.is_empty() {
return true;
}
name.to_lowercase().contains(&filter_lower)
|| phone.to_lowercase().contains(&filter_lower)
})
.collect();
result.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
self.group_menu_filtered = result;
if self.group_menu_filtered.is_empty() {
self.group_menu_index = 0;
} else if self.group_menu_index >= self.group_menu_filtered.len() {
self.group_menu_index = self.group_menu_filtered.len() - 1;
}
}
pub fn handle_group_menu_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let state = self.group_menu_state.clone()?;
match state {
GroupMenuState::Menu => {
let items = self.group_menu_items();
let item_count = items.len();
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.group_menu_index < item_count.saturating_sub(1) {
self.group_menu_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.group_menu_index = self.group_menu_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(action) = items.get(self.group_menu_index) {
self.transition_group_menu(action.key_hint);
}
}
KeyCode::Char(c) => {
let hint = match c {
'm' => "m", 'a' => "a", 'r' => "r",
'n' => "n", 'l' => "l", 'c' => "c",
_ => "",
};
if !hint.is_empty() && items.iter().any(|a| a.key_hint == hint) {
self.transition_group_menu(hint);
}
}
KeyCode::Esc => {
self.group_menu_state = None;
}
_ => {}
}
None
}
GroupMenuState::Members => {
let member_count = self.group_menu_filtered.len();
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.group_menu_index < member_count.saturating_sub(1) {
self.group_menu_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.group_menu_index = self.group_menu_index.saturating_sub(1);
}
KeyCode::Esc => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
}
_ => {}
}
None
}
GroupMenuState::AddMember => {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.group_menu_filtered.is_empty()
&& self.group_menu_index < self.group_menu_filtered.len() - 1
{
self.group_menu_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.group_menu_index = self.group_menu_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some((phone, _)) = self.group_menu_filtered.get(self.group_menu_index) {
let phone = phone.clone();
let group_id = self.active_conversation.clone()?;
self.group_menu_state = None;
self.group_menu_filter.clear();
return Some(SendRequest::AddGroupMembers {
group_id,
members: vec![phone],
});
}
}
KeyCode::Esc => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
self.group_menu_filter.clear();
}
KeyCode::Backspace => {
self.group_menu_filter.pop();
self.group_menu_index = 0;
self.refresh_group_add_filter();
}
KeyCode::Char(c) if c != 'j' && c != 'k' => {
self.group_menu_filter.push(c);
self.group_menu_index = 0;
self.refresh_group_add_filter();
}
_ => {}
}
None
}
GroupMenuState::RemoveMember => {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.group_menu_filtered.is_empty()
&& self.group_menu_index < self.group_menu_filtered.len() - 1
{
self.group_menu_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.group_menu_index = self.group_menu_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some((phone, _)) = self.group_menu_filtered.get(self.group_menu_index) {
let phone = phone.clone();
let group_id = self.active_conversation.clone()?;
self.group_menu_state = None;
self.group_menu_filter.clear();
return Some(SendRequest::RemoveGroupMembers {
group_id,
members: vec![phone],
});
}
}
KeyCode::Esc => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
self.group_menu_filter.clear();
}
KeyCode::Backspace => {
self.group_menu_filter.pop();
self.group_menu_index = 0;
self.refresh_group_remove_filter();
}
KeyCode::Char(c) if c != 'j' && c != 'k' => {
self.group_menu_filter.push(c);
self.group_menu_index = 0;
self.refresh_group_remove_filter();
}
_ => {}
}
None
}
GroupMenuState::Rename => {
match code {
KeyCode::Enter => {
let name = self.group_menu_input.trim().to_string();
if !name.is_empty() {
let group_id = self.active_conversation.clone()?;
self.group_menu_state = None;
self.group_menu_input.clear();
return Some(SendRequest::RenameGroup { group_id, name });
}
}
KeyCode::Esc => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
self.group_menu_input.clear();
}
KeyCode::Backspace => {
self.group_menu_input.pop();
}
KeyCode::Char(c) => {
self.group_menu_input.push(c);
}
_ => {}
}
None
}
GroupMenuState::Create => {
match code {
KeyCode::Enter => {
let name = self.group_menu_input.trim().to_string();
if !name.is_empty() {
self.group_menu_state = None;
self.group_menu_input.clear();
return Some(SendRequest::CreateGroup { name });
}
}
KeyCode::Esc => {
self.group_menu_state = None;
self.group_menu_input.clear();
}
KeyCode::Backspace => {
self.group_menu_input.pop();
}
KeyCode::Char(c) => {
self.group_menu_input.push(c);
}
_ => {}
}
None
}
GroupMenuState::LeaveConfirm => {
match code {
KeyCode::Char('y') => {
let group_id = self.active_conversation.clone()?;
self.group_menu_state = None;
return Some(SendRequest::LeaveGroup { group_id });
}
KeyCode::Char('n') | KeyCode::Esc => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
}
_ => {}
}
None
}
}
}
fn transition_group_menu(&mut self, hint: &str) {
self.group_menu_index = 0;
self.group_menu_filter.clear();
self.group_menu_input.clear();
match hint {
"m" => {
let members: Vec<(String, String)> = self.active_conversation.as_ref()
.and_then(|id| self.groups.get(id))
.map(|g| g.members.iter().map(|phone| {
let name = self.contact_names.get(phone)
.cloned()
.unwrap_or_else(|| phone.clone());
(phone.clone(), name)
}).collect())
.unwrap_or_default();
self.group_menu_filtered = members;
self.group_menu_state = Some(GroupMenuState::Members);
}
"a" => {
self.refresh_group_add_filter();
self.group_menu_state = Some(GroupMenuState::AddMember);
}
"r" => {
self.refresh_group_remove_filter();
self.group_menu_state = Some(GroupMenuState::RemoveMember);
}
"n" => {
let name = self.active_conversation.as_ref()
.and_then(|id| self.conversations.get(id))
.map(|c| c.name.clone())
.unwrap_or_default();
self.group_menu_input = name;
self.group_menu_state = Some(GroupMenuState::Rename);
}
"l" => {
self.group_menu_state = Some(GroupMenuState::LeaveConfirm);
}
"c" => {
self.group_menu_state = Some(GroupMenuState::Create);
}
_ => {}
}
}
fn handle_message_request_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let conv_id = match self.active_conversation.clone() {
Some(id) => id,
None => {
self.show_message_request = false;
return None;
}
};
match code {
KeyCode::Char('a') => {
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.accepted = true;
}
self.db_warn_visible(self.db.update_accepted(&conv_id, true), "update_accepted");
self.show_message_request = false;
Some(SendRequest::MessageRequestResponse {
recipient: conv_id,
is_group,
response_type: "accept".to_string(),
})
}
KeyCode::Char('d') => {
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
self.conversations.remove(&conv_id);
self.conversation_order.retain(|id| id != &conv_id);
self.scroll_positions.remove(&conv_id);
self.db_warn_visible(self.db.delete_conversation(&conv_id), "delete_conversation");
self.show_message_request = false;
self.active_conversation = None;
Some(SendRequest::MessageRequestResponse {
recipient: conv_id,
is_group,
response_type: "delete".to_string(),
})
}
KeyCode::Esc => {
self.show_message_request = false;
self.active_conversation = None;
None
}
_ => None,
}
}
fn handle_reaction_picker_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('h') | KeyCode::Left => {
self.reaction_picker_index = self.reaction_picker_index.saturating_sub(1);
None
}
KeyCode::Char('l') | KeyCode::Right => {
if self.reaction_picker_index < QUICK_REACTIONS.len() - 1 {
self.reaction_picker_index += 1;
}
None
}
KeyCode::Char(c @ '1'..='8') => {
let idx = (c as u8 - b'1') as usize;
if idx < QUICK_REACTIONS.len() {
self.reaction_picker_index = idx;
self.show_reaction_picker = false;
self.prepare_reaction_send()
} else {
None
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.show_reaction_picker = false;
self.prepare_reaction_send()
}
KeyCode::Esc => {
self.show_reaction_picker = false;
None
}
_ => None,
}
}
fn prepare_reaction_send(&mut self) -> Option<SendRequest> {
let emoji = QUICK_REACTIONS.get(self.reaction_picker_index)?.to_string();
let conv_id = self.active_conversation.clone()?;
let conv = self.conversations.get(&conv_id)?;
let is_group = conv.is_group;
let index = self.focused_msg_index.unwrap_or_else(|| {
conv.messages.len().saturating_sub(1)
});
let msg = conv.messages.get(index)?;
let target_timestamp = msg.timestamp_ms;
let target_author = if msg.sender == "you" {
self.account.clone()
} else {
self.contact_names
.iter()
.find(|(_, name)| name.as_str() == msg.sender)
.map(|(num, _)| num.clone())
.unwrap_or_else(|| msg.sender.clone())
};
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(msg) = conv.messages.get_mut(index) {
if let Some(existing) = msg.reactions.iter_mut().find(|r| r.sender == "you") {
existing.emoji = emoji.clone();
} else {
msg.reactions.push(Reaction {
emoji: emoji.clone(),
sender: "you".to_string(),
});
}
}
}
self.db_warn_visible(
self.db.upsert_reaction(&conv_id, target_timestamp, &target_author, "you", &emoji),
"upsert_reaction",
);
Some(SendRequest::Reaction {
conv_id,
emoji,
is_group,
target_author,
target_timestamp,
remove: false,
})
}
pub fn action_menu_items(&self) -> Vec<MenuAction> {
let msg = match self.selected_message() {
Some(m) => m,
None => return Vec::new(),
};
let mut items = Vec::new();
if !msg.is_system && !msg.is_deleted {
items.push(MenuAction {
label: "Reply",
key_hint: "q",
nerd_icon: "\u{f045a}",
});
}
if msg.sender == "you" && !msg.is_system && !msg.is_deleted {
items.push(MenuAction {
label: "Edit",
key_hint: "e",
nerd_icon: "\u{f03eb}",
});
}
if !msg.is_system {
items.push(MenuAction {
label: "React",
key_hint: "r",
nerd_icon: "\u{f0785}",
});
}
if !msg.is_system && !msg.is_deleted {
items.push(MenuAction {
label: "Forward",
key_hint: "f",
nerd_icon: "\u{f04d6}",
});
}
items.push(MenuAction {
label: "Copy",
key_hint: "y",
nerd_icon: "\u{f018f}",
});
if !msg.is_system && !msg.is_deleted {
items.push(MenuAction {
label: "Delete",
key_hint: "d",
nerd_icon: "\u{f0a79}",
});
}
if !msg.is_system && !msg.is_deleted {
items.push(MenuAction {
label: if msg.is_pinned { "Unpin" } else { "Pin" },
key_hint: "p",
nerd_icon: "\u{f0403}",
});
}
if let Some(ref poll) = msg.poll_data {
if !poll.closed {
items.push(MenuAction {
label: "Vote",
key_hint: "v",
nerd_icon: "\u{f0e73}",
});
}
if msg.sender == "you" && !poll.closed {
items.push(MenuAction {
label: "End Poll",
key_hint: "x",
nerd_icon: "\u{f073a}",
});
}
}
items
}
pub fn handle_action_menu_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let item_count = self.action_menu_items().len();
if item_count == 0 {
self.show_action_menu = false;
return None;
}
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.action_menu_index < item_count - 1 {
self.action_menu_index += 1;
}
None
}
KeyCode::Char('k') | KeyCode::Up => {
self.action_menu_index = self.action_menu_index.saturating_sub(1);
None
}
KeyCode::Enter => {
let items = self.action_menu_items();
if let Some(action) = items.get(self.action_menu_index) {
let hint = action.key_hint;
self.show_action_menu = false;
self.execute_action_by_hint(hint)
} else {
self.show_action_menu = false;
None
}
}
KeyCode::Char(c @ ('q' | 'e' | 'r' | 'f' | 'y' | 'd' | 'p' | 'v' | 'x')) => {
let hint = match c {
'q' => "q",
'e' => "e",
'r' => "r",
'f' => "f",
'y' => "y",
'd' => "d",
'p' => "p",
'v' => "v",
'x' => "x",
_ => unreachable!(),
};
let items = self.action_menu_items();
if items.iter().any(|a| a.key_hint == hint) {
self.show_action_menu = false;
self.execute_action_by_hint(hint)
} else {
None
}
}
KeyCode::Esc => {
self.show_action_menu = false;
None
}
_ => None,
}
}
fn execute_action_by_hint(&mut self, hint: &str) -> Option<SendRequest> {
match hint {
"q" => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
let author_phone = msg.sender_id.clone();
let snippet: String = if msg.body.chars().count() > 50 {
format!("{}…", msg.body.chars().take(50).collect::<String>())
} else {
msg.body.clone()
};
let ts = msg.timestamp_ms;
let phone = if author_phone.is_empty() || author_phone == "you" {
self.account.clone()
} else {
author_phone
};
self.reply_target = Some((phone, snippet, ts));
self.mode = InputMode::Insert;
}
}
None
}
"e" => {
if let Some(msg) = self.selected_message() {
if msg.sender == "you" && !msg.is_deleted && !msg.is_system {
let ts = msg.timestamp_ms;
let body = msg.body.clone();
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
self.editing_message = Some((ts, conv_id));
self.input_buffer = body;
self.input_cursor = self.input_buffer.len();
self.mode = InputMode::Insert;
}
}
}
None
}
"r" => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.show_reaction_picker = true;
self.reaction_picker_index = 0;
}
None
}
"f" => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.forward_body = msg.body.clone();
self.open_forward_picker();
}
}
None
}
"y" => {
self.copy_selected_message(false);
None
}
"d" => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.show_delete_confirm = true;
}
}
None
}
"p" => {
self.execute_pin_toggle()
}
"v" => {
if let Some(msg) = self.selected_message() {
if let Some(ref poll) = msg.poll_data {
if !poll.closed {
let conv_id = self.active_conversation.clone().unwrap_or_default();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let poll_author = if msg.sender_id.is_empty() || msg.sender_id == "you" {
self.account.clone()
} else {
msg.sender_id.clone()
};
let options = poll.options.clone();
let allow_multiple = poll.allow_multiple;
let poll_timestamp = msg.timestamp_ms;
let option_count = options.len();
self.poll_vote_pending = Some(PollVotePending {
conv_id,
is_group,
poll_author,
poll_timestamp,
allow_multiple,
options,
});
self.poll_vote_selections = vec![false; option_count];
self.poll_vote_index = 0;
self.show_poll_vote = true;
}
}
}
None
}
"x" => {
if let Some(msg) = self.selected_message() {
if msg.sender == "you" && msg.poll_data.as_ref().is_some_and(|p| !p.closed) {
let conv_id = self.active_conversation.clone()?;
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let poll_timestamp = msg.timestamp_ms;
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(idx) = conv.find_msg_idx(poll_timestamp) {
if let Some(ref mut poll) = conv.messages[idx].poll_data {
poll.closed = true;
}
}
}
self.db_warn_visible(self.db.close_poll(&conv_id, poll_timestamp), "close_poll");
return Some(SendRequest::PollTerminate {
recipient: conv_id,
is_group,
poll_timestamp,
});
}
}
None
}
_ => None,
}
}
pub fn handle_verify_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('j') | KeyCode::Down => {
self.verify_confirming = false;
if !self.verify_identities.is_empty()
&& self.verify_index < self.verify_identities.len() - 1
{
self.verify_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.verify_confirming = false;
if self.verify_index > 0 {
self.verify_index -= 1;
}
}
KeyCode::Char('v') | KeyCode::Enter => {
if let Some(id) = self.verify_identities.get(self.verify_index) {
if id.safety_number.is_empty() {
self.status_message = "Safety number not available — cannot verify".to_string();
return None;
}
if self.verify_confirming {
if let Some(ref number) = id.number {
let recipient = number.clone();
let safety_number = id.safety_number.clone();
self.verify_confirming = false;
return Some(SendRequest::TrustIdentity { recipient, safety_number });
}
} else {
self.verify_confirming = true;
}
}
}
KeyCode::Esc => {
self.verify_confirming = false;
self.show_verify = false;
}
_ => {
self.verify_confirming = false;
}
}
None
}
fn open_forward_picker(&mut self) {
self.show_forward = true;
self.forward_index = 0;
self.forward_filter.clear();
self.update_forward_filter();
}
fn update_forward_filter(&mut self) {
let filter = self.forward_filter.to_lowercase();
self.forward_filtered = self.conversation_order.iter()
.filter_map(|id| {
let conv = self.conversations.get(id)?;
if !conv.accepted { return None; }
if self.active_conversation.as_deref() == Some(id.as_str()) { return None; }
let name = &conv.name;
if filter.is_empty() || name.to_lowercase().contains(&filter) {
Some((id.clone(), name.clone()))
} else {
None
}
})
.collect();
if self.forward_index >= self.forward_filtered.len() {
self.forward_index = self.forward_filtered.len().saturating_sub(1);
}
}
pub fn handle_forward_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.forward_filtered.is_empty()
&& self.forward_index < self.forward_filtered.len() - 1
{
self.forward_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.forward_index = self.forward_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some((conv_id, name)) = self.forward_filtered.get(self.forward_index).cloned() {
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let body = format!("[Forwarded]\n{}", self.forward_body);
let local_ts_ms = chrono::Utc::now().timestamp_millis();
self.show_forward = false;
self.status_message = format!("Forwarded to {name}");
self.move_conversation_to_top(&conv_id);
return Some(SendRequest::Message {
recipient: conv_id,
body,
is_group,
local_ts_ms,
mentions: Vec::new(),
attachment: None,
quote_timestamp: None,
quote_author: None,
quote_body: None,
});
}
}
KeyCode::Backspace => {
self.forward_filter.pop();
self.update_forward_filter();
}
KeyCode::Esc => {
self.show_forward = false;
}
KeyCode::Char(c) => {
if !c.is_control() {
self.forward_filter.push(c);
self.update_forward_filter();
}
}
_ => {}
}
None
}
pub fn handle_contacts_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.contacts_filtered.is_empty()
&& self.contacts_index < self.contacts_filtered.len() - 1
{
self.contacts_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.contacts_index = self.contacts_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some((number, _)) = self.contacts_filtered.get(self.contacts_index) {
let number = number.clone();
self.show_contacts = false;
self.contacts_filter.clear();
self.join_conversation(&number);
}
}
KeyCode::Esc => {
self.show_contacts = false;
self.contacts_filter.clear();
}
KeyCode::Backspace => {
self.contacts_filter.pop();
self.refresh_contacts_filter();
}
KeyCode::Char(c) => {
self.contacts_filter.push(c);
self.refresh_contacts_filter();
}
_ => {}
}
}
pub fn handle_search_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.search_results.is_empty()
&& self.search_index < self.search_results.len() - 1
{
self.search_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.search_index = self.search_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(result) = self.search_results.get(self.search_index) {
let conv_id = result.conv_id.clone();
let target_ts = result.timestamp_ms;
self.show_search = false;
self.join_conversation(&conv_id);
self.jump_to_message_timestamp(target_ts);
}
}
KeyCode::Esc => {
self.show_search = false;
self.search_query.clear();
}
KeyCode::Backspace => {
if !self.search_query.is_empty() {
self.search_query.pop();
self.run_search();
}
}
KeyCode::Char(c) => {
self.search_query.push(c);
self.run_search();
}
_ => {}
}
}
fn run_search(&mut self) {
if self.search_query.is_empty() {
self.search_results.clear();
self.search_index = 0;
return;
}
let results = if let Some(ref conv_id) = self.active_conversation {
self.db.search_messages(conv_id, &self.search_query, 50)
} else {
self.db.search_all_messages(&self.search_query, 50)
};
match results {
Ok(rows) => {
self.search_results = rows
.into_iter()
.map(|(sender, body, timestamp_ms, conv_id, conv_name)| SearchResult {
sender,
body,
timestamp_ms,
conv_id,
conv_name,
})
.collect();
}
Err(e) => {
crate::debug_log::logf(format_args!("search error: {e}"));
self.search_results.clear();
}
}
if self.search_results.is_empty() {
self.search_index = 0;
} else if self.search_index >= self.search_results.len() {
self.search_index = self.search_results.len() - 1;
}
}
fn jump_to_message_timestamp(&mut self, target_ts: i64) {
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id.clone(),
None => return,
};
let conv = match self.conversations.get(&conv_id) {
Some(c) => c,
None => return,
};
let total = conv.messages.len();
if total == 0 {
return;
}
let idx = conv.find_msg_idx(target_ts);
if let Some(i) = idx {
let from_bottom = total.saturating_sub(i + 1);
self.scroll_offset = from_bottom;
self.focused_msg_index = Some(i);
self.mode = InputMode::Normal;
}
}
fn jump_to_search_result(&mut self, forward: bool) {
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id,
None => return,
};
let conv_results: Vec<usize> = self
.search_results
.iter()
.enumerate()
.filter(|(_, r)| r.conv_id == *conv_id)
.map(|(i, _)| i)
.collect();
if conv_results.is_empty() {
self.status_message = "no matches in this conversation".to_string();
return;
}
let current_pos = conv_results.iter().position(|&i| i == self.search_index);
let next_idx = match current_pos {
Some(pos) => {
if forward {
if pos + 1 < conv_results.len() {
conv_results[pos + 1]
} else {
conv_results[0] }
} else if pos > 0 {
conv_results[pos - 1]
} else {
conv_results[conv_results.len() - 1] }
}
None => conv_results[0],
};
self.search_index = next_idx;
if let Some(result) = self.search_results.get(next_idx) {
let ts = result.timestamp_ms;
self.jump_to_message_timestamp(ts);
let pos = conv_results.iter().position(|&i| i == next_idx).unwrap_or(0) + 1;
self.status_message = format!(
"match {}/{} for \"{}\"",
pos,
conv_results.len(),
self.search_query
);
}
}
pub fn open_file_browser(&mut self) {
if self.active_conversation.is_none() {
self.status_message = "No active conversation. Use /join <name> first.".to_string();
return;
}
self.show_file_browser = true;
self.file_browser_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
self.file_browser_index = 0;
self.file_browser_filter.clear();
self.file_browser_error = None;
self.refresh_file_browser_entries();
}
fn refresh_file_browser_entries(&mut self) {
self.file_browser_entries.clear();
self.file_browser_error = None;
match std::fs::read_dir(&self.file_browser_dir) {
Ok(entries) => {
let mut dirs: Vec<(String, bool, u64)> = Vec::new();
let mut files: Vec<(String, bool, u64)> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let meta = entry.metadata();
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
if is_dir {
dirs.push((name, true, 0));
} else {
files.push((name, false, size));
}
}
dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
self.file_browser_entries.extend(dirs);
self.file_browser_entries.extend(files);
}
Err(e) => {
self.file_browser_error = Some(format!("Cannot read directory: {e}"));
}
}
self.refresh_file_browser_filter();
}
fn refresh_file_browser_filter(&mut self) {
let filter_lower = self.file_browser_filter.to_lowercase();
self.file_browser_filtered = self
.file_browser_entries
.iter()
.enumerate()
.filter(|(_, (name, _, _))| {
filter_lower.is_empty() || name.to_lowercase().contains(&filter_lower)
})
.map(|(i, _)| i)
.collect();
if self.file_browser_filtered.is_empty() {
self.file_browser_index = 0;
} else if self.file_browser_index >= self.file_browser_filtered.len() {
self.file_browser_index = self.file_browser_filtered.len() - 1;
}
}
pub fn handle_file_browser_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.file_browser_filtered.is_empty()
&& self.file_browser_index < self.file_browser_filtered.len() - 1
{
self.file_browser_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.file_browser_index = self.file_browser_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(&entry_idx) = self.file_browser_filtered.get(self.file_browser_index) {
let (name, is_dir, _) = self.file_browser_entries[entry_idx].clone();
if is_dir {
self.file_browser_dir = self.file_browser_dir.join(&name);
self.file_browser_index = 0;
self.file_browser_filter.clear();
self.refresh_file_browser_entries();
} else {
let path = self.file_browser_dir.join(&name);
self.pending_attachment = Some(path);
self.show_file_browser = false;
}
}
}
KeyCode::Backspace => {
if !self.file_browser_filter.is_empty() {
self.file_browser_filter.pop();
self.refresh_file_browser_filter();
} else {
self.file_browser_navigate_up();
}
}
KeyCode::Char('-') => {
self.file_browser_navigate_up();
}
KeyCode::Esc => {
self.show_file_browser = false;
}
KeyCode::Char(c) => {
self.file_browser_filter.push(c);
self.refresh_file_browser_filter();
}
_ => {}
}
}
fn file_browser_navigate_up(&mut self) {
if let Some(parent) = self.file_browser_dir.parent() {
let parent = parent.to_path_buf();
if parent != self.file_browser_dir {
self.file_browser_dir = parent;
self.file_browser_index = 0;
self.file_browser_filter.clear();
self.refresh_file_browser_entries();
}
}
}
pub fn handle_autocomplete_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let list_len = match self.autocomplete_mode {
AutocompleteMode::Command => self.autocomplete_candidates.len(),
AutocompleteMode::Mention => self.mention_candidates.len(),
AutocompleteMode::Join => self.join_candidates.len(),
};
match code {
KeyCode::Up => {
if list_len > 0 {
self.autocomplete_index = if self.autocomplete_index == 0 {
list_len - 1
} else {
self.autocomplete_index - 1
};
}
}
KeyCode::Down => {
if list_len > 0 {
self.autocomplete_index = (self.autocomplete_index + 1) % list_len;
}
}
KeyCode::Tab => {
self.apply_autocomplete();
}
KeyCode::Esc => {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.mention_candidates.clear();
self.join_candidates.clear();
self.autocomplete_index = 0;
}
KeyCode::Enter => {
if self.autocomplete_mode == AutocompleteMode::Mention {
self.apply_autocomplete();
} else {
self.apply_autocomplete();
return self.handle_input();
}
}
_ => {
self.apply_input_edit(code);
self.update_autocomplete();
}
}
None
}
pub fn new(account: String, db: Database) -> Self {
let (image_render_tx, image_render_rx) = mpsc::channel();
Self {
conversations: HashMap::new(),
conversation_order: Vec::new(),
active_conversation: None,
input_buffer: String::new(),
input_cursor: 0,
input_history: Vec::new(),
history_index: None,
history_draft: String::new(),
sidebar_visible: true,
scroll_offset: 0,
scroll_positions: HashMap::new(),
status_message: "connecting...".to_string(),
should_quit: false,
quit_confirm: false,
account,
sidebar_width: 22,
sidebar_on_right: false,
typing_indicators: HashMap::new(),
last_read_index: HashMap::new(),
connected: false,
loading: true,
startup_status: "Starting signal-cli...".to_string(),
spinner_tick: 0,
mode: InputMode::Insert,
db,
connection_error: None,
contact_names: HashMap::new(),
pending_bell: false,
notify_direct: true,
notify_group: true,
desktop_notifications: false,
notification_preview: "full".to_string(),
clipboard_clear_seconds: 30,
clipboard_set_at: None,
muted_conversations: HashSet::new(),
blocked_conversations: HashSet::new(),
autocomplete_visible: false,
autocomplete_candidates: Vec::new(),
autocomplete_index: 0,
show_settings: false,
settings_index: 0,
show_help: false,
show_contacts: false,
contacts_index: 0,
contacts_filter: String::new(),
contacts_filtered: Vec::new(),
show_verify: false,
verify_index: 0,
verify_identities: Vec::new(),
identity_trust: HashMap::new(),
verify_confirming: false,
inline_images: true,
show_link_previews: true,
link_regions: Vec::new(),
link_url_map: HashMap::new(),
image_protocol: image_render::detect_protocol(),
visible_images: Vec::new(),
prev_visible_images: Vec::new(),
native_images: false,
native_image_cache: HashMap::new(),
prev_active_conversation: None,
incognito: false,
has_more_messages: HashSet::new(),
at_scroll_top: false,
date_separators: true,
show_receipts: true,
color_receipts: true,
nerd_fonts: false,
pending_sends: HashMap::new(),
pending_receipts: Vec::new(),
focused_message_time: None,
focused_msg_index: None,
show_reaction_picker: false,
reaction_picker_index: 0,
reaction_verbose: false,
groups: HashMap::new(),
uuid_to_name: HashMap::new(),
number_to_uuid: HashMap::new(),
autocomplete_mode: AutocompleteMode::Command,
mention_candidates: Vec::new(),
join_candidates: Vec::new(),
mention_trigger_pos: 0,
pending_mentions: Vec::new(),
is_demo: false,
show_file_browser: false,
file_browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
file_browser_entries: Vec::new(),
file_browser_index: 0,
file_browser_filter: String::new(),
file_browser_filtered: Vec::new(),
file_browser_error: None,
pending_attachment: None,
paste_temp_path: {
let dir = std::env::temp_dir().join(format!("siggy-paste-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
if let Err(e) = std::fs::create_dir_all(&dir) {
crate::debug_log::logf(format_args!("paste temp dir init failed: {e}"));
}
dir
},
reply_target: None,
show_delete_confirm: false,
editing_message: None,
show_search: false,
search_query: String::new(),
search_results: Vec::new(),
search_index: 0,
typing_sent: false,
typing_last_keypress: None,
pending_typing_stop: None,
send_read_receipts: true,
pending_read_receipts: Vec::new(),
show_action_menu: false,
action_menu_index: 0,
show_forward: false,
forward_index: 0,
forward_filter: String::new(),
forward_filtered: Vec::new(),
forward_body: String::new(),
group_menu_state: None,
group_menu_index: 0,
group_menu_filter: String::new(),
group_menu_filtered: Vec::new(),
group_menu_input: String::new(),
show_message_request: false,
mouse_sidebar_inner: None,
mouse_messages_area: Rect::default(),
mouse_input_area: Rect::default(),
mouse_input_prefix_len: 0,
mouse_enabled: true,
pending_mouse_toggle: None,
theme: theme::default_theme(),
show_theme_picker: false,
theme_index: 0,
available_themes: theme::all_themes(),
keybindings: keybindings::default_profile(),
show_keybindings: false,
keybindings_index: 0,
keybindings_capturing: false,
keybindings_conflict: None,
keybindings_profile_picker: false,
keybindings_profile_index: 0,
available_kb_profiles: keybindings::all_profile_names(),
show_pin_duration: false,
pin_duration_index: 0,
pin_pending: None,
show_poll_vote: false,
poll_vote_index: 0,
poll_vote_selections: Vec::new(),
poll_vote_pending: None,
pending_polls: HashMap::new(),
expiring_msg_count: 0,
show_about: false,
show_profile: false,
profile_index: 0,
profile_editing: false,
profile_fields: [String::new(), String::new(), String::new(), String::new()],
profile_edit_buffer: String::new(),
next_kitty_image_id: 1,
kitty_image_ids: HashMap::new(),
kitty_transmitted: HashSet::new(),
kitty_pending_transmits: Vec::new(),
iterm2_crop_cache: HashMap::new(),
settings_profile_name: "Default".to_string(),
show_settings_profile_manager: false,
settings_profile_manager_index: 0,
available_settings_profiles: crate::settings_profile::all_settings_profiles(),
settings_profile_save_as: false,
settings_profile_save_as_input: String::new(),
settings_mouse_snapshot: true,
image_render_tx,
image_render_rx,
image_render_in_flight: HashSet::new(),
}
}
const PAGE_SIZE: usize = 100;
pub fn load_from_db(&mut self) -> anyhow::Result<()> {
let conv_data = self.db.load_conversations(Self::PAGE_SIZE)?;
let order = self.db.load_conversation_order()?;
for mut conv in conv_data {
let id = conv.id.clone();
let msg_count = conv.messages.len();
let unread = conv.unread;
for msg in &mut conv.messages {
if msg.status == Some(MessageStatus::Sending) {
msg.status = Some(MessageStatus::Sent);
}
}
for msg in &mut conv.messages {
if msg.body.starts_with("[image:") {
let path_str = if let Some(uri_pos) = msg.body.find("file:///") {
let uri_slice = msg.body[uri_pos..].trim_end_matches(')');
Some(file_uri_to_path(uri_slice))
} else if let Some(arrow_pos) = msg.body.find(" -> ") {
Some(msg.body[arrow_pos + 4..].trim_end_matches(']').to_string())
} else {
None
};
if let Some(p) = path_str {
if Path::new(&p).exists() {
msg.image_path = Some(p);
}
}
}
}
if msg_count >= Self::PAGE_SIZE {
self.has_more_messages.insert(id.clone());
}
self.conversations.insert(id.clone(), conv);
if msg_count > 0 {
let read_index = msg_count.saturating_sub(unread);
self.last_read_index.insert(id, read_index);
}
}
self.conversation_order = order;
self.muted_conversations = self.db.load_muted()?;
self.blocked_conversations = self.db.load_blocked()?;
for conv in self.conversations.values_mut() {
if !conv.is_group && conv.name == conv.id && conv.name.starts_with('+') {
if let Some(name) = conv.messages.iter().rev()
.find(|m| m.sender != "you" && m.sender != conv.id && !m.sender.starts_with('+'))
.map(|m| m.sender.clone())
{
db_warn(self.db.upsert_conversation(&conv.id, &name, false), "upsert_conversation");
conv.name = name;
}
}
}
Ok(())
}
pub fn load_more_messages(&mut self) {
self.at_scroll_top = false;
let conv_id = match self.active_conversation.as_ref() {
Some(id) if self.has_more_messages.contains(id) => id.clone(),
_ => return,
};
let already_loaded = self.conversations.get(&conv_id)
.map(|c| c.messages.len()).unwrap_or(0);
let new_msgs = match self.db.load_messages_page(&conv_id, Self::PAGE_SIZE, already_loaded) {
Ok(msgs) => msgs,
Err(_) => return,
};
if new_msgs.len() < Self::PAGE_SIZE {
self.has_more_messages.remove(&conv_id);
}
if new_msgs.is_empty() {
return;
}
let prepend_count = new_msgs.len();
let mut processed: Vec<DisplayMessage> = new_msgs.into_iter().map(|mut msg| {
if msg.status == Some(MessageStatus::Sending) {
msg.status = Some(MessageStatus::Sent);
}
if msg.body.starts_with("[image:") {
let path_str = if let Some(uri_pos) = msg.body.find("file:///") {
let uri_slice = msg.body[uri_pos..].trim_end_matches(')');
Some(file_uri_to_path(uri_slice))
} else if let Some(arrow_pos) = msg.body.find(" -> ") {
Some(msg.body[arrow_pos + 4..].trim_end_matches(']').to_string())
} else {
None
};
if let Some(p) = path_str {
if Path::new(&p).exists() {
msg.image_path = Some(p);
}
}
}
msg
}).collect();
if let Some(conv) = self.conversations.get_mut(&conv_id) {
processed.append(&mut conv.messages);
conv.messages = processed;
}
if let Some(read_idx) = self.last_read_index.get_mut(&conv_id) {
*read_idx += prepend_count;
}
if self.active_conversation.as_ref() == Some(&conv_id) {
if let Some(ref mut fi) = self.focused_msg_index {
*fi += prepend_count;
}
}
}
pub fn resize_sidebar(&mut self, delta: i16) {
let new_width = (self.sidebar_width as i16 + delta).clamp(14, 40) as u16;
self.sidebar_width = new_width;
}
pub fn mark_read(&mut self) {
if let Some(ref conv_id) = self.active_conversation {
if let Some(conv) = self.conversations.get(conv_id) {
self.last_read_index
.insert(conv_id.clone(), conv.messages.len());
}
let conv_id = conv_id.clone();
if let Ok(Some(rowid)) = self.db.last_message_rowid(&conv_id) {
db_warn(self.db.save_read_marker(&conv_id, rowid), "save_read_marker");
}
}
}
fn queue_read_receipts_for_conv(&mut self, conv_id: &str, start_index: usize) {
if !self.send_read_receipts {
return;
}
let conv = match self.conversations.get(conv_id) {
Some(c) => c,
None => return,
};
if !conv.accepted {
return;
}
if self.blocked_conversations.contains(conv_id) {
return;
}
let mut by_sender: HashMap<String, Vec<i64>> = HashMap::new();
for msg in conv.messages.iter().skip(start_index) {
if msg.status.is_some() || msg.is_system || msg.sender_id.is_empty() {
continue;
}
if msg.sender_id == self.account {
continue;
}
by_sender
.entry(msg.sender_id.clone())
.or_default()
.push(msg.timestamp_ms);
}
for (recipient, timestamps) in by_sender {
if !timestamps.is_empty() {
self.pending_read_receipts.push((recipient, timestamps));
}
}
}
fn queue_single_read_receipt(&mut self, sender_id: &str, timestamp_ms: i64) {
if !self.send_read_receipts {
return;
}
if sender_id.is_empty() || sender_id == self.account {
return;
}
self.pending_read_receipts
.push((sender_id.to_string(), vec![timestamp_ms]));
}
pub fn cleanup_typing(&mut self) -> bool {
let before = self.typing_indicators.len();
let now = Instant::now();
self.typing_indicators
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 5);
self.typing_indicators.len() != before
}
fn build_typing_request(&self, stop: bool) -> Option<SendRequest> {
let conv_id = self.active_conversation.as_ref()?;
let is_group = self
.conversations
.get(conv_id)
.map(|c| c.is_group)
.unwrap_or(false);
Some(SendRequest::Typing {
recipient: conv_id.clone(),
is_group,
stop,
})
}
pub fn check_typing_timeout(&mut self) -> Option<SendRequest> {
if !self.typing_sent {
return None;
}
let elapsed = self
.typing_last_keypress
.map(|t| t.elapsed() > std::time::Duration::from_secs(5))
.unwrap_or(false);
if elapsed {
self.typing_sent = false;
self.typing_last_keypress = None;
return self.build_typing_request(true);
}
None
}
pub fn clear_kitty_state(&mut self) {
self.kitty_transmitted.clear();
self.kitty_pending_transmits.clear();
self.native_image_cache.clear();
self.iterm2_crop_cache.clear();
}
fn reset_typing_with_stop(&mut self) {
if self.typing_sent {
self.pending_typing_stop = self.build_typing_request(true);
}
self.typing_sent = false;
self.typing_last_keypress = None;
}
pub fn handle_global_key(&mut self, modifiers: KeyModifiers, code: KeyCode) -> bool {
let action = self.keybindings.resolve(modifiers, code, BindingMode::Global);
if self.quit_confirm && !matches!(action, Some(KeyAction::Quit)) {
self.quit_confirm = false;
self.update_status();
}
match action {
Some(KeyAction::Quit) => {
if self.input_buffer.is_empty() || self.quit_confirm {
self.should_quit = true;
} else {
self.quit_confirm = true;
}
true
}
Some(KeyAction::NextConversation) if !self.autocomplete_visible => {
self.next_conversation();
true
}
Some(KeyAction::PrevConversation) => {
self.prev_conversation();
true
}
Some(KeyAction::ResizeSidebarLeft) => {
self.resize_sidebar(-2);
true
}
Some(KeyAction::ResizeSidebarRight) => {
self.resize_sidebar(2);
true
}
Some(KeyAction::PageScrollUp) => {
self.scroll_offset = self.scroll_offset.saturating_add(5);
self.focused_msg_index = None;
true
}
Some(KeyAction::PageScrollDown) => {
self.scroll_offset = self.scroll_offset.saturating_sub(5);
self.focused_msg_index = None;
true
}
_ => false,
}
}
pub fn handle_overlay_key(&mut self, code: KeyCode) -> (bool, Option<SendRequest>) {
if self.show_poll_vote {
let send = self.handle_poll_vote_key(code);
return (true, send);
}
if self.show_pin_duration {
let send = self.handle_pin_duration_key(code);
return (true, send);
}
if self.show_action_menu {
let send = self.handle_action_menu_key(code);
return (true, send);
}
if self.show_delete_confirm {
let send = self.handle_delete_confirm_key(code);
return (true, send);
}
if self.show_file_browser {
self.handle_file_browser_key(code);
return (true, None);
}
if self.show_reaction_picker {
let send = self.handle_reaction_picker_key(code);
return (true, send);
}
if self.show_message_request {
let send = self.handle_message_request_key(code);
return (true, send);
}
if self.group_menu_state.is_some() {
let send = self.handle_group_menu_key(code);
return (true, send);
}
if self.show_about {
self.show_about = false;
return (true, None);
}
if self.show_profile {
let send = self.handle_profile_key(code);
return (true, send);
}
if self.show_help {
self.show_help = false;
return (true, None);
}
if self.show_verify {
let send = self.handle_verify_key(code);
return (true, send);
}
if self.show_forward {
let send = self.handle_forward_key(code);
return (true, send);
}
if self.show_contacts {
self.handle_contacts_key(code);
return (true, None);
}
if self.show_search {
self.handle_search_key(code);
return (true, None);
}
if self.show_settings_profile_manager {
self.handle_settings_profile_manager_key(code);
return (true, None);
}
if self.show_theme_picker {
self.handle_theme_key(code);
return (true, None);
}
if self.show_keybindings {
self.handle_keybindings_key(code);
return (true, None);
}
if self.show_settings {
self.handle_settings_key(code);
return (true, None);
}
if self.autocomplete_visible {
let send = self.handle_autocomplete_key(code);
return (true, send);
}
(false, None)
}
pub fn handle_normal_key(&mut self, modifiers: KeyModifiers, code: KeyCode) -> Option<SendRequest> {
match self.keybindings.resolve(modifiers, code, BindingMode::Normal) {
Some(KeyAction::ScrollDown) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); self.focused_msg_index = None; None }
Some(KeyAction::ScrollUp) => { self.scroll_offset = self.scroll_offset.saturating_add(1); self.focused_msg_index = None; None }
Some(KeyAction::FocusNextMessage) => { self.jump_to_adjacent_message(false); None }
Some(KeyAction::FocusPrevMessage) => { self.jump_to_adjacent_message(true); None }
Some(KeyAction::HalfPageDown) => { self.scroll_offset = self.scroll_offset.saturating_sub(10); self.focused_msg_index = None; None }
Some(KeyAction::HalfPageUp) => { self.scroll_offset = self.scroll_offset.saturating_add(10); self.focused_msg_index = None; None }
Some(KeyAction::ScrollToTop) => {
if let Some(ref id) = self.active_conversation {
if let Some(conv) = self.conversations.get(id) {
self.scroll_offset = conv.messages.len();
}
}
self.focused_msg_index = None;
None
}
Some(KeyAction::ScrollToBottom) => { self.scroll_offset = 0; self.focused_msg_index = None; None }
Some(KeyAction::InsertAtCursor) => { self.mode = InputMode::Insert; None }
Some(KeyAction::InsertAfterCursor) => {
self.input_cursor = next_char_pos(&self.input_buffer, self.input_cursor);
self.mode = InputMode::Insert;
None
}
Some(KeyAction::InsertLineStart) => { self.input_cursor = self.current_line_start(); self.mode = InputMode::Insert; None }
Some(KeyAction::InsertLineEnd) => { self.input_cursor = self.current_line_end(); self.mode = InputMode::Insert; None }
Some(KeyAction::OpenLineBelow) => { self.input_buffer.clear(); self.input_cursor = 0; self.mode = InputMode::Insert; None }
Some(KeyAction::CursorLeft) => { self.input_cursor = prev_char_pos(&self.input_buffer, self.input_cursor); None }
Some(KeyAction::CursorRight) => {
self.input_cursor = next_char_pos(&self.input_buffer, self.input_cursor);
None
}
Some(KeyAction::LineStart) => { self.input_cursor = self.current_line_start(); None }
Some(KeyAction::LineEnd) => { self.input_cursor = self.current_line_end(); None }
Some(KeyAction::WordForward) => {
let buf = &self.input_buffer;
let mut pos = self.input_cursor;
while pos < buf.len() {
let c = buf[pos..].chars().next().unwrap();
if c.is_whitespace() { break; }
pos += c.len_utf8();
}
while pos < buf.len() {
let c = buf[pos..].chars().next().unwrap();
if !c.is_whitespace() { break; }
pos += c.len_utf8();
}
self.input_cursor = pos;
None
}
Some(KeyAction::WordBack) => {
let buf = &self.input_buffer;
let mut pos = self.input_cursor;
while pos > 0 {
let prev = buf[..pos].chars().next_back().unwrap();
if !prev.is_whitespace() { break; }
pos -= prev.len_utf8();
}
while pos > 0 {
let prev = buf[..pos].chars().next_back().unwrap();
if prev.is_whitespace() { break; }
pos -= prev.len_utf8();
}
self.input_cursor = pos;
None
}
Some(KeyAction::DeleteChar) => {
if self.input_cursor < self.input_buffer.len() {
self.input_buffer.remove(self.input_cursor);
if self.input_cursor > 0 && self.input_cursor >= self.input_buffer.len() {
self.input_cursor = prev_char_pos(&self.input_buffer, self.input_buffer.len());
}
}
None
}
Some(KeyAction::DeleteToEnd) => {
let line_end = self.current_line_end();
self.input_buffer.drain(self.input_cursor..line_end);
None
}
Some(KeyAction::StartSearch) => {
self.input_buffer = "/".to_string();
self.input_cursor = 1;
self.mode = InputMode::Insert;
self.update_autocomplete();
None
}
Some(KeyAction::ClearInput) => {
if !self.input_buffer.is_empty() {
self.input_buffer.clear();
self.input_cursor = 0;
self.pending_mentions.clear();
}
None
}
Some(KeyAction::CopyMessage) => { self.copy_selected_message(false); None }
Some(KeyAction::CopyAllMessages) => { self.copy_selected_message(true); None }
Some(KeyAction::React) => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.show_reaction_picker = true;
self.reaction_picker_index = 0;
}
None
}
Some(KeyAction::Quote) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
let author_phone = msg.sender_id.clone();
let snippet: String = if msg.body.chars().count() > 50 {
format!("{}…", msg.body.chars().take(50).collect::<String>())
} else {
msg.body.clone()
};
let ts = msg.timestamp_ms;
let phone = if author_phone.is_empty() || author_phone == "you" {
self.account.clone()
} else {
author_phone
};
self.reply_target = Some((phone, snippet, ts));
self.mode = InputMode::Insert;
}
}
None
}
Some(KeyAction::EditMessage) => {
if let Some(msg) = self.selected_message() {
if msg.sender == "you" && !msg.is_deleted && !msg.is_system {
let ts = msg.timestamp_ms;
let body = msg.body.clone();
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
self.editing_message = Some((ts, conv_id));
self.input_buffer = body;
self.input_cursor = self.input_buffer.len();
self.mode = InputMode::Insert;
}
}
}
None
}
Some(KeyAction::ForwardMessage) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.forward_body = msg.body.clone();
self.open_forward_picker();
}
}
None
}
Some(KeyAction::DeleteMessage) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.show_delete_confirm = true;
}
}
None
}
Some(KeyAction::NextSearchResult) => {
if !self.search_results.is_empty() { self.jump_to_search_result(true); }
None
}
Some(KeyAction::PrevSearchResult) => {
if !self.search_results.is_empty() { self.jump_to_search_result(false); }
None
}
Some(KeyAction::OpenActionMenu) => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.show_action_menu = true;
self.action_menu_index = 0;
}
None
}
Some(KeyAction::PinMessage) => self.execute_pin_toggle(),
_ => None,
}
}
pub fn handle_insert_key(&mut self, modifiers: KeyModifiers, code: KeyCode) -> Option<SendRequest> {
match self.keybindings.resolve(modifiers, code, BindingMode::Insert) {
Some(KeyAction::ExitInsert) => {
self.mode = InputMode::Normal;
self.autocomplete_visible = false;
self.reply_target = None;
self.editing_message = None;
if self.typing_sent {
self.typing_sent = false;
self.typing_last_keypress = None;
return self.build_typing_request(true);
}
None
}
Some(KeyAction::InsertNewline) => {
self.input_buffer.insert(self.input_cursor, '\n');
self.input_cursor += 1;
self.autocomplete_visible = false;
self.typing_last_keypress = Some(Instant::now());
if !self.typing_sent
&& !self.input_buffer.starts_with('/')
&& self.active_conversation.as_ref().is_some_and(|id| !self.blocked_conversations.contains(id))
{
self.typing_sent = true;
return self.build_typing_request(false);
}
None
}
Some(KeyAction::SendMessage) => {
let was_typing = self.typing_sent;
self.typing_sent = false;
self.typing_last_keypress = None;
let result = self.handle_input();
if result.is_some() {
result
} else if was_typing {
self.build_typing_request(true)
} else {
None
}
}
Some(KeyAction::DeleteWordBack) => {
self.delete_word_back();
None
}
Some(KeyAction::ScrollDown) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); self.focused_msg_index = None; None }
Some(KeyAction::ScrollUp) => { self.scroll_offset = self.scroll_offset.saturating_add(1); self.focused_msg_index = None; None }
Some(KeyAction::CursorLeft) => { self.input_cursor = prev_char_pos(&self.input_buffer, self.input_cursor); None }
Some(KeyAction::CursorRight) => {
self.input_cursor = next_char_pos(&self.input_buffer, self.input_cursor);
None
}
Some(KeyAction::LineStart) => { self.input_cursor = self.current_line_start(); None }
Some(KeyAction::LineEnd) => { self.input_cursor = self.current_line_end(); None }
Some(KeyAction::DeleteChar) => {
if self.input_cursor < self.input_buffer.len() {
self.input_buffer.remove(self.input_cursor);
}
None
}
Some(KeyAction::DeleteToEnd) => {
let line_end = self.current_line_end();
self.input_buffer.drain(self.input_cursor..line_end);
None
}
Some(KeyAction::CopyMessage) => { self.copy_selected_message(false); None }
Some(KeyAction::CopyAllMessages) => { self.copy_selected_message(true); None }
Some(KeyAction::React) => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.show_reaction_picker = true;
self.reaction_picker_index = 0;
}
None
}
Some(KeyAction::Quote) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
let author_phone = msg.sender_id.clone();
let snippet: String = if msg.body.chars().count() > 50 {
format!("{}…", msg.body.chars().take(50).collect::<String>())
} else {
msg.body.clone()
};
let ts = msg.timestamp_ms;
let phone = if author_phone.is_empty() || author_phone == "you" {
self.account.clone()
} else {
author_phone
};
self.reply_target = Some((phone, snippet, ts));
}
}
None
}
Some(KeyAction::EditMessage) => {
if let Some(msg) = self.selected_message() {
if msg.sender == "you" && !msg.is_deleted && !msg.is_system {
let ts = msg.timestamp_ms;
let body = msg.body.clone();
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
self.editing_message = Some((ts, conv_id));
self.input_buffer = body;
self.input_cursor = self.input_buffer.len();
}
}
}
None
}
Some(KeyAction::ForwardMessage) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.forward_body = msg.body.clone();
self.open_forward_picker();
}
}
None
}
Some(KeyAction::DeleteMessage) => {
if let Some(msg) = self.selected_message() {
if !msg.is_system && !msg.is_deleted {
self.show_delete_confirm = true;
}
}
None
}
Some(KeyAction::NextSearchResult) => {
if !self.search_results.is_empty() { self.jump_to_search_result(true); }
None
}
Some(KeyAction::PrevSearchResult) => {
if !self.search_results.is_empty() { self.jump_to_search_result(false); }
None
}
Some(KeyAction::OpenActionMenu) => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.show_action_menu = true;
self.action_menu_index = 0;
}
None
}
Some(KeyAction::PinMessage) => self.execute_pin_toggle(),
_ => {
let needs_ac_update = matches!(
code,
KeyCode::Backspace | KeyCode::Delete | KeyCode::Char(_)
);
self.apply_input_edit(code);
if needs_ac_update {
self.update_autocomplete();
}
if matches!(code, KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete) {
self.typing_last_keypress = Some(Instant::now());
if self.input_buffer.is_empty() && self.typing_sent {
self.typing_sent = false;
self.typing_last_keypress = None;
return self.build_typing_request(true);
}
if !self.typing_sent
&& !self.input_buffer.is_empty()
&& !self.input_buffer.starts_with('/')
&& self.active_conversation.as_ref().is_some_and(|id| !self.blocked_conversations.contains(id))
{
self.typing_sent = true;
return self.build_typing_request(false);
}
}
None
}
}
}
pub fn handle_signal_event(&mut self, event: SignalEvent) {
match event {
SignalEvent::MessageReceived(msg) => self.handle_message(msg),
SignalEvent::ReceiptReceived { sender, receipt_type, timestamps } => {
self.handle_receipt(&sender, &receipt_type, ×tamps);
}
SignalEvent::SendTimestamp { rpc_id, server_ts } => {
self.handle_send_timestamp(&rpc_id, server_ts);
}
SignalEvent::SendFailed { rpc_id } => {
self.status_message = "send failed".to_string();
self.handle_send_failed(&rpc_id);
}
SignalEvent::TypingIndicator { sender, sender_name, is_typing, group_id } => {
if let Some(ref name) = sender_name {
self.contact_names.entry(sender.clone()).or_insert_with(|| name.clone());
}
let conv_key = group_id.as_ref().unwrap_or(&sender).clone();
if is_typing {
self.typing_indicators.insert(conv_key, (sender.clone(), Instant::now()));
} else {
self.typing_indicators.remove(&conv_key);
}
}
SignalEvent::ReactionReceived {
conv_id, emoji, sender, sender_name, target_author, target_timestamp, is_remove,
} => {
if let Some(ref name) = sender_name {
self.contact_names.entry(sender.clone()).or_insert_with(|| name.clone());
}
self.handle_reaction(&conv_id, &emoji, &sender, &target_author, target_timestamp, is_remove);
}
SignalEvent::EditReceived {
conv_id, sender: _, sender_name: _, target_timestamp, new_body, new_timestamp: _, is_outgoing: _,
} => {
self.handle_edit_received(&conv_id, target_timestamp, &new_body);
}
SignalEvent::RemoteDeleteReceived {
conv_id, sender: _, target_timestamp,
} => {
self.handle_remote_delete(&conv_id, target_timestamp);
}
SignalEvent::PinReceived {
conv_id, sender, sender_name, target_author: _, target_timestamp,
} => {
if let Some(ref name) = sender_name {
self.contact_names.entry(sender.clone()).or_insert_with(|| name.clone());
}
self.handle_pin_received(&conv_id, &sender, target_timestamp, true);
}
SignalEvent::UnpinReceived {
conv_id, sender, sender_name, target_author: _, target_timestamp,
} => {
if let Some(ref name) = sender_name {
self.contact_names.entry(sender.clone()).or_insert_with(|| name.clone());
}
self.handle_pin_received(&conv_id, &sender, target_timestamp, false);
}
SignalEvent::PollCreated { conv_id, timestamp, poll_data } => {
self.handle_poll_created(&conv_id, timestamp, poll_data);
}
SignalEvent::PollVoteReceived {
conv_id, target_timestamp, voter, voter_name, option_indexes, vote_count,
} => {
if let Some(ref name) = voter_name {
self.contact_names.entry(voter.clone()).or_insert_with(|| name.clone());
}
self.handle_poll_vote(&conv_id, target_timestamp, &voter, voter_name.as_deref(), &option_indexes, vote_count);
}
SignalEvent::PollTerminated { conv_id, target_timestamp } => {
self.handle_poll_terminated(&conv_id, target_timestamp);
}
SignalEvent::SystemMessage { conv_id, body, timestamp, timestamp_ms } => {
self.handle_system_message(&conv_id, &body, timestamp, timestamp_ms);
}
SignalEvent::ExpirationTimerChanged { conv_id, seconds, body, timestamp, timestamp_ms } => {
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let conv_name = self.contact_names.get(&conv_id).cloned().unwrap_or_else(|| conv_id.to_string());
self.get_or_create_conversation(&conv_id, &conv_name, is_group);
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.expiration_timer = seconds;
}
self.db_warn_visible(self.db.update_expiration_timer(&conv_id, seconds), "update_expiration_timer");
self.handle_system_message(&conv_id, &body, timestamp, timestamp_ms);
}
SignalEvent::ReadSyncReceived { read_messages } => {
self.handle_read_sync(read_messages);
}
SignalEvent::ContactList(contacts) => self.handle_contact_list(contacts),
SignalEvent::GroupList(groups) => self.handle_group_list(groups),
SignalEvent::IdentityList(identities) => self.handle_identity_list(identities),
SignalEvent::Error(ref err) => {
crate::debug_log::logf(format_args!("signal event error: {err}"));
self.status_message = format!("error: {err}");
}
}
}
fn handle_message(&mut self, msg: SignalMessage) {
let conv_id = if let Some(ref gid) = msg.group_id {
gid.clone()
} else if msg.is_outgoing {
match msg.destination {
Some(ref dest) => dest.clone(),
None => return,
}
} else {
msg.source.clone()
};
self.move_conversation_to_top(&conv_id);
if !msg.is_outgoing {
if let Some(ref name) = msg.source_name {
self.contact_names.entry(msg.source.clone()).or_insert_with(|| name.clone());
}
}
let is_group = msg.group_id.is_some();
let conv_name = msg
.group_name
.as_deref()
.or(if is_group { None } else { msg.source_name.as_deref() })
.unwrap_or_else(|| {
self.contact_names.get(&conv_id).map(|s| s.as_str()).unwrap_or(&conv_id)
})
.to_string();
let sender_display = if msg.is_outgoing {
"you".to_string()
} else {
msg.source_name
.clone()
.or_else(|| self.contact_names.get(&msg.source).cloned())
.unwrap_or_else(|| short_name(&msg.source))
};
let sender_id = if msg.is_outgoing {
self.account.clone()
} else {
msg.source.clone()
};
let is_new = !self.conversations.contains_key(&conv_id);
self.get_or_create_conversation(&conv_id, &conv_name, is_group);
if is_new && !msg.is_outgoing && !is_group && !self.contact_names.contains_key(&conv_id) {
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.accepted = false;
}
self.db_warn_visible(self.db.update_accepted(&conv_id, false), "update_accepted");
}
let ts_rfc3339 = msg.timestamp.to_rfc3339();
let msg_ts_ms = msg.timestamp.timestamp_millis();
let msg_status = if msg.is_outgoing { Some(MessageStatus::Sent) } else { None };
let msg_expires_in = msg.expires_in_seconds;
let msg_expiration_start = if msg_expires_in > 0 {
if msg.is_outgoing { msg_ts_ms } else { Utc::now().timestamp_millis() }
} else {
0
};
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if conv.expiration_timer != msg_expires_in {
conv.expiration_timer = msg_expires_in;
db_warn(self.db.update_expiration_timer(&conv_id, msg_expires_in), "update_expiration_timer");
}
}
let resolved_body = msg.body.as_ref().map(|body| {
self.resolve_mentions(body, &msg.mentions)
});
let resolved_styles = resolved_body.as_ref().map(|(resolved, _)| {
self.resolve_text_styles(resolved, &msg.text_styles, &msg.mentions)
}).unwrap_or_default();
let msg_quote = msg.quote.as_ref().map(|(ts, author_phone, body)| {
let author_display = self.contact_names.get(author_phone)
.cloned()
.unwrap_or_else(|| if *author_phone == self.account { "you".to_string() } else { author_phone.clone() });
(Quote { author: author_display, body: body.clone(), timestamp_ms: *ts, author_id: author_phone.clone() }, author_phone.clone(), body.clone(), *ts)
});
let display_quote = msg_quote.as_ref().map(|(q, _, _, _)| q.clone());
let wire_quote_author = msg_quote.as_ref().map(|(_, a, _, _)| a.clone());
let wire_quote_body = msg_quote.as_ref().map(|(_, _, b, _)| b.clone());
let wire_quote_ts = msg_quote.as_ref().map(|(_, _, _, t)| *t);
let mut push_msg = |body: String,
image_lines: Option<Vec<Line<'static>>>,
image_path: Option<String>,
mention_ranges: Vec<(usize, usize)>,
style_ranges: Vec<(usize, usize, StyleType)>,
quote: Option<Quote>| {
let deferred_poll = self.pending_polls.remove(&(conv_id.clone(), msg_ts_ms));
if let Some(conv) = self.conversations.get_mut(&conv_id) {
let pos = conv.messages.partition_point(|m| m.timestamp_ms <= msg_ts_ms);
conv.messages.insert(pos, DisplayMessage {
sender: sender_display.clone(),
timestamp: msg.timestamp,
body: body.clone(),
is_system: false,
image_lines,
image_path,
status: msg_status,
timestamp_ms: msg_ts_ms,
reactions: Vec::new(),
mention_ranges,
style_ranges,
quote,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: sender_id.clone(),
expires_in_seconds: msg_expires_in,
expiration_start_ms: msg_expiration_start,
poll_data: deferred_poll,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
if let Some(read_idx) = self.last_read_index.get_mut(&conv_id) {
if pos <= *read_idx {
*read_idx += 1;
}
}
if msg_expires_in > 0 {
self.expiring_msg_count += 1;
}
}
db_warn(
self.db.insert_message_full(
&conv_id, &sender_display, &ts_rfc3339, &body, false, msg_status, msg_ts_ms,
&sender_id,
wire_quote_author.as_deref(),
wire_quote_body.as_deref(),
wire_quote_ts,
msg_expires_in,
msg_expiration_start,
),
"insert_message",
);
};
if let Some((resolved, ranges)) = resolved_body {
push_msg(resolved, None, None, ranges, resolved_styles, display_quote);
}
for att in &msg.attachments {
let label = att.filename.as_deref().unwrap_or(&att.content_type);
let is_image = matches!(
att.content_type.as_str(),
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
);
let path_info = att
.local_path
.as_deref()
.map(|p| format!("({})", path_to_file_uri(p)))
.unwrap_or_default();
if is_image {
let rendered = att.local_path
.as_deref()
.and_then(|p| image_render::render_image(Path::new(p), 40));
push_msg(
format!("[image: {label}]{path_info}"),
rendered,
att.local_path.clone(),
Vec::new(),
Vec::new(),
None,
);
} else {
push_msg(format!("[attachment: {label}]{path_info}"), None, None, Vec::new(), Vec::new(), None);
}
}
if let Some(preview) = msg.previews.into_iter().next() {
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(dm) = conv.messages.iter_mut().rev()
.find(|m| m.timestamp_ms == msg_ts_ms && !m.body.starts_with('['))
{
let (img_lines, img_path) = if self.show_link_previews && self.inline_images {
if let Some(ref p) = preview.image_path {
(image_render::render_image(Path::new(p), 30), Some(p.clone()))
} else {
(None, None)
}
} else {
(None, None)
};
dm.preview = Some(preview.clone());
dm.preview_image_lines = img_lines;
dm.preview_image_path = img_path;
}
}
db_warn(self.db.upsert_link_preview(&conv_id, msg_ts_ms, &preview), "upsert_link_preview");
}
let is_active = self
.active_conversation
.as_ref()
.map(|a| a == &conv_id)
.unwrap_or(false);
if !is_active && !msg.is_outgoing {
if let Some(c) = self.conversations.get_mut(&conv_id) {
c.unread += 1;
}
let conv_accepted = self.conversations.get(&conv_id).map(|c| c.accepted).unwrap_or(true);
let not_muted_or_blocked = conv_accepted
&& !self.muted_conversations.contains(&conv_id)
&& !self.blocked_conversations.contains(&conv_id);
let type_enabled = if is_group { self.notify_group } else { self.notify_direct };
if type_enabled && not_muted_or_blocked {
self.pending_bell = true;
}
if self.desktop_notifications && not_muted_or_blocked {
let notif_body = msg.body.as_deref().unwrap_or("");
let notif_group = if is_group {
self.conversations.get(&conv_id).map(|c| c.name.clone())
} else {
None
};
show_desktop_notification(
&sender_display,
notif_body,
is_group,
notif_group.as_deref(),
&self.notification_preview,
);
}
}
let conv_accepted = self.conversations.get(&conv_id).map(|c| c.accepted).unwrap_or(true);
if is_active {
if !msg.is_outgoing && conv_accepted && !self.blocked_conversations.contains(&conv_id) {
self.queue_single_read_receipt(&sender_id, msg_ts_ms);
}
if let Some(conv) = self.conversations.get(&conv_id) {
self.last_read_index.insert(conv_id.clone(), conv.messages.len());
}
if let Ok(Some(rowid)) = self.db.last_message_rowid(&conv_id) {
db_warn(self.db.save_read_marker(&conv_id, rowid), "save_read_marker");
}
}
}
fn handle_system_message(
&mut self,
conv_id: &str,
body: &str,
timestamp: DateTime<Utc>,
timestamp_ms: i64,
) {
let is_group = self.conversations.get(conv_id).map(|c| c.is_group).unwrap_or(false);
let conv_name = self.contact_names.get(conv_id).cloned().unwrap_or_else(|| conv_id.to_string());
self.get_or_create_conversation(conv_id, &conv_name, is_group);
if let Some(conv) = self.conversations.get_mut(conv_id) {
let pos = conv.messages.partition_point(|m| m.timestamp_ms <= timestamp_ms);
conv.messages.insert(pos, DisplayMessage {
sender: String::new(),
timestamp,
body: body.to_string(),
is_system: true,
image_lines: None,
image_path: None,
status: None,
timestamp_ms,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
if let Some(read_idx) = self.last_read_index.get_mut(conv_id) {
if pos <= *read_idx {
*read_idx += 1;
}
}
}
let ts_rfc3339 = timestamp.to_rfc3339();
self.db_warn_visible(
self.db.insert_message(conv_id, "", &ts_rfc3339, body, true, None, timestamp_ms),
"insert_system_message",
);
}
pub fn sweep_expired_messages(&mut self) -> bool {
if self.expiring_msg_count == 0 {
return false;
}
let now_ms = Utc::now().timestamp_millis();
let mut removed_count: usize = 0;
for conv in self.conversations.values_mut() {
let before = conv.messages.len();
conv.messages.retain(|m| {
if m.expires_in_seconds > 0 && m.expiration_start_ms > 0 {
let expiry = m.expiration_start_ms + m.expires_in_seconds * 1000;
expiry >= now_ms
} else {
true
}
});
removed_count += before - conv.messages.len();
}
self.expiring_msg_count = self.expiring_msg_count.saturating_sub(removed_count);
let removed = removed_count > 0;
if let Ok(n) = self.db.delete_expired_messages(now_ms) {
if n > 0 {
return true;
}
}
removed
}
fn handle_reaction(
&mut self,
conv_id: &str,
emoji: &str,
sender: &str,
target_author: &str,
target_timestamp: i64,
is_remove: bool,
) {
let account = &self.account;
let target_display = self.contact_names.get(target_author).cloned();
let is_self = sender == self.account;
let sender_display = if is_self {
"you".to_string()
} else {
self.contact_names
.get(sender)
.cloned()
.unwrap_or_else(|| sender.to_string())
};
if let Some(conv) = self.conversations.get_mut(conv_id) {
let found = conv.find_msg_idx(target_timestamp).and_then(|idx| {
let m = &conv.messages[idx];
let matches = if m.sender == "you" {
target_author == account.as_str()
} else {
m.sender == target_author
|| target_display.as_deref() == Some(m.sender.as_str())
};
if matches { Some(idx) } else { None }
});
if let Some(msg) = found.map(|idx| &mut conv.messages[idx]) {
if is_remove {
msg.reactions.retain(|r| r.sender != sender_display);
} else {
if let Some(existing) = msg.reactions.iter_mut().find(|r| r.sender == sender_display) {
existing.emoji = emoji.to_string();
} else {
msg.reactions.push(Reaction {
emoji: emoji.to_string(),
sender: sender_display,
});
}
}
}
}
if is_remove {
self.db_warn_visible(
self.db.remove_reaction(conv_id, target_timestamp, target_author, sender),
"remove_reaction",
);
} else {
self.db_warn_visible(
self.db.upsert_reaction(conv_id, target_timestamp, target_author, sender, emoji),
"upsert_reaction",
);
}
}
pub fn handle_delete_confirm_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('y') => {
self.show_delete_confirm = false;
let conv_id = self.active_conversation.clone()?;
let conv = self.conversations.get(&conv_id)?;
let is_group = conv.is_group;
let index = self.focused_msg_index.unwrap_or_else(|| {
conv.messages.len().saturating_sub(1)
});
let msg = conv.messages.get(index)?;
let is_outgoing = msg.sender == "you";
let target_timestamp = msg.timestamp_ms;
let conv = self.conversations.get_mut(&conv_id)?;
let msg = conv.messages.get_mut(index)?;
msg.is_deleted = true;
msg.body = "[deleted]".to_string();
msg.reactions.clear();
self.db_warn_visible(
self.db.mark_message_deleted(&conv_id, target_timestamp),
"mark_message_deleted",
);
if is_outgoing {
return Some(SendRequest::RemoteDelete {
recipient: conv_id,
is_group,
target_timestamp,
});
}
None
}
KeyCode::Char('l') => {
self.show_delete_confirm = false;
let conv_id = self.active_conversation.clone()?;
let conv = self.conversations.get(&conv_id)?;
let index = self.focused_msg_index.unwrap_or_else(|| {
conv.messages.len().saturating_sub(1)
});
let msg = conv.messages.get(index)?;
let target_timestamp = msg.timestamp_ms;
let conv = self.conversations.get_mut(&conv_id)?;
let msg = conv.messages.get_mut(index)?;
msg.is_deleted = true;
msg.body = "[deleted]".to_string();
msg.reactions.clear();
self.db_warn_visible(
self.db.mark_message_deleted(&conv_id, target_timestamp),
"mark_message_deleted",
);
None
}
KeyCode::Char('n') | KeyCode::Esc => {
self.show_delete_confirm = false;
None
}
_ => None,
}
}
fn handle_edit_received(&mut self, conv_id: &str, target_timestamp: i64, new_body: &str) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
conv.messages[idx].body = new_body.to_string();
conv.messages[idx].is_edited = true;
}
}
self.db_warn_visible(
self.db.update_message_body(conv_id, target_timestamp, new_body),
"update_message_body",
);
}
fn handle_remote_delete(&mut self, conv_id: &str, target_timestamp: i64) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
conv.messages[idx].is_deleted = true;
conv.messages[idx].body = "[deleted]".to_string();
conv.messages[idx].reactions.clear();
}
}
self.db_warn_visible(
self.db.mark_message_deleted(conv_id, target_timestamp),
"mark_message_deleted",
);
}
fn handle_pin_received(&mut self, conv_id: &str, sender: &str, target_timestamp: i64, pinned: bool) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
conv.messages[idx].is_pinned = pinned;
}
}
self.db_warn_visible(
self.db.set_message_pinned(conv_id, target_timestamp, pinned),
"set_message_pinned",
);
let sender_display = if sender == self.account {
"you".to_string()
} else {
self.contact_names.get(sender).cloned().unwrap_or_else(|| sender.to_string())
};
let action = if pinned { "pinned" } else { "unpinned" };
let body = format!("{sender_display} {action} a message");
let now = Utc::now();
let now_ms = now.timestamp_millis();
self.handle_system_message(conv_id, &body, now, now_ms);
}
fn handle_poll_created(&mut self, conv_id: &str, timestamp: i64, poll_data: PollData) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(timestamp) {
conv.messages[idx].poll_data = Some(poll_data.clone());
} else {
self.pending_polls.insert((conv_id.to_string(), timestamp), poll_data.clone());
}
}
self.db_warn_visible(
self.db.upsert_poll_data(conv_id, timestamp, &poll_data),
"upsert_poll_data",
);
}
fn handle_poll_vote(
&mut self,
conv_id: &str,
target_timestamp: i64,
voter: &str,
voter_name: Option<&str>,
option_indexes: &[i64],
vote_count: i64,
) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
let msg = &mut conv.messages[idx];
if let Some(existing) = msg.poll_votes.iter_mut().find(|v| v.voter == voter) {
existing.option_indexes = option_indexes.to_vec();
existing.vote_count = vote_count;
existing.voter_name = voter_name.map(|s| s.to_string());
} else {
msg.poll_votes.push(PollVote {
voter: voter.to_string(),
voter_name: voter_name.map(|s| s.to_string()),
option_indexes: option_indexes.to_vec(),
vote_count,
});
}
}
}
self.db_warn_visible(
self.db.upsert_poll_vote(conv_id, target_timestamp, voter, voter_name, option_indexes, vote_count),
"upsert_poll_vote",
);
}
fn handle_poll_terminated(&mut self, conv_id: &str, target_timestamp: i64) {
if let Some(conv) = self.conversations.get_mut(conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
if let Some(ref mut poll) = conv.messages[idx].poll_data {
poll.closed = true;
}
}
}
self.db_warn_visible(
self.db.close_poll(conv_id, target_timestamp),
"close_poll",
);
}
fn execute_pin_toggle(&mut self) -> Option<SendRequest> {
let msg = self.selected_message()?;
if msg.is_system || msg.is_deleted {
return None;
}
let was_pinned = msg.is_pinned;
let target_timestamp = msg.timestamp_ms;
let author_phone = msg.sender_id.clone();
let conv_id = self.active_conversation.clone()?;
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let target_author = if author_phone.is_empty() || author_phone == "you" {
self.account.clone()
} else {
author_phone
};
if was_pinned {
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(idx) = conv.find_msg_idx(target_timestamp) {
conv.messages[idx].is_pinned = false;
}
}
self.db_warn_visible(
self.db.set_message_pinned(&conv_id, target_timestamp, false),
"set_message_pinned",
);
self.scroll_offset = 0;
self.focused_msg_index = None;
let body = "you unpinned a message";
let now = Utc::now();
let now_ms = now.timestamp_millis();
self.handle_system_message(&conv_id, body, now, now_ms);
Some(SendRequest::Unpin {
recipient: conv_id,
is_group,
target_author,
target_timestamp,
})
} else {
self.pin_pending = Some(PinPending {
conv_id,
is_group,
target_author,
target_timestamp,
});
self.show_pin_duration = true;
self.pin_duration_index = 0;
None
}
}
pub fn handle_pin_duration_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.pin_duration_index < PIN_DURATIONS.len() - 1 {
self.pin_duration_index += 1;
}
None
}
KeyCode::Char('k') | KeyCode::Up => {
self.pin_duration_index = self.pin_duration_index.saturating_sub(1);
None
}
KeyCode::Enter => {
let duration = PIN_DURATIONS[self.pin_duration_index].0;
self.show_pin_duration = false;
let pending = self.pin_pending.take()?;
if let Some(conv) = self.conversations.get_mut(&pending.conv_id) {
if let Some(idx) = conv.find_msg_idx(pending.target_timestamp) {
conv.messages[idx].is_pinned = true;
}
}
self.db_warn_visible(
self.db.set_message_pinned(&pending.conv_id, pending.target_timestamp, true),
"set_message_pinned",
);
self.scroll_offset = 0;
self.focused_msg_index = None;
let body = "you pinned a message";
let now = Utc::now();
let now_ms = now.timestamp_millis();
self.handle_system_message(&pending.conv_id, body, now, now_ms);
Some(SendRequest::Pin {
recipient: pending.conv_id,
is_group: pending.is_group,
target_author: pending.target_author,
target_timestamp: pending.target_timestamp,
pin_duration: duration,
})
}
KeyCode::Esc => {
self.show_pin_duration = false;
self.pin_pending = None;
None
}
_ => None,
}
}
pub fn handle_profile_key(&mut self, code: KeyCode) -> Option<SendRequest> {
const FIELD_COUNT: usize = 4;
const SAVE_INDEX: usize = FIELD_COUNT;
if self.profile_editing {
match code {
KeyCode::Esc => {
self.profile_editing = false;
}
KeyCode::Enter => {
self.profile_fields[self.profile_index] = self.profile_edit_buffer.clone();
self.profile_editing = false;
}
KeyCode::Backspace => {
self.profile_edit_buffer.pop();
}
KeyCode::Char(c) => {
self.profile_edit_buffer.push(c);
}
_ => {}
}
return None;
}
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.profile_index < SAVE_INDEX {
self.profile_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.profile_index > 0 {
self.profile_index -= 1;
}
}
KeyCode::Enter => {
if self.profile_index < FIELD_COUNT {
self.profile_editing = true;
self.profile_edit_buffer = self.profile_fields[self.profile_index].clone();
} else {
let [given_name, family_name, about, about_emoji] = self.profile_fields.clone();
if given_name.trim().is_empty() {
self.status_message = "Given name is required".to_string();
return None;
}
self.show_profile = false;
return Some(SendRequest::UpdateProfile {
given_name,
family_name,
about,
about_emoji,
});
}
}
KeyCode::Esc => {
self.show_profile = false;
}
_ => {}
}
None
}
pub fn handle_poll_vote_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let pending = self.poll_vote_pending.as_ref()?;
let option_count = pending.options.len();
match code {
KeyCode::Char('j') | KeyCode::Down => {
if self.poll_vote_index < option_count.saturating_sub(1) {
self.poll_vote_index += 1;
}
None
}
KeyCode::Char('k') | KeyCode::Up => {
self.poll_vote_index = self.poll_vote_index.saturating_sub(1);
None
}
KeyCode::Char(' ') => {
let allow_multiple = pending.allow_multiple;
if allow_multiple {
if let Some(sel) = self.poll_vote_selections.get_mut(self.poll_vote_index) {
*sel = !*sel;
}
} else {
for sel in &mut self.poll_vote_selections {
*sel = false;
}
if let Some(sel) = self.poll_vote_selections.get_mut(self.poll_vote_index) {
*sel = true;
}
}
None
}
KeyCode::Enter => {
let selected: Vec<i64> = self.poll_vote_selections
.iter()
.enumerate()
.filter(|(_, &sel)| sel)
.map(|(i, _)| i as i64)
.collect();
if selected.is_empty() {
return None;
}
let pending = self.poll_vote_pending.take()?;
self.show_poll_vote = false;
let voter = self.account.clone();
self.handle_poll_vote(&pending.conv_id, pending.poll_timestamp, &voter, None, &selected, 1);
Some(SendRequest::PollVote {
recipient: pending.conv_id,
is_group: pending.is_group,
poll_author: pending.poll_author,
poll_timestamp: pending.poll_timestamp,
option_indexes: selected,
vote_count: 1,
})
}
KeyCode::Esc => {
self.show_poll_vote = false;
self.poll_vote_pending = None;
None
}
_ => None,
}
}
fn handle_read_sync(&mut self, read_messages: Vec<(String, i64)>) {
let mut max_ts_per_conv: HashMap<String, i64> = HashMap::new();
for (sender, timestamp) in &read_messages {
if self.conversations.contains_key(sender.as_str()) {
let entry = max_ts_per_conv.entry(sender.clone()).or_insert(0);
*entry = (*entry).max(*timestamp);
continue;
}
let mut found = false;
for (conv_id, conv) in &self.conversations {
if !conv.is_group {
continue;
}
if conv.messages.iter().any(|m| m.timestamp_ms == *timestamp) {
let entry = max_ts_per_conv.entry(conv_id.clone()).or_insert(0);
*entry = (*entry).max(*timestamp);
found = true;
break;
}
}
if !found {
crate::debug_log::logf(format_args!(
"read_sync: no conversation found for sender={} ts={timestamp}",
crate::debug_log::mask_phone(sender)
));
}
}
for (conv_id, max_ts) in &max_ts_per_conv {
let new_read_idx = if let Some(conv) = self.conversations.get(conv_id) {
conv.messages.partition_point(|m| m.timestamp_ms <= *max_ts)
} else {
continue;
};
let current = self.last_read_index.get(conv_id).copied().unwrap_or(0);
if new_read_idx > current {
self.last_read_index.insert(conv_id.clone(), new_read_idx);
if let Some(conv) = self.conversations.get_mut(conv_id) {
let unread = conv.messages[new_read_idx..]
.iter()
.filter(|m| !m.is_system && m.status.is_none())
.count();
conv.unread = unread;
}
if let Ok(Some(rowid)) = self.db.max_rowid_up_to_timestamp(conv_id, *max_ts) {
db_warn(
self.db.save_read_marker(conv_id, rowid),
"save_read_marker (read_sync)",
);
}
}
}
}
fn handle_contact_list(&mut self, contacts: Vec<Contact>) {
self.loading = false;
self.startup_status.clear();
for contact in contacts {
if let Some(ref name) = contact.name {
if !name.is_empty() {
self.contact_names.insert(contact.number.clone(), name.clone());
}
}
if let Some(ref uuid) = contact.uuid {
if let Some(ref name) = contact.name {
if !name.is_empty() {
self.uuid_to_name.insert(uuid.clone(), name.clone());
}
}
self.number_to_uuid.insert(contact.number.clone(), uuid.clone());
}
if let Some(conv) = self.conversations.get_mut(&contact.number) {
if let Some(ref contact_name) = contact.name {
if !contact_name.is_empty() && conv.name != *contact_name {
conv.name = contact_name.clone();
db_warn(self.db.upsert_conversation(&contact.number, contact_name, false), "upsert_conversation");
}
}
}
}
let to_accept: Vec<String> = self.conversations.iter()
.filter(|(_, c)| !c.accepted && !c.is_group && self.contact_names.contains_key(&c.id))
.map(|(id, _)| id.clone())
.collect();
for id in to_accept {
if let Some(conv) = self.conversations.get_mut(&id) {
conv.accepted = true;
db_warn(self.db.update_accepted(&id, true), "update_accepted");
}
}
self.resolve_stored_names();
}
fn handle_group_list(&mut self, groups: Vec<Group>) {
for group in groups {
if !group.name.is_empty() {
self.contact_names.insert(group.id.clone(), group.name.clone());
}
for (phone, uuid) in &group.member_uuids {
self.number_to_uuid.entry(phone.clone()).or_insert_with(|| uuid.clone());
}
self.groups.insert(group.id.clone(), group.clone());
let conv = self.get_or_create_conversation(&group.id, &group.name, true);
if !group.name.is_empty() && conv.name != group.name {
conv.name = group.name.clone();
db_warn(self.db.upsert_conversation(&group.id, &group.name, true), "upsert_conversation");
}
}
self.resolve_stored_names();
}
fn handle_identity_list(&mut self, identities: Vec<IdentityInfo>) {
self.identity_trust.clear();
for id in &identities {
if let Some(ref number) = id.number {
self.identity_trust.insert(number.clone(), id.trust_level);
}
}
if self.show_verify {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
if is_group {
if let Some(group) = self.groups.get(&conv_id) {
let members: HashSet<&str> = group.members.iter().map(|s| s.as_str()).collect();
self.verify_identities = identities.iter()
.filter(|id| id.number.as_ref().is_some_and(|n| members.contains(n.as_str())))
.cloned()
.collect();
}
} else {
self.verify_identities = identities.iter()
.filter(|id| id.number.as_deref() == Some(conv_id.as_str()))
.cloned()
.collect();
}
if !self.verify_identities.is_empty() && self.verify_index >= self.verify_identities.len() {
self.verify_index = self.verify_identities.len() - 1;
}
}
}
}
fn resolve_stored_names(&mut self) {
let mut phone_to_name: HashMap<String, String> = HashMap::new();
for conv in self.conversations.values() {
for msg in &conv.messages {
if !msg.sender_id.is_empty()
&& msg.sender_id != "you"
&& !msg.sender.is_empty()
&& msg.sender != msg.sender_id
{
phone_to_name.insert(msg.sender_id.clone(), msg.sender.clone());
}
}
}
for (phone, name) in &self.contact_names {
phone_to_name.insert(phone.clone(), name.clone());
}
for conv in self.conversations.values_mut() {
for msg in &mut conv.messages {
for reaction in &mut msg.reactions {
if reaction.sender == "you" {
continue;
}
if reaction.sender == self.account {
reaction.sender = "you".to_string();
} else if let Some(name) = phone_to_name.get(&reaction.sender) {
reaction.sender = name.clone();
}
}
if let Some(ref mut quote) = msg.quote {
if quote.author == self.account {
quote.author = "you".to_string();
} else if let Some(name) = phone_to_name.get("e.author) {
quote.author = name.clone();
}
}
}
}
}
fn resolve_mentions(&self, body: &str, mentions: &[Mention]) -> (String, Vec<(usize, usize)>) {
if mentions.is_empty() {
return (body.to_string(), Vec::new());
}
let mut sorted: Vec<&Mention> = mentions.iter().collect();
sorted.sort_by(|a, b| b.start.cmp(&a.start));
let utf16: Vec<u16> = body.encode_utf16().collect();
let mut result_utf16 = utf16.clone();
for mention in &sorted {
if mention.start >= result_utf16.len() {
continue;
}
let name = self
.uuid_to_name
.get(&mention.uuid)
.cloned()
.unwrap_or_else(|| {
let short = if mention.uuid.len() > 8 {
&mention.uuid[..8]
} else {
&mention.uuid
};
short.to_string()
});
let replacement = format!("@{name}");
let replacement_utf16: Vec<u16> = replacement.encode_utf16().collect();
let end = (mention.start + mention.length).min(result_utf16.len());
result_utf16.splice(mention.start..end, replacement_utf16);
}
let resolved = String::from_utf16_lossy(&result_utf16);
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut sorted_fwd: Vec<&Mention> = mentions.iter().collect();
sorted_fwd.sort_by_key(|m| m.start);
let resolved_utf16: Vec<u16> = resolved.encode_utf16().collect();
let mut byte_pos = 0;
let resolved_bytes = resolved.as_bytes();
let mut utf16_to_byte: Vec<usize> = Vec::with_capacity(resolved_utf16.len() + 1);
for ch in resolved.chars() {
let utf16_len = ch.len_utf16();
let utf8_len = ch.len_utf8();
for _ in 0..utf16_len {
utf16_to_byte.push(byte_pos);
}
byte_pos += utf8_len;
}
utf16_to_byte.push(byte_pos);
let mut offset_shift: i64 = 0;
for mention in &sorted_fwd {
let adjusted_start = (mention.start as i64 + offset_shift) as usize;
let name = self
.uuid_to_name
.get(&mention.uuid)
.cloned()
.unwrap_or_else(|| {
let short = if mention.uuid.len() > 8 {
&mention.uuid[..8]
} else {
&mention.uuid
};
short.to_string()
});
let replacement_utf16_len = format!("@{name}").encode_utf16().count();
let byte_start = utf16_to_byte.get(adjusted_start).copied().unwrap_or(resolved_bytes.len());
let byte_end = utf16_to_byte
.get(adjusted_start + replacement_utf16_len)
.copied()
.unwrap_or(resolved_bytes.len());
ranges.push((byte_start, byte_end));
offset_shift += replacement_utf16_len as i64 - mention.length as i64;
}
(resolved, ranges)
}
fn resolve_text_styles(
&self,
resolved_body: &str,
text_styles: &[TextStyle],
mentions: &[Mention],
) -> Vec<(usize, usize, StyleType)> {
if text_styles.is_empty() {
return Vec::new();
}
let mut mention_shifts: Vec<(usize, i64)> = Vec::new(); if !mentions.is_empty() {
let mut sorted_mentions: Vec<&Mention> = mentions.iter().collect();
sorted_mentions.sort_by_key(|m| m.start);
let mut cumulative: i64 = 0;
for m in &sorted_mentions {
let name = self
.uuid_to_name
.get(&m.uuid)
.cloned()
.unwrap_or_else(|| {
let short = if m.uuid.len() > 8 { &m.uuid[..8] } else { &m.uuid };
short.to_string()
});
let replacement_utf16_len = format!("@{name}").encode_utf16().count() as i64;
let original_len = m.length as i64;
cumulative += replacement_utf16_len - original_len;
mention_shifts.push((m.start + m.length, cumulative));
}
}
let shift_offset = |orig: usize| -> usize {
let mut shift: i64 = 0;
for &(boundary, cum_shift) in &mention_shifts {
if orig >= boundary {
shift = cum_shift;
} else {
break;
}
}
(orig as i64 + shift) as usize
};
let mut utf16_to_byte: Vec<usize> = Vec::new();
let mut byte_pos = 0;
for ch in resolved_body.chars() {
for _ in 0..ch.len_utf16() {
utf16_to_byte.push(byte_pos);
}
byte_pos += ch.len_utf8();
}
utf16_to_byte.push(byte_pos);
let body_byte_len = resolved_body.len();
text_styles
.iter()
.filter_map(|ts| {
let shifted_start = shift_offset(ts.start);
let shifted_end = shift_offset(ts.start + ts.length);
let byte_start = utf16_to_byte.get(shifted_start).copied().unwrap_or(body_byte_len);
let byte_end = utf16_to_byte.get(shifted_end).copied().unwrap_or(body_byte_len);
if byte_start < byte_end && byte_end <= body_byte_len {
Some((byte_start, byte_end, ts.style))
} else {
None
}
})
.collect()
}
fn prepare_outgoing_mentions(&self, text: &str) -> (String, Vec<(usize, String)>) {
if self.pending_mentions.is_empty() {
return (text.to_string(), Vec::new());
}
let mut wire = text.to_string();
let mut mentions: Vec<(usize, String)> = Vec::new();
let mut found: Vec<(usize, usize, String)> = Vec::new(); for (name, uuid) in &self.pending_mentions {
let pattern = format!("@{name}");
if let Some(uuid) = uuid {
if let Some(pos) = wire.find(&pattern) {
found.push((pos, pos + pattern.len(), uuid.clone()));
}
}
}
found.sort_by(|a, b| b.0.cmp(&a.0));
for (byte_start, byte_end, uuid) in &found {
let utf16_offset = wire[..*byte_start].encode_utf16().count();
wire.replace_range(*byte_start..*byte_end, "\u{FFFC}");
mentions.push((utf16_offset, uuid.clone()));
}
mentions.sort_by_key(|(off, _)| *off);
(wire, mentions)
}
fn handle_send_timestamp(&mut self, rpc_id: &str, server_ts: i64) {
if let Some((conv_id, local_ts)) = self.pending_sends.remove(rpc_id) {
crate::debug_log::logf(format_args!(
"send confirmed: conv={} local_ts={local_ts} server_ts={server_ts}",
crate::debug_log::mask_phone(&conv_id)
));
let effective_ts = if server_ts != 0 { server_ts } else { local_ts };
let mut found = false;
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(idx) = conv.find_msg_idx(local_ts).filter(|&idx| conv.messages[idx].sender == "you") {
conv.messages[idx].timestamp_ms = effective_ts;
conv.messages[idx].status = Some(MessageStatus::Sent);
found = true;
}
}
if found {
self.db_warn_visible(self.db.update_message_timestamp_ms(
&conv_id,
local_ts,
effective_ts,
MessageStatus::Sent.to_i32(),
), "update_message_timestamp_ms");
}
if !self.pending_receipts.is_empty() {
let receipts = std::mem::take(&mut self.pending_receipts);
for (sender, receipt_type, timestamps) in receipts {
self.handle_receipt(&sender, &receipt_type, ×tamps);
}
}
}
}
fn handle_send_failed(&mut self, rpc_id: &str) {
if let Some((conv_id, local_ts)) = self.pending_sends.remove(rpc_id) {
let mut found = false;
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(idx) = conv.find_msg_idx(local_ts).filter(|&idx| conv.messages[idx].sender == "you") {
conv.messages[idx].status = Some(MessageStatus::Failed);
found = true;
}
}
if found {
self.db_warn_visible(self.db.update_message_status(
&conv_id,
local_ts,
MessageStatus::Failed.to_i32(),
), "update_message_status");
}
}
}
fn try_upgrade_receipt(
db: &Database,
conv_id: &str,
conv: &mut Conversation,
ts: i64,
new_status: MessageStatus,
) -> bool {
if let Some(idx) = conv.find_msg_idx(ts).filter(|&idx| conv.messages[idx].sender == "you") {
if let Some(current) = conv.messages[idx].status {
if new_status > current {
conv.messages[idx].status = Some(new_status);
db_warn(
db.update_message_status(conv_id, ts, new_status.to_i32()),
"update_message_status",
);
}
}
return true;
}
false
}
fn handle_receipt(&mut self, sender: &str, receipt_type: &str, timestamps: &[i64]) {
let receipt_upper = receipt_type.to_uppercase();
let new_status = match receipt_upper.as_str() {
"DELIVERY" => MessageStatus::Delivered,
"READ" => MessageStatus::Read,
"VIEWED" => MessageStatus::Viewed,
_ => return,
};
let mut matched_any = false;
let conv_id = sender.to_string();
if let Some(conv) = self.conversations.get_mut(&conv_id) {
for ts in timestamps {
if Self::try_upgrade_receipt(&self.db, &conv_id, conv, *ts, new_status) {
matched_any = true;
}
}
}
if !matched_any {
for ts in timestamps {
for (cid, conv) in &mut self.conversations {
if Self::try_upgrade_receipt(&self.db, cid, conv, *ts, new_status) {
matched_any = true;
break;
}
}
}
}
if !matched_any && !timestamps.is_empty() {
crate::debug_log::logf(format_args!(
"receipt: buffering {receipt_type} from {} (no matching ts)",
crate::debug_log::mask_phone(sender)
));
self.pending_receipts.push((
sender.to_string(),
receipt_type.to_string(),
timestamps.to_vec(),
));
} else if matched_any {
crate::debug_log::logf(format_args!(
"receipt: {receipt_type} from {} -> {new_status:?}",
crate::debug_log::mask_phone(sender)
));
}
}
fn get_or_create_conversation(
&mut self,
id: &str,
name: &str,
is_group: bool,
) -> &mut Conversation {
if !self.conversations.contains_key(id) {
db_warn(self.db.upsert_conversation(id, name, is_group), "upsert_conversation");
self.conversations.insert(
id.to_string(),
Conversation {
name: name.to_string(),
id: id.to_string(),
messages: Vec::new(),
unread: 0,
is_group,
expiration_timer: 0,
accepted: true,
},
);
self.conversation_order.push(id.to_string());
} else if name != id {
let conv = self.conversations.get_mut(id).unwrap();
if conv.name != name {
conv.name = name.to_string();
db_warn(self.db.upsert_conversation(id, name, is_group), "upsert_conversation");
}
}
self.conversations.get_mut(id).unwrap()
}
pub fn handle_input(&mut self) -> Option<SendRequest> {
let input = self.input_buffer.clone();
let trimmed = input.trim();
if !trimmed.is_empty() {
self.input_history.push(trimmed.to_string());
}
self.history_index = None;
self.input_buffer.clear();
self.input_cursor = 0;
let action = input::parse_input(&input);
match action {
InputAction::SendText(text) => {
if text.is_empty() && self.pending_attachment.is_none() && self.editing_message.is_none() {
return None;
}
if let Some((edit_ts, edit_conv_id)) = self.editing_message.take() {
if !text.is_empty() {
let original_quote = self.conversations.get(&edit_conv_id)
.and_then(|conv| conv.find_msg_idx(edit_ts).map(|idx| &conv.messages[idx]))
.filter(|msg| msg.sender == "you")
.and_then(|msg| msg.quote.as_ref())
.map(|q| (q.timestamp_ms, q.author_id.clone(), q.body.clone()));
if let Some(conv) = self.conversations.get_mut(&edit_conv_id) {
if let Some(idx) = conv.find_msg_idx(edit_ts).filter(|&idx| conv.messages[idx].sender == "you") {
conv.messages[idx].body = text.clone();
conv.messages[idx].is_edited = true;
}
let is_group = conv.is_group;
let (wire_body, wire_mentions) = self.prepare_outgoing_mentions(&text);
self.pending_mentions.clear();
self.db_warn_visible(
self.db.update_message_body(&edit_conv_id, edit_ts, &text),
"update_message_body",
);
let now = Utc::now();
return Some(SendRequest::Edit {
recipient: edit_conv_id,
body: wire_body,
is_group,
edit_timestamp: edit_ts,
local_ts_ms: now.timestamp_millis(),
mentions: wire_mentions,
quote_timestamp: original_quote.as_ref().map(|(ts, _, _)| *ts),
quote_author: original_quote.as_ref().map(|(_, a, _)| a.clone()),
quote_body: original_quote.map(|(_, _, b)| b),
});
}
}
return None;
}
if let Some(ref conv_id) = self.active_conversation {
let attachment = self.pending_attachment.take();
let is_group = self
.conversations
.get(conv_id)
.map(|c| c.is_group)
.unwrap_or(false);
let conv_id = conv_id.clone();
let (display_body, outgoing_image_lines, outgoing_image_path) = if let Some(ref path) = attachment {
let fname = path.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "file".to_string());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let is_image = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp");
let prefix = if is_image { "image" } else { "attachment" };
let body = if text.is_empty() { format!("[{prefix}: {fname}]") } else { format!("[{prefix}: {fname}] {text}") };
let (img_lines, img_path) = if is_image && self.inline_images {
(image_render::render_image(path, 40), Some(path.to_string_lossy().into_owned()))
} else {
(None, None)
};
(body, img_lines, img_path)
} else {
(text.clone(), None, None)
};
let mut mention_ranges = Vec::new();
for (name, _uuid) in &self.pending_mentions {
let needle = format!("@{name}");
if let Some(pos) = display_body.find(&needle) {
mention_ranges.push((pos, pos + needle.len()));
}
}
let (wire_body, wire_mentions) = self.prepare_outgoing_mentions(&text);
self.pending_mentions.clear();
let now = Utc::now();
let local_ts_ms = now.timestamp_millis();
let quote = self.reply_target.as_ref().map(|(author_phone, body, ts)| {
let author_display = self.contact_names.get(author_phone)
.cloned()
.unwrap_or_else(|| if *author_phone == self.account { "you".to_string() } else { author_phone.clone() });
Quote { author: author_display, body: body.clone(), timestamp_ms: *ts, author_id: author_phone.clone() }
});
let quote_timestamp = self.reply_target.as_ref().map(|(_, _, ts)| *ts);
let quote_author = self.reply_target.as_ref().map(|(phone, _, _)| phone.clone());
let quote_body = self.reply_target.as_ref().map(|(_, body, _)| body.clone());
let out_expires = self.conversations.get(&conv_id)
.map(|c| c.expiration_timer).unwrap_or(0);
let out_expiry_start = if out_expires > 0 { local_ts_ms } else { 0 };
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: now,
body: display_body.clone(),
is_system: false,
image_lines: outgoing_image_lines,
image_path: outgoing_image_path,
status: Some(MessageStatus::Sending),
timestamp_ms: local_ts_ms,
reactions: Vec::new(),
mention_ranges,
style_ranges: Vec::new(),
quote,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: self.account.clone(),
expires_in_seconds: out_expires,
expiration_start_ms: out_expiry_start,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
if out_expires > 0 {
self.expiring_msg_count += 1;
}
}
self.db_warn_visible(self.db.insert_message_full(
&conv_id,
"you",
&now.to_rfc3339(),
&display_body,
false,
Some(MessageStatus::Sending),
local_ts_ms,
&self.account,
quote_author.as_deref(),
quote_body.as_deref(),
quote_timestamp,
out_expires,
out_expiry_start,
), "insert_message");
self.scroll_offset = 0;
self.focused_msg_index = None;
self.reply_target = None;
self.move_conversation_to_top(&conv_id);
return Some(SendRequest::Message {
recipient: conv_id,
body: wire_body,
is_group,
local_ts_ms,
mentions: wire_mentions,
attachment,
quote_timestamp,
quote_author,
quote_body,
});
} else {
self.status_message =
"No active conversation. Use /join <name> first.".to_string();
}
}
InputAction::Join(target) => {
self.join_conversation(&target);
}
InputAction::Part => {
self.save_scroll_position();
self.active_conversation = None;
self.scroll_offset = 0;
self.focused_msg_index = None;
self.pending_attachment = None;
self.reset_typing_with_stop();
self.update_status();
}
InputAction::Quit => {
if self.input_buffer.is_empty() || self.quit_confirm {
self.should_quit = true;
} else {
self.quit_confirm = true;
}
}
InputAction::ToggleSidebar => {
self.sidebar_visible = !self.sidebar_visible;
}
InputAction::ToggleBell(ref target) => {
match target.as_deref() {
None => {
let new_state = !(self.notify_direct && self.notify_group);
self.notify_direct = new_state;
self.notify_group = new_state;
let state = if new_state { "on" } else { "off" };
self.status_message = format!("notifications {state}");
}
Some("direct" | "dm" | "1:1") => {
self.notify_direct = !self.notify_direct;
let state = if self.notify_direct { "on" } else { "off" };
self.status_message = format!("direct notifications {state}");
}
Some("group" | "groups") => {
self.notify_group = !self.notify_group;
let state = if self.notify_group { "on" } else { "off" };
self.status_message = format!("group notifications {state}");
}
Some(other) => {
self.status_message = format!("unknown bell type: {other} (use direct or group)");
}
}
}
InputAction::ToggleMute => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
if self.muted_conversations.remove(&conv_id) {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("unmuted {name}");
db_warn(self.db.set_muted(&conv_id, false), "set_muted");
} else {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("muted {name}");
self.muted_conversations.insert(conv_id.clone());
db_warn(self.db.set_muted(&conv_id, true), "set_muted");
}
} else {
self.status_message = "no active conversation to mute".to_string();
}
}
InputAction::Block => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
if self.blocked_conversations.contains(&conv_id) {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("{name} is already blocked");
} else {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("blocked {name}");
self.blocked_conversations.insert(conv_id.clone());
db_warn(self.db.set_blocked(&conv_id, true), "set_blocked");
return Some(SendRequest::Block { recipient: conv_id, is_group });
}
} else {
self.status_message = "no active conversation to block".to_string();
}
}
InputAction::Unblock => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
if self.blocked_conversations.remove(&conv_id) {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("unblocked {name}");
db_warn(self.db.set_blocked(&conv_id, false), "set_blocked");
return Some(SendRequest::Unblock { recipient: conv_id, is_group });
} else {
let name = self.conversations.get(&conv_id)
.map(|c| c.name.as_str()).unwrap_or(&conv_id);
self.status_message = format!("{name} is not blocked");
}
} else {
self.status_message = "no active conversation to unblock".to_string();
}
}
InputAction::Settings => {
self.show_settings = true;
self.settings_index = 0;
self.settings_mouse_snapshot = self.mouse_enabled;
}
InputAction::Attach => {
self.open_file_browser();
}
InputAction::Search(query) => {
self.search_query = query;
self.search_index = 0;
self.run_search();
self.show_search = true;
}
InputAction::Contacts => {
self.show_contacts = true;
self.contacts_index = 0;
self.contacts_filter.clear();
self.refresh_contacts_filter();
}
InputAction::Theme => {
self.show_theme_picker = true;
self.theme_index = self.available_themes.iter()
.position(|t| t.name == self.theme.name)
.unwrap_or(0);
}
InputAction::Group => {
self.group_menu_state = Some(GroupMenuState::Menu);
self.group_menu_index = 0;
self.group_menu_filter.clear();
self.group_menu_input.clear();
}
InputAction::Verify => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let conv = &self.conversations[&conv_id];
if conv.is_group {
if let Some(group) = self.groups.get(&conv_id) {
let members: HashSet<&str> = group.members.iter().map(|s| s.as_str()).collect();
self.verify_identities = self.identity_trust.keys()
.filter(|num| members.contains(num.as_str()))
.filter_map(|num| {
Some(IdentityInfo {
number: Some(num.clone()),
uuid: None,
fingerprint: String::new(),
safety_number: String::new(),
trust_level: *self.identity_trust.get(num)?,
added_timestamp: 0,
})
})
.collect();
} else {
self.verify_identities.clear();
}
} else {
self.verify_identities = self.identity_trust.get(&conv_id)
.map(|tl| vec![IdentityInfo {
number: Some(conv_id.clone()),
uuid: None,
fingerprint: String::new(),
safety_number: String::new(),
trust_level: *tl,
added_timestamp: 0,
}])
.unwrap_or_default();
}
self.show_verify = true;
self.verify_index = 0;
return Some(SendRequest::ListIdentities);
} else {
self.status_message = "no active conversation".to_string();
}
}
InputAction::Profile => {
self.show_profile = true;
self.profile_index = 0;
self.profile_editing = false;
}
InputAction::About => {
self.show_about = true;
}
InputAction::Keybindings => {
self.show_keybindings = true;
self.keybindings_index = 0;
}
InputAction::Help => {
self.show_help = true;
}
InputAction::SetDisappearing(duration_str) => {
match input::parse_duration_to_seconds(&duration_str) {
Ok(seconds) => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.expiration_timer = seconds;
}
self.db_warn_visible(self.db.update_expiration_timer(&conv_id, seconds), "update_expiration_timer");
return Some(SendRequest::UpdateExpiration {
conv_id,
is_group,
seconds,
});
} else {
self.status_message = "No active conversation".to_string();
}
}
Err(msg) => {
self.status_message = msg;
}
}
}
InputAction::Poll { question, options, allow_multiple } => {
if let Some(ref conv_id) = self.active_conversation {
let conv_id = conv_id.clone();
let is_group = self.conversations.get(&conv_id).map(|c| c.is_group).unwrap_or(false);
let now = Utc::now();
let local_ts_ms = now.timestamp_millis();
let poll_options: Vec<PollOption> = options.iter().enumerate()
.map(|(i, text)| PollOption { id: i as i64, text: text.clone() })
.collect();
let poll_data = PollData {
question: question.clone(),
options: poll_options,
allow_multiple,
closed: false,
};
let poll_data_for_db = poll_data.clone();
if let Some(conv) = self.conversations.get_mut(&conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: now,
body: format!("\u{1F4CA} {question}"),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sending),
timestamp_ms: local_ts_ms,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: self.account.clone(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: Some(poll_data),
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
self.db_warn_visible(self.db.insert_message_full(
&conv_id, "you", &now.to_rfc3339(),
&format!("\u{1F4CA} {question}"),
false, Some(MessageStatus::Sending), local_ts_ms,
&self.account.clone(), None, None, None, 0, 0,
), "insert_poll_msg");
self.db_warn_visible(self.db.upsert_poll_data(&conv_id, local_ts_ms, &poll_data_for_db), "upsert_poll_data");
self.scroll_offset = 0;
return Some(SendRequest::PollCreate {
recipient: conv_id,
is_group,
question,
options,
allow_multiple,
local_ts_ms,
});
} else {
self.status_message = "No active conversation".to_string();
}
}
InputAction::Paste => {
return self.handle_paste_command();
}
InputAction::Unknown(msg) => {
self.status_message = msg;
}
}
None
}
pub fn update_autocomplete(&mut self) {
let buf = &self.input_buffer;
if buf.starts_with('/') && !buf.contains(' ') {
let prefix = buf.to_lowercase();
let mut candidates = Vec::new();
for (i, cmd) in COMMANDS.iter().enumerate() {
if cmd.name.starts_with(&prefix)
|| (!cmd.alias.is_empty() && cmd.alias.starts_with(&prefix))
{
candidates.push(i);
}
}
if !candidates.is_empty() {
self.autocomplete_visible = true;
self.autocomplete_mode = AutocompleteMode::Command;
self.autocomplete_candidates = candidates;
if self.autocomplete_index >= self.autocomplete_candidates.len() {
self.autocomplete_index = 0;
}
return;
}
}
let join_prefix = if buf.starts_with("/join ") {
Some("/join ".len())
} else if buf.starts_with("/j ") {
Some("/j ".len())
} else {
None
};
if let Some(prefix_len) = join_prefix {
let filter_lower = buf[prefix_len..].to_lowercase();
let mut candidates: Vec<(String, String)> = Vec::new();
for (phone, name) in &self.contact_names {
if !phone.starts_with('+') {
continue;
}
let display = format!("{name} ({phone})");
if filter_lower.is_empty()
|| name.to_lowercase().contains(&filter_lower)
|| phone.contains(&filter_lower)
{
candidates.push((display, phone.clone()));
}
}
for group in self.groups.values() {
let display = format!("#{}", group.name);
if filter_lower.is_empty()
|| group.name.to_lowercase().contains(&filter_lower)
{
candidates.push((display, group.id.clone()));
}
}
for conv_id in &self.conversation_order {
if let Some(conv) = self.conversations.get(conv_id) {
let already_listed = candidates.iter().any(|(_, val)| {
val == conv_id
});
if !already_listed {
let display = if conv.is_group {
format!("#{}", conv.name)
} else {
format!("{} ({})", conv.name, conv_id)
};
if filter_lower.is_empty()
|| conv.name.to_lowercase().contains(&filter_lower)
|| conv_id.to_lowercase().contains(&filter_lower)
{
candidates.push((display, conv_id.clone()));
}
}
}
}
candidates.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
if !candidates.is_empty() {
self.autocomplete_visible = true;
self.autocomplete_mode = AutocompleteMode::Join;
self.join_candidates = candidates;
if self.autocomplete_index >= self.join_candidates.len() {
self.autocomplete_index = 0;
}
return;
}
}
if let Some(ref conv_id) = self.active_conversation {
if let Some(conv) = self.conversations.get(conv_id) {
if let Some(trigger_pos) = self.find_mention_trigger() {
let after_at = &self.input_buffer[trigger_pos + 1..self.input_cursor];
let filter_lower = after_at.to_lowercase();
let mut candidates: Vec<(String, String, Option<String>)> = Vec::new();
if conv.is_group {
if let Some(group) = self.groups.get(conv_id) {
for member_phone in &group.members {
let name = self
.contact_names
.get(member_phone)
.cloned()
.unwrap_or_else(|| member_phone.clone());
let uuid = self.number_to_uuid.get(member_phone).cloned();
if filter_lower.is_empty()
|| name.to_lowercase().contains(&filter_lower)
|| member_phone.contains(&filter_lower)
{
candidates.push((member_phone.clone(), name, uuid));
}
}
}
} else {
let name = self
.contact_names
.get(conv_id)
.cloned()
.unwrap_or_else(|| conv_id.clone());
let uuid = self.number_to_uuid.get(conv_id).cloned();
if filter_lower.is_empty()
|| name.to_lowercase().contains(&filter_lower)
|| conv_id.contains(&filter_lower)
{
candidates.push((conv_id.clone(), name, uuid));
}
}
candidates.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
if !candidates.is_empty() {
self.autocomplete_visible = true;
self.autocomplete_mode = AutocompleteMode::Mention;
self.mention_candidates = candidates;
self.mention_trigger_pos = trigger_pos;
if self.autocomplete_index >= self.mention_candidates.len() {
self.autocomplete_index = 0;
}
return;
}
}
}
}
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.mention_candidates.clear();
self.join_candidates.clear();
self.autocomplete_index = 0;
}
fn find_mention_trigger(&self) -> Option<usize> {
let before_cursor = &self.input_buffer[..self.input_cursor];
let at_pos = before_cursor.rfind('@')?;
if at_pos > 0 {
let prev_char = before_cursor[..at_pos].chars().next_back()?;
if !prev_char.is_whitespace() {
return None;
}
}
let after_at = &before_cursor[at_pos + 1..];
if after_at.contains(' ') {
return None;
}
Some(at_pos)
}
pub fn history_up(&mut self) {
if self.input_history.is_empty() {
return;
}
match self.history_index {
None => {
self.history_draft = self.input_buffer.clone();
self.history_index = Some(self.input_history.len() - 1);
}
Some(idx) if idx > 0 => {
self.history_index = Some(idx - 1);
}
_ => return,
}
self.input_buffer = self.input_history[self.history_index.unwrap()].clone();
self.input_cursor = self.input_buffer.len();
}
pub fn history_down(&mut self) {
let idx = match self.history_index {
Some(idx) => idx,
None => return,
};
if idx < self.input_history.len() - 1 {
self.history_index = Some(idx + 1);
self.input_buffer = self.input_history[idx + 1].clone();
} else {
self.input_buffer = self.history_draft.clone();
self.history_index = None;
}
self.input_cursor = self.input_buffer.len();
}
pub fn apply_input_edit(&mut self, key_code: KeyCode) -> bool {
match key_code {
KeyCode::Backspace => {
if self.input_cursor > 0 {
self.input_cursor = prev_char_pos(&self.input_buffer, self.input_cursor);
self.input_buffer.remove(self.input_cursor);
} else if self.pending_attachment.is_some() {
self.pending_attachment = None;
}
true
}
KeyCode::Delete => {
if self.input_cursor < self.input_buffer.len() {
self.input_buffer.remove(self.input_cursor);
}
true
}
KeyCode::Left => {
self.input_cursor = prev_char_pos(&self.input_buffer, self.input_cursor);
true
}
KeyCode::Right => {
self.input_cursor = next_char_pos(&self.input_buffer, self.input_cursor);
true
}
KeyCode::Home => {
self.input_cursor = self.current_line_start();
true
}
KeyCode::End => {
self.input_cursor = self.current_line_end();
true
}
KeyCode::Up => {
let (line, col) = self.cursor_line_col();
if line > 0 {
let lines: Vec<&str> = self.input_buffer.split('\n').collect();
let target_line = lines[line - 1];
let target_chars = target_line.chars().count();
let target_col: usize = target_line.chars().take(col.min(target_chars)).map(|c| c.len_utf8()).sum();
let offset: usize = lines.iter().take(line - 1).map(|l| l.len() + 1).sum();
self.input_cursor = offset + target_col;
} else {
self.history_up();
}
true
}
KeyCode::Down => {
let (line, col) = self.cursor_line_col();
let total_lines = self.input_line_count();
if line < total_lines - 1 {
let lines: Vec<&str> = self.input_buffer.split('\n').collect();
let target_line = lines[line + 1];
let target_chars = target_line.chars().count();
let target_col: usize = target_line.chars().take(col.min(target_chars)).map(|c| c.len_utf8()).sum();
let offset: usize = lines.iter().take(line + 1).map(|l| l.len() + 1).sum();
self.input_cursor = offset + target_col;
} else {
self.history_down();
}
true
}
KeyCode::Char(c) => {
self.input_buffer.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
true
}
_ => false,
}
}
pub fn input_line_count(&self) -> usize {
self.input_buffer.matches('\n').count() + 1
}
pub fn cursor_line_col(&self) -> (usize, usize) {
let before = &self.input_buffer[..self.input_cursor];
let line = before.matches('\n').count();
let line_start = match before.rfind('\n') {
Some(pos) => pos + 1,
None => 0,
};
let col = before[line_start..].chars().count();
(line, col)
}
fn current_line_start(&self) -> usize {
self.input_buffer[..self.input_cursor]
.rfind('\n')
.map(|p| p + 1)
.unwrap_or(0)
}
fn current_line_end(&self) -> usize {
self.input_buffer[self.input_cursor..]
.find('\n')
.map(|p| self.input_cursor + p)
.unwrap_or(self.input_buffer.len())
}
fn delete_word_back(&mut self) {
if self.input_cursor == 0 {
return;
}
let buf = &self.input_buffer;
let mut pos = self.input_cursor;
while pos > 0 {
let prev = buf[..pos].chars().next_back().unwrap();
if !prev.is_whitespace() { break; }
pos -= prev.len_utf8();
}
while pos > 0 {
let prev = buf[..pos].chars().next_back().unwrap();
if prev.is_whitespace() { break; }
pos -= prev.len_utf8();
}
self.input_buffer.drain(pos..self.input_cursor);
self.input_cursor = pos;
}
pub fn handle_paste(&mut self, text: String) -> Option<SendRequest> {
if self.mode != InputMode::Insert || self.has_overlay() {
return None;
}
let text = text.replace("\r\n", "\n").replace('\r', "\n");
self.input_buffer.insert_str(self.input_cursor, &text);
self.input_cursor += text.len();
self.update_autocomplete();
self.typing_last_keypress = Some(Instant::now());
if !self.typing_sent
&& !self.input_buffer.is_empty()
&& !self.input_buffer.starts_with('/')
&& self.active_conversation.as_ref().is_some_and(|id| !self.blocked_conversations.contains(id))
{
self.typing_sent = true;
return self.build_typing_request(false);
}
None
}
fn handle_paste_text(&mut self, text: &str) -> Option<SendRequest> {
let text = text.trim();
if text.is_empty() {
self.status_message = "Clipboard is empty".to_string();
return None;
}
self.handle_paste(text.to_string())
}
fn handle_clipboard_image(&mut self, img_data: arboard::ImageData) -> Option<SendRequest> {
use image::{ImageBuffer, RgbaImage};
let width = img_data.width as u32;
let height = img_data.height as u32;
let img: RgbaImage = match ImageBuffer::from_raw(width, height, img_data.bytes.into_owned()) {
Some(img) => img,
None => {
self.status_message = "Failed to decode clipboard image".to_string();
return None;
}
};
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S%.3f");
let filename = format!("clipboard_{timestamp}.png");
let path = self.paste_temp_path.join(&filename);
if let Err(e) = std::fs::create_dir_all(&self.paste_temp_path) {
self.status_message = format!("Cannot create paste directory: {e}");
return None;
}
if let Err(e) = img.save(&path) {
self.status_message = format!("Failed to save clipboard image: {e}");
return None;
}
self.pending_attachment = Some(path);
self.status_message = format!("Pasted image: {filename}");
None
}
fn handle_paste_command(&mut self) -> Option<SendRequest> {
if self.active_conversation.is_none() {
self.status_message = "No active conversation".to_string();
return None;
}
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
return None;
}
};
if let Ok(img_data) = clipboard.get_image() {
return self.handle_clipboard_image(img_data);
}
if let Ok(text) = clipboard.get_text() {
return self.handle_paste_text(&text);
}
self.status_message = "Clipboard is empty or unsupported format".to_string();
None
}
pub fn apply_autocomplete(&mut self) {
match self.autocomplete_mode {
AutocompleteMode::Command => {
if let Some(&cmd_idx) = self.autocomplete_candidates.get(self.autocomplete_index) {
let cmd = &COMMANDS[cmd_idx];
if cmd.args.is_empty() {
self.input_buffer = cmd.name.to_string();
} else {
self.input_buffer = format!("{} ", cmd.name);
}
self.input_cursor = self.input_buffer.len();
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_index = 0;
}
}
AutocompleteMode::Mention => {
if let Some((_phone, name, uuid)) =
self.mention_candidates.get(self.autocomplete_index).cloned()
{
let replacement = format!("@{name} ");
let before = &self.input_buffer[..self.mention_trigger_pos];
let after = &self.input_buffer[self.input_cursor..];
self.input_buffer = format!("{before}{replacement}{after}");
self.input_cursor = self.mention_trigger_pos + replacement.len();
self.pending_mentions.push((name, uuid));
self.autocomplete_visible = false;
self.mention_candidates.clear();
self.autocomplete_index = 0;
}
}
AutocompleteMode::Join => {
if let Some((_display, value)) =
self.join_candidates.get(self.autocomplete_index).cloned()
{
self.input_buffer = format!("/join {value}");
self.input_cursor = self.input_buffer.len();
self.autocomplete_visible = false;
self.join_candidates.clear();
self.autocomplete_index = 0;
}
}
}
}
fn save_scroll_position(&mut self) {
if let Some(ref id) = self.active_conversation {
self.scroll_positions.insert(id.clone(), (self.scroll_offset, self.focused_msg_index));
}
}
fn restore_scroll_position(&mut self, conv_id: &str) {
if let Some(&(offset, focus)) = self.scroll_positions.get(conv_id) {
self.scroll_offset = offset;
self.focused_msg_index = focus;
} else {
self.scroll_offset = 0;
self.focused_msg_index = None;
}
}
fn join_conversation(&mut self, target: &str) {
self.mark_read();
self.save_scroll_position();
self.pending_attachment = None;
self.reset_typing_with_stop();
self.clear_kitty_state();
if self.conversations.contains_key(target) {
let read_from = self.last_read_index.get(target).copied().unwrap_or(0);
self.queue_read_receipts_for_conv(target, read_from);
self.active_conversation = Some(target.to_string());
if let Some(conv) = self.conversations.get_mut(target) {
conv.unread = 0;
}
self.restore_scroll_position(target);
self.update_status();
return;
}
let target_lower = target.to_lowercase();
let found_id = self
.conversations
.iter()
.find(|(_, conv)| conv.name.to_lowercase().contains(&target_lower))
.map(|(id, _)| id.clone());
if let Some(id) = found_id {
let read_from = self.last_read_index.get(&id).copied().unwrap_or(0);
self.queue_read_receipts_for_conv(&id, read_from);
self.active_conversation = Some(id.clone());
if let Some(conv) = self.conversations.get_mut(&id) {
conv.unread = 0;
}
self.restore_scroll_position(&id);
self.update_status();
return;
}
if target.starts_with('+') {
self.get_or_create_conversation(target, target, false);
self.active_conversation = Some(target.to_string());
self.scroll_offset = 0;
self.focused_msg_index = None;
self.update_status();
} else {
self.status_message = format!("Conversation not found: {target}");
}
}
pub fn next_conversation(&mut self) {
if self.conversation_order.is_empty() {
return;
}
self.mark_read();
self.save_scroll_position();
self.pending_attachment = None;
self.reset_typing_with_stop();
self.clear_kitty_state();
let idx = self
.active_conversation
.as_ref()
.and_then(|id| self.conversation_order.iter().position(|x| x == id))
.map(|i| (i + 1) % self.conversation_order.len())
.unwrap_or(0);
let new_id = self.conversation_order[idx].clone();
let read_from = self.last_read_index.get(&new_id).copied().unwrap_or(0);
self.queue_read_receipts_for_conv(&new_id, read_from);
self.active_conversation = Some(new_id.clone());
if let Some(conv) = self.conversations.get_mut(&new_id) {
conv.unread = 0;
}
self.restore_scroll_position(&new_id);
self.update_status();
}
pub fn prev_conversation(&mut self) {
if self.conversation_order.is_empty() {
return;
}
self.mark_read();
self.save_scroll_position();
self.pending_attachment = None;
self.reset_typing_with_stop();
self.clear_kitty_state();
let len = self.conversation_order.len();
let idx = self
.active_conversation
.as_ref()
.and_then(|id| self.conversation_order.iter().position(|x| x == id))
.map(|i| if i == 0 { len - 1 } else { i - 1 })
.unwrap_or(0);
let new_id = self.conversation_order[idx].clone();
let read_from = self.last_read_index.get(&new_id).copied().unwrap_or(0);
self.queue_read_receipts_for_conv(&new_id, read_from);
self.active_conversation = Some(new_id.clone());
if let Some(conv) = self.conversations.get_mut(&new_id) {
conv.unread = 0;
}
self.restore_scroll_position(&new_id);
self.update_status();
}
fn update_status(&mut self) {
if let Some(ref id) = self.active_conversation {
if let Some(conv) = self.conversations.get(id) {
let prefix = if conv.is_group { "#" } else { "" };
self.status_message = format!("connected | {}{}", prefix, conv.name);
}
self.show_message_request = self.active_conversation.as_ref()
.and_then(|id| self.conversations.get(id))
.is_some_and(|c| !c.accepted);
} else {
self.status_message = "connected | no conversation selected".to_string();
self.show_message_request = false;
}
}
pub fn set_connected(&mut self) {
self.connected = true;
self.status_message = "connected | no conversation selected".to_string();
}
pub fn total_unread(&self) -> usize {
self.conversations.values().map(|c| c.unread).sum()
}
pub fn selected_message(&self) -> Option<&DisplayMessage> {
let conv_id = self.active_conversation.as_ref()?;
let conv = self.conversations.get(conv_id)?;
let index = self.focused_msg_index.unwrap_or_else(|| {
conv.messages.len().saturating_sub(1)
});
conv.messages.get(index)
}
fn jump_to_adjacent_message(&mut self, older: bool) {
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id.clone(),
None => return,
};
let conv = match self.conversations.get(&conv_id) {
Some(c) => c,
None => return,
};
let total = conv.messages.len();
if total == 0 {
return;
}
let current = match self.focused_msg_index {
Some(i) => i,
None => {
let start = (0..total).rev().find(|&i| !conv.messages[i].is_system);
if let Some(s) = start {
self.focused_msg_index = Some(s);
if self.scroll_offset == 0 {
self.scroll_offset = 1;
}
}
return;
}
};
let target = if older {
(0..current).rev().find(|&i| !conv.messages[i].is_system)
} else {
((current + 1)..total).find(|&i| !conv.messages[i].is_system)
};
if let Some(t) = target {
self.focused_msg_index = Some(t);
}
}
pub fn copy_selected_message(&mut self, full_line: bool) {
let text = match self.selected_message() {
Some(msg) if msg.is_system => Some(msg.body.clone()),
Some(msg) => {
if full_line {
Some(format!("[{}] <{}> {}", msg.format_time(), msg.sender, msg.body))
} else {
Some(msg.body.clone())
}
}
None => None,
};
let Some(text) = text else {
self.status_message = "No message to copy".to_string();
return;
};
match arboard::Clipboard::new() {
Ok(mut clipboard) => match clipboard.set_text(&text) {
Ok(()) => {
self.status_message = "Copied to clipboard".to_string();
if self.clipboard_clear_seconds > 0 {
self.clipboard_set_at = Some(std::time::Instant::now());
}
}
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
}
},
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
}
}
}
pub fn check_clipboard_clear(&mut self) {
if let Some(set_at) = self.clipboard_set_at {
if set_at.elapsed().as_secs() >= self.clipboard_clear_seconds {
self.clipboard_set_at = None;
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text("");
}
}
}
}
pub fn has_overlay(&self) -> bool {
self.show_settings
|| self.show_help
|| self.show_contacts
|| self.show_search
|| self.show_file_browser
|| self.show_action_menu
|| self.show_reaction_picker
|| self.show_delete_confirm
|| self.group_menu_state.is_some()
|| self.show_message_request
|| self.show_theme_picker
|| self.show_keybindings
|| self.show_settings_profile_manager
|| self.show_pin_duration
|| self.show_poll_vote
|| self.show_about
|| self.show_profile
|| self.show_forward
|| self.autocomplete_visible
}
pub fn handle_mouse_event(&mut self, event: MouseEvent) -> Option<SendRequest> {
if !self.mouse_enabled {
return None;
}
if self.has_overlay() {
match event.kind {
MouseEventKind::ScrollUp => self.handle_overlay_key(KeyCode::Char('k')),
MouseEventKind::ScrollDown => self.handle_overlay_key(KeyCode::Char('j')),
_ => (false, None),
};
return None;
}
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.handle_left_click(event.column, event.row);
}
MouseEventKind::ScrollUp => {
if is_in_rect(event.column, event.row, self.mouse_messages_area) {
self.scroll_offset = self.scroll_offset.saturating_add(3);
self.focused_msg_index = None;
}
}
MouseEventKind::ScrollDown => {
if is_in_rect(event.column, event.row, self.mouse_messages_area) {
self.scroll_offset = self.scroll_offset.saturating_sub(3);
self.focused_msg_index = None;
}
}
_ => {}
}
None
}
fn handle_left_click(&mut self, col: u16, row: u16) {
for link in &self.link_regions {
if row == link.y && col >= link.x && col < link.x + link.width {
let url = link.url.clone();
self.open_url(&url);
return;
}
}
if let Some(inner) = self.mouse_sidebar_inner {
if is_in_rect(col, row, inner) {
let index = (row - inner.y) as usize;
if index < self.conversation_order.len() {
let conv_id = self.conversation_order[index].clone();
self.join_conversation(&conv_id);
}
return;
}
}
if is_in_rect(col, row, self.mouse_input_area) {
self.mode = InputMode::Insert;
let content_start_col = self.mouse_input_area.x + 1 + self.mouse_input_prefix_len;
if col >= content_start_col {
let text_width = (self.mouse_input_area.width.saturating_sub(2)) as usize
- self.mouse_input_prefix_len as usize;
let input_scroll = floor_char_boundary(&self.input_buffer, self.input_cursor.saturating_sub(text_width));
let target_col = (col - content_start_col) as usize;
let mut byte_pos = input_scroll;
for (col_pos, ch) in self.input_buffer[input_scroll..].chars().enumerate() {
if col_pos >= target_col {
break;
}
byte_pos += ch.len_utf8();
}
self.input_cursor = byte_pos.min(self.input_buffer.len());
} else {
self.input_cursor = 0;
}
}
}
fn open_url(&mut self, url: &str) {
if !url.starts_with("http://") && !url.starts_with("https://") {
self.status_message = "Only http/https URLs can be opened".to_string();
return;
}
if let Err(e) = open::that(url) {
self.status_message = format!("Failed to open URL: {e}");
}
}
fn move_conversation_to_top(&mut self, id: &str) {
let pos = match self.conversation_order.iter().position(|c| c == id) {
Some(pos) => pos,
None => return,
};
self.conversation_order.remove(pos);
self.conversation_order.insert(0, id.to_string());
}
}
fn is_in_rect(col: u16, row: u16, rect: Rect) -> bool {
col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
fn short_name(number: &str) -> String {
let chars: Vec<char> = number.chars().collect();
if chars.len() > 6 {
let prefix: String = chars[..2].iter().collect();
let last4: String = chars[chars.len() - 4..].iter().collect();
format!("{prefix}***{last4}")
} else {
number.to_string()
}
}
fn path_to_file_uri(path: &str) -> String {
let normalized = path.replace('\\', "/");
if normalized.starts_with('/') {
format!("file://{normalized}")
} else {
format!("file:///{normalized}")
}
}
fn file_uri_to_path(uri: &str) -> String {
let uri = uri.trim();
if let Some(rest) = uri.strip_prefix("file:///") {
#[cfg(windows)]
{ rest.to_string() }
#[cfg(not(windows))]
{ format!("/{rest}")}
} else if let Some(rest) = uri.strip_prefix("file://") {
rest.to_string()
} else {
uri.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::signal::types::{Attachment, Contact, Group, Mention, SignalEvent, SignalMessage, StyleType, TextStyle};
use rstest::{fixture, rstest};
#[fixture]
fn app() -> App {
let db = Database::open_in_memory().unwrap();
let mut app = App::new("+10000000000".to_string(), db);
app.set_connected();
app
}
#[rstest]
fn contact_list_does_not_create_conversations(mut app: App) {
assert!(app.conversations.is_empty());
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: Some("Alice".to_string()), uuid: None },
Contact { number: "+2".to_string(), name: Some("Bob".to_string()), uuid: None },
]));
assert!(app.conversations.is_empty());
assert!(app.conversation_order.is_empty());
assert_eq!(app.contact_names["+1"], "Alice");
assert_eq!(app.contact_names["+2"], "Bob");
}
#[rstest]
fn group_list_creates_conversations(mut app: App) {
app.handle_signal_event(SignalEvent::GroupList(vec![
Group { id: "g1".to_string(), name: "Family".to_string(), members: vec![], member_uuids: vec![] },
Group { id: "g2".to_string(), name: "Work".to_string(), members: vec![], member_uuids: vec![] },
]));
assert_eq!(app.conversations.len(), 2);
assert_eq!(app.conversations["g1"].name, "Family");
assert_eq!(app.conversations["g2"].name, "Work");
assert!(app.conversations["g1"].is_group);
assert_eq!(app.contact_names["g1"], "Family");
}
#[rstest]
fn contact_name_updates_existing_conversation(mut app: App) {
let msg = SignalMessage {
source: "+15551234567".to_string(),
source_name: None,
timestamp: chrono::Utc::now(),
body: Some("hey".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+15551234567"].name, "+15551234567");
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+15551234567".to_string(), name: Some("Alice".to_string()), uuid: None },
]));
assert_eq!(app.conversations["+15551234567"].name, "Alice");
}
#[rstest]
fn contact_without_name_does_not_overwrite_existing_name(mut app: App) {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hi".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+1"].name, "Alice");
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: None, uuid: None },
]));
assert_eq!(app.conversations["+1"].name, "Alice");
}
#[rstest]
fn message_uses_contact_name_lookup(mut app: App) {
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: Some("Alice".to_string()), uuid: None },
]));
assert!(app.conversations.is_empty());
let msg = SignalMessage {
source: "+1".to_string(),
source_name: None,
timestamp: chrono::Utc::now(),
body: Some("hello!".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations.len(), 1);
assert_eq!(app.conversations["+1"].name, "Alice");
assert_eq!(app.conversations["+1"].messages[0].sender, "Alice");
}
#[rstest]
fn message_in_known_group_uses_name_lookup(mut app: App) {
app.handle_signal_event(SignalEvent::GroupList(vec![
Group { id: "g1".to_string(), name: "Family".to_string(), members: vec![], member_uuids: vec![] },
]));
assert_eq!(app.conversations.len(), 1);
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hey family".to_string()),
attachments: vec![],
group_id: Some("g1".to_string()),
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations.len(), 1);
assert_eq!(app.conversations["g1"].name, "Family");
assert_eq!(app.conversations["g1"].messages.len(), 1);
}
#[rstest]
fn no_duplicate_on_repeated_messages(mut app: App) {
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: Some("Alice".to_string()), uuid: None },
]));
for _ in 0..3 {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("msg".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
}
assert_eq!(app.conversations.len(), 1);
assert_eq!(app.conversation_order.len(), 1);
assert_eq!(app.conversations["+1"].messages.len(), 3);
}
#[rstest]
#[case("/", true, None)]
#[case("/jo", true, Some(1))]
#[case("hello", false, Some(0))]
#[case("/join ", false, None)]
#[case("/zzz", false, Some(0))]
fn autocomplete_visibility(
mut app: App,
#[case] input: &str,
#[case] expected_visible: bool,
#[case] expected_count: Option<usize>,
) {
app.input_buffer = input.to_string();
app.update_autocomplete();
assert_eq!(app.autocomplete_visible, expected_visible, "visibility for {input:?}");
if let Some(count) = expected_count {
assert_eq!(app.autocomplete_candidates.len(), count, "count for {input:?}");
}
}
#[rstest]
fn apply_autocomplete_trailing_space_for_arg_command(mut app: App) {
app.input_buffer = "/jo".to_string();
app.update_autocomplete();
app.apply_autocomplete();
assert_eq!(app.input_buffer, "/join ");
assert_eq!(app.input_cursor, 6);
}
#[rstest]
fn apply_autocomplete_no_space_for_no_arg_command(mut app: App) {
app.input_buffer = "/pa".to_string();
app.update_autocomplete();
app.apply_autocomplete();
assert_eq!(app.input_buffer, "/part");
assert_eq!(app.input_cursor, 5);
}
#[rstest]
fn apply_autocomplete_index_clamped(mut app: App) {
app.input_buffer = "/".to_string();
app.update_autocomplete();
let len = app.autocomplete_candidates.len();
app.autocomplete_index = len + 5; app.update_autocomplete(); assert!(app.autocomplete_index < app.autocomplete_candidates.len());
}
#[rstest]
fn join_autocomplete_shows_contacts(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.contact_names.insert("+2".to_string(), "Bob".to_string());
app.input_buffer = "/join ".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.autocomplete_mode, AutocompleteMode::Join);
assert_eq!(app.join_candidates.len(), 2);
}
#[rstest]
fn join_autocomplete_shows_groups(mut app: App) {
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec![],
member_uuids: vec![],
});
app.input_buffer = "/join ".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.autocomplete_mode, AutocompleteMode::Join);
assert_eq!(app.join_candidates.len(), 1);
assert!(app.join_candidates[0].0.starts_with('#'));
}
#[rstest]
fn join_autocomplete_filters_by_name(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.contact_names.insert("+2".to_string(), "Bob".to_string());
app.input_buffer = "/join al".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.join_candidates.len(), 1);
assert!(app.join_candidates[0].0.contains("Alice"));
}
#[rstest]
fn join_autocomplete_filters_by_phone(mut app: App) {
app.contact_names.insert("+1234".to_string(), "Alice".to_string());
app.contact_names.insert("+5678".to_string(), "Bob".to_string());
app.input_buffer = "/join +123".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.join_candidates.len(), 1);
assert!(app.join_candidates[0].1 == "+1234");
}
#[rstest]
fn join_autocomplete_alias(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.input_buffer = "/j ".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.autocomplete_mode, AutocompleteMode::Join);
assert_eq!(app.join_candidates.len(), 1);
}
#[rstest]
fn join_autocomplete_no_match_hides(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.input_buffer = "/join zzz".to_string();
app.update_autocomplete();
assert!(!app.autocomplete_visible);
}
#[rstest]
fn apply_join_autocomplete(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.input_buffer = "/join al".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
app.apply_autocomplete();
assert_eq!(app.input_buffer, "/join +1");
assert_eq!(app.input_cursor, 8);
assert!(!app.autocomplete_visible);
}
#[rstest]
fn apply_join_autocomplete_group(mut app: App) {
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec![],
member_uuids: vec![],
});
app.input_buffer = "/join fam".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
app.apply_autocomplete();
assert_eq!(app.input_buffer, "/join g1");
assert_eq!(app.input_cursor, 8);
}
#[rstest]
fn join_autocomplete_includes_conversations(mut app: App) {
app.get_or_create_conversation("+9999", "+9999", false);
app.input_buffer = "/join +999".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.join_candidates.len(), 1);
}
#[rstest]
fn join_autocomplete_skips_group_ids_in_contacts(mut app: App) {
app.contact_names.insert("g1".to_string(), "Family".to_string());
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.input_buffer = "/join ".to_string();
app.update_autocomplete();
assert!(app.autocomplete_visible);
let contact_entries: Vec<_> = app.join_candidates.iter()
.filter(|(_, v)| v == "+1")
.collect();
assert_eq!(contact_entries.len(), 1);
}
#[rstest]
fn join_autocomplete_index_clamped(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.input_buffer = "/join ".to_string();
app.update_autocomplete();
app.autocomplete_index = 100; app.update_autocomplete(); assert!(app.autocomplete_index < app.join_candidates.len());
}
#[rstest]
fn input_edit_char_insert(mut app: App) {
assert!(app.apply_input_edit(KeyCode::Char('a')));
assert!(app.apply_input_edit(KeyCode::Char('b')));
assert_eq!(app.input_buffer, "ab");
assert_eq!(app.input_cursor, 2);
}
#[rstest]
fn input_edit_backspace(mut app: App) {
app.input_buffer = "abc".to_string();
app.input_cursor = 3;
assert!(app.apply_input_edit(KeyCode::Backspace));
assert_eq!(app.input_buffer, "ab");
assert_eq!(app.input_cursor, 2);
}
#[rstest]
fn input_edit_delete(mut app: App) {
app.input_buffer = "abc".to_string();
app.input_cursor = 1;
assert!(app.apply_input_edit(KeyCode::Delete));
assert_eq!(app.input_buffer, "ac");
assert_eq!(app.input_cursor, 1);
}
#[rstest]
fn input_edit_left_right(mut app: App) {
app.input_buffer = "abc".to_string();
app.input_cursor = 2;
assert!(app.apply_input_edit(KeyCode::Left));
assert_eq!(app.input_cursor, 1);
assert!(app.apply_input_edit(KeyCode::Right));
assert_eq!(app.input_cursor, 2);
}
#[rstest]
fn input_edit_home_end(mut app: App) {
app.input_buffer = "abc".to_string();
app.input_cursor = 1;
assert!(app.apply_input_edit(KeyCode::Home));
assert_eq!(app.input_cursor, 0);
assert!(app.apply_input_edit(KeyCode::End));
assert_eq!(app.input_cursor, 3);
}
#[rstest]
fn input_edit_unhandled_key(mut app: App) {
assert!(!app.apply_input_edit(KeyCode::F(1)));
}
#[rstest]
fn history_up_empty_is_noop(mut app: App) {
app.input_buffer = "draft".to_string();
app.history_up();
assert_eq!(app.input_buffer, "draft");
assert_eq!(app.history_index, None);
}
#[rstest]
fn history_down_without_browsing_is_noop(mut app: App) {
app.input_buffer = "draft".to_string();
app.history_down();
assert_eq!(app.input_buffer, "draft");
assert_eq!(app.history_index, None);
}
#[rstest]
fn history_up_recalls_last_entry(mut app: App) {
app.input_history = vec!["hello".to_string(), "world".to_string()];
app.input_buffer = "draft".to_string();
app.input_cursor = 5;
app.history_up();
assert_eq!(app.input_buffer, "world");
assert_eq!(app.history_index, Some(1));
assert_eq!(app.history_draft, "draft");
assert_eq!(app.input_cursor, 5); }
#[rstest]
fn history_up_walks_to_oldest(mut app: App) {
app.input_history = vec!["first".to_string(), "second".to_string(), "third".to_string()];
app.input_buffer = String::new();
app.history_up(); assert_eq!(app.input_buffer, "third");
assert_eq!(app.history_index, Some(2));
app.history_up(); assert_eq!(app.input_buffer, "second");
assert_eq!(app.history_index, Some(1));
app.history_up(); assert_eq!(app.input_buffer, "first");
assert_eq!(app.history_index, Some(0));
app.history_up();
assert_eq!(app.input_buffer, "first");
assert_eq!(app.history_index, Some(0));
}
#[rstest]
fn history_down_walks_forward_and_restores_draft(mut app: App) {
app.input_history = vec!["aaa".to_string(), "bbb".to_string()];
app.input_buffer = "my draft".to_string();
app.history_up(); app.history_up(); assert_eq!(app.input_buffer, "aaa");
assert_eq!(app.history_index, Some(0));
app.history_down(); assert_eq!(app.input_buffer, "bbb");
assert_eq!(app.history_index, Some(1));
app.history_down();
assert_eq!(app.input_buffer, "my draft");
assert_eq!(app.history_index, None);
}
#[rstest]
fn history_cursor_moves_to_end(mut app: App) {
app.input_history = vec!["short".to_string(), "a longer entry".to_string()];
app.input_buffer = String::new();
app.input_cursor = 0;
app.history_up(); assert_eq!(app.input_cursor, 14);
app.history_up(); assert_eq!(app.input_cursor, 5);
app.history_down(); assert_eq!(app.input_cursor, 14);
app.history_down(); assert_eq!(app.input_cursor, 0);
}
#[rstest]
fn handle_input_saves_to_history(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.input_buffer = "hello".to_string();
app.input_cursor = 5;
app.handle_input();
assert_eq!(app.input_history, vec!["hello".to_string()]);
assert_eq!(app.history_index, None);
app.input_buffer = "world".to_string();
app.input_cursor = 5;
app.handle_input();
assert_eq!(app.input_history, vec!["hello".to_string(), "world".to_string()]);
}
#[rstest]
fn handle_input_trims_and_skips_empty(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.input_buffer = " ".to_string();
app.handle_input();
assert!(app.input_history.is_empty());
app.input_buffer = " hello ".to_string();
app.input_cursor = 9;
app.handle_input();
assert_eq!(app.input_history, vec!["hello".to_string()]);
}
#[rstest]
fn handle_input_resets_history_index(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.input_history = vec!["old".to_string()];
app.history_index = Some(0);
app.input_buffer = "new".to_string();
app.input_cursor = 3;
app.handle_input();
assert_eq!(app.history_index, None);
}
#[rstest]
fn apply_input_edit_up_down_routes_to_history(mut app: App) {
app.input_history = vec!["recalled".to_string()];
app.input_buffer = "draft".to_string();
assert!(app.apply_input_edit(KeyCode::Up));
assert_eq!(app.input_buffer, "recalled");
assert!(app.apply_input_edit(KeyCode::Down));
assert_eq!(app.input_buffer, "draft");
}
#[rstest]
fn input_line_count_single_line(mut app: App) {
app.input_buffer = "hello".to_string();
assert_eq!(app.input_line_count(), 1);
}
#[rstest]
fn input_line_count_multi_line(mut app: App) {
app.input_buffer = "hello\nworld\nfoo".to_string();
assert_eq!(app.input_line_count(), 3);
}
#[rstest]
fn cursor_line_col_first_line(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 3;
assert_eq!(app.cursor_line_col(), (0, 3));
}
#[rstest]
fn cursor_line_col_second_line(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 8; assert_eq!(app.cursor_line_col(), (1, 2));
}
#[rstest]
fn cursor_line_col_at_newline(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 6; assert_eq!(app.cursor_line_col(), (1, 0));
}
#[rstest]
fn up_navigates_between_lines(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 8; app.apply_input_edit(KeyCode::Up);
assert_eq!(app.input_cursor, 2); }
#[rstest]
fn down_navigates_between_lines(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 2; app.apply_input_edit(KeyCode::Down);
assert_eq!(app.input_cursor, 8); }
#[rstest]
fn up_clamps_to_shorter_line(mut app: App) {
app.input_buffer = "hi\nhello world".to_string();
app.input_cursor = 12; app.apply_input_edit(KeyCode::Up);
assert_eq!(app.input_cursor, 2); }
#[rstest]
fn down_clamps_to_shorter_line(mut app: App) {
app.input_buffer = "hello world\nhi".to_string();
app.input_cursor = 9; app.apply_input_edit(KeyCode::Down);
assert_eq!(app.input_cursor, 14); }
#[rstest]
fn up_on_first_line_uses_history(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 3; app.input_history = vec!["recalled".to_string()];
app.apply_input_edit(KeyCode::Up);
assert_eq!(app.input_buffer, "recalled");
}
#[rstest]
fn down_on_last_line_falls_through_to_history(mut app: App) {
app.input_buffer = "current".to_string();
app.input_cursor = 3;
app.input_history = vec!["old".to_string()];
app.history_index = Some(0);
app.apply_input_edit(KeyCode::Down);
assert_eq!(app.history_index, None); }
#[rstest]
fn home_end_line_aware(mut app: App) {
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 8; app.apply_input_edit(KeyCode::Home);
assert_eq!(app.input_cursor, 6); app.apply_input_edit(KeyCode::End);
assert_eq!(app.input_cursor, 11); }
#[rstest]
fn alt_enter_inserts_newline(mut app: App) {
app.mode = InputMode::Insert;
app.input_buffer = "hello".to_string();
app.input_cursor = 5;
app.handle_insert_key(KeyModifiers::ALT, KeyCode::Enter);
assert_eq!(app.input_buffer, "hello\n");
assert_eq!(app.input_cursor, 6);
}
#[rstest]
fn enter_sends_multiline_message(mut app: App) {
app.mode = InputMode::Insert;
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.input_buffer = "hello\nworld".to_string();
app.input_cursor = 11;
let result = app.handle_insert_key(KeyModifiers::NONE, KeyCode::Enter);
assert!(result.is_some()); assert!(app.input_buffer.is_empty()); }
#[rstest]
fn paste_normalizes_line_endings(mut app: App) {
app.mode = InputMode::Insert;
app.handle_paste("hello\r\nworld\rfoo".to_string());
assert_eq!(app.input_buffer, "hello\nworld\nfoo");
}
#[rstest]
fn load_from_db_marks_has_more(mut app: App) {
let conv_id = "+pagination";
app.db.upsert_conversation(conv_id, "PagTest", false).unwrap();
for i in 0..App::PAGE_SIZE {
app.db.insert_message(
conv_id, "Alice",
&format!("2025-01-01T00:{:02}:{:02}Z", i / 60, i % 60),
&format!("msg{i}"),
false, None, i as i64 * 1000,
).unwrap();
}
app.load_from_db().unwrap();
assert!(app.has_more_messages.contains(conv_id));
}
#[rstest]
fn load_from_db_no_more_when_under_page_size(mut app: App) {
let conv_id = "+small";
app.db.upsert_conversation(conv_id, "Small", false).unwrap();
app.db.insert_message(conv_id, "Alice", "2025-01-01T00:00:00Z", "only one", false, None, 0).unwrap();
app.load_from_db().unwrap();
assert!(!app.has_more_messages.contains(conv_id));
}
#[rstest]
fn load_more_messages_prepends(mut app: App) {
let conv_id = "+paginate";
app.db.upsert_conversation(conv_id, "Test", false).unwrap();
for i in 0..150 {
app.db.insert_message(
conv_id, "Alice",
&format!("2025-01-01T{:02}:{:02}:00Z", i / 60, i % 60),
&format!("msg{i}"),
false, None, i as i64 * 1000,
).unwrap();
}
app.load_from_db().unwrap();
app.active_conversation = Some(conv_id.to_string());
assert_eq!(app.conversations[conv_id].messages.len(), 100);
assert!(app.has_more_messages.contains(conv_id));
assert_eq!(app.conversations[conv_id].messages[0].body, "msg50");
assert_eq!(app.conversations[conv_id].messages[99].body, "msg149");
app.last_read_index.insert(conv_id.to_string(), 90);
app.focused_msg_index = Some(95);
app.load_more_messages();
assert_eq!(app.conversations[conv_id].messages.len(), 150);
assert_eq!(app.conversations[conv_id].messages[0].body, "msg0");
assert_eq!(app.conversations[conv_id].messages[149].body, "msg149");
assert_eq!(app.last_read_index[conv_id], 140);
assert_eq!(app.focused_msg_index, Some(145));
assert!(!app.has_more_messages.contains(conv_id));
}
#[rstest]
fn receipt_upgrades_outgoing_message_status(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sent),
timestamp_ms: ts_ms,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: conv_id.to_string(),
receipt_type: "DELIVERY".to_string(),
timestamps: vec![ts_ms],
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Delivered)
);
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: conv_id.to_string(),
receipt_type: "READ".to_string(),
timestamps: vec![ts_ms],
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Read)
);
}
#[rstest]
fn receipt_does_not_downgrade_status(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Read),
timestamp_ms: ts_ms,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: conv_id.to_string(),
receipt_type: "DELIVERY".to_string(),
timestamps: vec![ts_ms],
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Read)
);
}
#[rstest]
fn send_timestamp_upgrades_sending_to_sent(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let local_ts = 1700000000000_i64;
let server_ts = 1700000000123_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sending),
timestamp_ms: local_ts,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.pending_sends.insert("rpc-1".to_string(), (conv_id.to_string(), local_ts));
app.handle_signal_event(SignalEvent::SendTimestamp {
rpc_id: "rpc-1".to_string(),
server_ts,
});
let msg = &app.conversations[conv_id].messages[0];
assert_eq!(msg.status, Some(MessageStatus::Sent));
assert_eq!(msg.timestamp_ms, server_ts);
}
#[rstest]
fn send_failed_sets_failed_status(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let local_ts = 1700000000000_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sending),
timestamp_ms: local_ts,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.pending_sends.insert("rpc-1".to_string(), (conv_id.to_string(), local_ts));
app.handle_signal_event(SignalEvent::SendFailed {
rpc_id: "rpc-1".to_string(),
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Failed)
);
}
#[rstest]
fn incoming_messages_have_no_status(mut app: App) {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hello".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+1"].messages[0].status, None);
}
#[rstest]
fn receipt_before_send_timestamp_is_buffered_and_replayed(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let local_ts = 1700000000000_i64;
let server_ts = 1700000000123_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sending),
timestamp_ms: local_ts,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.pending_sends.insert("rpc-1".to_string(), (conv_id.to_string(), local_ts));
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: conv_id.to_string(),
receipt_type: "DELIVERY".to_string(),
timestamps: vec![server_ts],
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Sending)
);
assert_eq!(app.pending_receipts.len(), 1);
app.handle_signal_event(SignalEvent::SendTimestamp {
rpc_id: "rpc-1".to_string(),
server_ts,
});
assert_eq!(
app.conversations[conv_id].messages[0].status,
Some(MessageStatus::Delivered)
);
assert!(app.pending_receipts.is_empty());
}
#[rstest]
fn handle_reaction_adds_to_message(mut app: App) {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hello".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.conversations["+1"].messages[0].timestamp_ms;
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts_ms,
is_remove: false,
});
let reactions = &app.conversations["+1"].messages[0].reactions;
assert_eq!(reactions.len(), 1);
assert_eq!(reactions[0].emoji, "\u{1f44d}");
assert_eq!(reactions[0].sender, "Bob");
}
#[rstest]
fn handle_reaction_replaces_existing_from_same_sender(mut app: App) {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hello".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.conversations["+1"].messages[0].timestamp_ms;
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts_ms,
is_remove: false,
});
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{2764}\u{fe0f}".to_string(),
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts_ms,
is_remove: false,
});
let reactions = &app.conversations["+1"].messages[0].reactions;
assert_eq!(reactions.len(), 1);
assert_eq!(reactions[0].emoji, "\u{2764}\u{fe0f}");
}
#[rstest]
fn handle_reaction_remove(mut app: App) {
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hello".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.conversations["+1"].messages[0].timestamp_ms;
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts_ms,
is_remove: false,
});
assert_eq!(app.conversations["+1"].messages[0].reactions.len(), 1);
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts_ms,
is_remove: true,
});
assert_eq!(app.conversations["+1"].messages[0].reactions.len(), 0);
}
#[rstest]
fn handle_reaction_on_own_message(mut app: App) {
let conv_id = "+1";
app.get_or_create_conversation(conv_id, "Alice", false);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.conversations.get_mut(conv_id) {
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
timestamp: chrono::Utc::now(),
body: "hello".to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: Some(MessageStatus::Sent),
timestamp_ms: ts_ms,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: String::new(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
}
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: conv_id.to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_author: "+10000000000".to_string(), target_timestamp: ts_ms,
is_remove: false,
});
let reactions = &app.conversations[conv_id].messages[0].reactions;
assert_eq!(reactions.len(), 1);
assert_eq!(reactions[0].sender, "Alice");
}
#[rstest]
fn handle_reaction_unknown_message_persists_to_db(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: "+1".to_string(),
emoji: "\u{1f44d}".to_string(),
sender: "+2".to_string(),
sender_name: None,
target_author: "+1".to_string(),
target_timestamp: 9999999999999,
is_remove: false,
});
assert!(app.conversations["+1"].messages.is_empty());
let db_reactions = app.db.load_reactions("+1").unwrap();
assert_eq!(db_reactions.len(), 1);
}
#[rstest]
fn contact_list_resolves_reactions_and_quotes(mut app: App) {
app.get_or_create_conversation("+1", "+1", false);
let conv = app.conversations.get_mut("+1").unwrap();
conv.messages.push(DisplayMessage {
sender: "Charlie".to_string(),
body: "hey".to_string(),
timestamp: chrono::Utc::now(),
is_system: false,
image_lines: None,
image_path: None,
status: None,
timestamp_ms: 900,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: None,
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: "+3".to_string(), expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
conv.messages.push(DisplayMessage {
sender: "Alice".to_string(),
body: "hello".to_string(),
timestamp: chrono::Utc::now(),
is_system: false,
image_lines: None,
image_path: None,
status: None,
timestamp_ms: 1000,
reactions: vec![
Reaction { emoji: "\u{1f44d}".to_string(), sender: "+2".to_string() }, Reaction { emoji: "\u{2764}".to_string(), sender: "+10000000000".to_string() }, Reaction { emoji: "\u{1f602}".to_string(), sender: "+3".to_string() }, ],
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: Some(Quote { author: "+10000000000".to_string(), body: "quoted".to_string(), timestamp_ms: 500, author_id: "+10000000000".to_string() }),
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: "+1".to_string(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
conv.messages.push(DisplayMessage {
sender: "you".to_string(),
body: "reply".to_string(),
timestamp: chrono::Utc::now(),
is_system: false,
image_lines: None,
image_path: None,
status: None,
timestamp_ms: 1100,
reactions: Vec::new(),
mention_ranges: Vec::new(),
style_ranges: Vec::new(),
quote: Some(Quote { author: "+3".to_string(), body: "hey".to_string(), timestamp_ms: 900, author_id: "+3".to_string() }),
is_edited: false,
is_deleted: false,
is_pinned: false,
sender_id: "+10000000000".to_string(),
expires_in_seconds: 0,
expiration_start_ms: 0,
poll_data: None,
poll_votes: Vec::new(),
preview: None,
preview_image_lines: None,
preview_image_path: None,
});
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: Some("Alice".to_string()), uuid: None },
Contact { number: "+2".to_string(), name: Some("Bob".to_string()), uuid: None },
]));
let msgs = &app.conversations["+1"].messages;
assert_eq!(msgs[1].reactions[0].sender, "Bob");
assert_eq!(msgs[1].reactions[1].sender, "you");
assert_eq!(msgs[1].reactions[2].sender, "Charlie");
assert_eq!(msgs[1].quote.as_ref().unwrap().author, "you");
assert_eq!(msgs[2].quote.as_ref().unwrap().author, "Charlie");
}
#[rstest]
#[case("basic", &[("uuid-alice", "Alice")], "\u{FFFC} check this out",
&[(0, 1, "uuid-alice")], "@Alice check this out", &["@Alice"])]
#[case("multiple", &[("uuid-alice", "Alice"), ("uuid-bob", "Bob")],
"\u{FFFC} and \u{FFFC} should join",
&[(0, 1, "uuid-alice"), (6, 1, "uuid-bob")],
"@Alice and @Bob should join", &["@Alice", "@Bob"])]
#[case("unknown_uuid", &[], "\u{FFFC} said hi",
&[(0, 1, "abcdef12-3456")], "@abcdef12 said hi", &["@abcdef12"])]
#[case("empty", &[], "no mentions here", &[], "no mentions here", &[])]
fn resolve_mentions_variants(
mut app: App,
#[case] _label: &str,
#[case] uuid_names: &[(&str, &str)],
#[case] body: &str,
#[case] mention_data: &[(usize, usize, &str)],
#[case] expected_body: &str,
#[case] expected_tags: &[&str],
) {
for (uuid, name) in uuid_names {
app.uuid_to_name.insert(uuid.to_string(), name.to_string());
}
let mentions: Vec<Mention> = mention_data.iter()
.map(|(start, length, uuid)| Mention { start: *start, length: *length, uuid: uuid.to_string() })
.collect();
let (resolved, ranges) = app.resolve_mentions(body, &mentions);
assert_eq!(resolved, expected_body);
assert_eq!(ranges.len(), expected_tags.len());
for (range, tag) in ranges.iter().zip(expected_tags.iter()) {
assert_eq!(&resolved[range.0..range.1], *tag);
}
}
#[rstest]
fn mention_autocomplete_in_direct_chat(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.active_conversation = Some("+1".to_string());
app.input_buffer = "@Al".to_string();
app.input_cursor = 3;
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.autocomplete_mode, AutocompleteMode::Mention);
assert_eq!(app.mention_candidates.len(), 1);
assert_eq!(app.mention_candidates[0].1, "Alice");
}
#[rstest]
fn mention_autocomplete_in_group(mut app: App) {
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Test Group".to_string(),
members: vec!["+1".to_string(), "+2".to_string()],
member_uuids: vec![],
});
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.contact_names.insert("+2".to_string(), "Bob".to_string());
app.get_or_create_conversation("g1", "Test Group", true);
app.active_conversation = Some("g1".to_string());
app.input_buffer = "@Al".to_string();
app.input_cursor = 3;
app.update_autocomplete();
assert!(app.autocomplete_visible);
assert_eq!(app.autocomplete_mode, AutocompleteMode::Mention);
assert_eq!(app.mention_candidates.len(), 1);
assert_eq!(app.mention_candidates[0].1, "Alice");
}
#[rstest]
fn apply_mention_autocomplete(mut app: App) {
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Test Group".to_string(),
members: vec!["+1".to_string()],
member_uuids: vec![],
});
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.number_to_uuid.insert("+1".to_string(), "uuid-alice".to_string());
app.get_or_create_conversation("g1", "Test Group", true);
app.active_conversation = Some("g1".to_string());
app.input_buffer = "Hey @Al".to_string();
app.input_cursor = 7;
app.update_autocomplete();
assert!(app.autocomplete_visible);
app.apply_autocomplete();
assert_eq!(app.input_buffer, "Hey @Alice ");
assert_eq!(app.pending_mentions.len(), 1);
assert_eq!(app.pending_mentions[0].0, "Alice");
assert_eq!(app.pending_mentions[0].1.as_deref(), Some("uuid-alice"));
}
#[rstest]
fn prepare_outgoing_mentions(mut app: App) {
app.pending_mentions = vec![
("Alice".to_string(), Some("uuid-alice".to_string())),
];
let (wire, mentions) = app.prepare_outgoing_mentions("Hey @Alice what's up");
assert_eq!(wire, "Hey \u{FFFC} what's up");
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0, 4); assert_eq!(mentions[0].1, "uuid-alice");
}
#[rstest]
fn prepare_outgoing_no_pending_mentions(app: App) {
let (wire, mentions) = app.prepare_outgoing_mentions("Hello world");
assert_eq!(wire, "Hello world");
assert!(mentions.is_empty());
}
#[rstest]
fn contact_list_builds_uuid_maps(mut app: App) {
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact {
number: "+1".to_string(),
name: Some("Alice".to_string()),
uuid: Some("uuid-alice".to_string()),
},
]));
assert_eq!(app.uuid_to_name.get("uuid-alice").unwrap(), "Alice");
assert_eq!(app.number_to_uuid.get("+1").unwrap(), "uuid-alice");
}
#[rstest]
fn group_list_stores_groups(mut app: App) {
app.handle_signal_event(SignalEvent::GroupList(vec![
Group {
id: "g1".to_string(),
name: "Test".to_string(),
members: vec!["+1".to_string(), "+2".to_string()],
member_uuids: vec![],
},
]));
assert!(app.groups.contains_key("g1"));
assert_eq!(app.groups["g1"].members.len(), 2);
}
#[rstest]
fn incoming_message_resolves_mentions(mut app: App) {
app.uuid_to_name.insert("uuid-bob".to_string(), "Bob".to_string());
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("\u{FFFC} check this".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![Mention { start: 0, length: 1, uuid: "uuid-bob".to_string() }],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let conv = &app.conversations["+1"];
assert_eq!(conv.messages[0].body, "@Bob check this");
assert_eq!(conv.messages[0].mention_ranges.len(), 1);
}
#[rstest]
fn backspace_at_zero_clears_pending_attachment(mut app: App) {
app.pending_attachment = Some(std::path::PathBuf::from("/tmp/photo.jpg"));
app.input_cursor = 0;
app.input_buffer.clear();
app.apply_input_edit(KeyCode::Backspace);
assert!(app.pending_attachment.is_none());
}
#[rstest]
fn empty_text_with_attachment_sends(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.pending_attachment = Some(std::path::PathBuf::from("/tmp/photo.jpg"));
app.input_buffer.clear();
app.input_cursor = 0;
let result = app.handle_input();
assert!(result.is_some());
assert!(app.pending_attachment.is_none());
}
#[rstest]
fn attach_no_conversation_shows_error(mut app: App) {
app.active_conversation = None;
app.open_file_browser();
assert!(!app.show_file_browser);
assert!(app.status_message.contains("No active conversation"));
}
#[rstest]
fn clears_attachment_on_next_conversation(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.pending_attachment = Some(std::path::PathBuf::from("/tmp/photo.jpg"));
app.get_or_create_conversation("+2", "Bob", false);
app.next_conversation();
assert!(app.pending_attachment.is_none());
}
#[rstest]
fn clears_attachment_on_part_command(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.pending_attachment = Some(std::path::PathBuf::from("/tmp/photo.jpg"));
app.input_buffer = "/part".to_string();
app.input_cursor = 5;
app.handle_input();
assert!(app.pending_attachment.is_none());
}
#[rstest]
fn search_opens_overlay(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.db.insert_message("+1", "Alice", "2025-01-01T00:00:00Z", "hello world", false, None, 1000).unwrap();
app.input_buffer = "/search hello".to_string();
app.input_cursor = 13;
app.handle_input();
assert!(app.show_search);
assert_eq!(app.search_query, "hello");
assert!(!app.search_results.is_empty());
assert_eq!(app.search_results[0].body, "hello world");
}
#[rstest]
fn search_without_query_shows_error(mut app: App) {
app.input_buffer = "/search".to_string();
app.input_cursor = 7;
app.handle_input();
assert!(!app.show_search);
assert!(app.status_message.contains("requires"));
}
#[rstest]
fn search_overlay_esc_closes(mut app: App) {
app.show_search = true;
app.search_query = "test".to_string();
app.handle_search_key(KeyCode::Esc);
assert!(!app.show_search);
assert!(app.search_query.is_empty());
}
#[rstest]
fn search_overlay_typing_refines(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.db.insert_message("+1", "Alice", "2025-01-01T00:00:00Z", "hello world", false, None, 1000).unwrap();
app.db.insert_message("+1", "Alice", "2025-01-01T00:01:00Z", "goodbye world", false, None, 2000).unwrap();
app.show_search = true;
app.search_query = "hello".to_string();
app.run_search();
assert_eq!(app.search_results.len(), 1);
app.search_query = "world".to_string();
app.run_search();
assert_eq!(app.search_results.len(), 2);
}
#[rstest]
fn system_message_inserted_with_is_system_true(mut app: App) {
let ts = chrono::Utc::now();
let ts_ms = ts.timestamp_millis();
app.handle_signal_event(SignalEvent::SystemMessage {
conv_id: "+15551234567".to_string(),
body: "Missed voice call".to_string(),
timestamp: ts,
timestamp_ms: ts_ms,
});
assert!(app.conversations.contains_key("+15551234567"));
let conv = &app.conversations["+15551234567"];
assert_eq!(conv.messages.len(), 1);
assert!(conv.messages[0].is_system);
assert_eq!(conv.messages[0].body, "Missed voice call");
assert!(conv.messages[0].sender.is_empty());
}
#[rstest]
fn unread_bar_clears_on_active_incoming_message(mut app: App) {
let msg1 = SignalMessage {
source: "+15551234567".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("first".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg1));
assert_eq!(app.conversations["+15551234567"].messages.len(), 1);
let read_idx = app.last_read_index.get("+15551234567").copied().unwrap_or(0);
assert_eq!(read_idx, 0);
app.active_conversation = Some("+15551234567".to_string());
let msg2 = SignalMessage {
source: "+15551234567".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("second".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
let total = app.conversations["+15551234567"].messages.len();
let read_idx = app.last_read_index["+15551234567"];
assert_eq!(total, 2);
assert_eq!(read_idx, total);
}
#[rstest]
fn read_sync_advances_read_marker_and_clears_unread(mut app: App) {
let msg = |body: &str, ts_ms: i64| SignalMessage {
source: "+15551234567".to_string(),
source_name: Some("Alice".to_string()),
timestamp: DateTime::from_timestamp_millis(ts_ms).unwrap(),
body: Some(body.to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg("one", 1000)));
app.handle_signal_event(SignalEvent::MessageReceived(msg("two", 2000)));
app.handle_signal_event(SignalEvent::MessageReceived(msg("three", 3000)));
assert_eq!(app.conversations["+15551234567"].unread, 3);
assert_eq!(app.last_read_index.get("+15551234567").copied().unwrap_or(0), 0);
app.handle_signal_event(SignalEvent::ReadSyncReceived {
read_messages: vec![("+15551234567".to_string(), 2000)],
});
assert_eq!(app.last_read_index["+15551234567"], 2);
assert_eq!(app.conversations["+15551234567"].unread, 1);
}
#[rstest]
fn read_sync_does_not_retreat_read_marker(mut app: App) {
let msg = |body: &str, ts_ms: i64| SignalMessage {
source: "+15551234567".to_string(),
source_name: Some("Alice".to_string()),
timestamp: DateTime::from_timestamp_millis(ts_ms).unwrap(),
body: Some(body.to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg("one", 1000)));
app.handle_signal_event(SignalEvent::MessageReceived(msg("two", 2000)));
app.handle_signal_event(SignalEvent::MessageReceived(msg("three", 3000)));
app.handle_signal_event(SignalEvent::ReadSyncReceived {
read_messages: vec![("+15551234567".to_string(), 3000)],
});
assert_eq!(app.last_read_index["+15551234567"], 3);
assert_eq!(app.conversations["+15551234567"].unread, 0);
app.handle_signal_event(SignalEvent::ReadSyncReceived {
read_messages: vec![("+15551234567".to_string(), 1000)],
});
assert_eq!(app.last_read_index["+15551234567"], 3);
assert_eq!(app.conversations["+15551234567"].unread, 0);
}
#[rstest]
fn text_style_ranges_resolved_to_byte_offsets(app: App) {
let body = "hello bold world";
let styles = vec![
TextStyle { start: 6, length: 4, style: StyleType::Bold },
TextStyle { start: 11, length: 5, style: StyleType::Italic },
];
let resolved = app.resolve_text_styles(body, &styles, &[]);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0], (6, 10, StyleType::Bold)); assert_eq!(resolved[1], (11, 16, StyleType::Italic)); }
#[rstest]
fn text_style_ranges_with_multibyte_chars(app: App) {
let body = "Hi \u{1F600} bold";
let styles = vec![
TextStyle { start: 6, length: 4, style: StyleType::Bold },
];
let resolved = app.resolve_text_styles(body, &styles, &[]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, 8); assert_eq!(resolved[0].1, 12); assert_eq!(resolved[0].2, StyleType::Bold);
}
#[rstest]
fn text_style_ranges_with_mentions(mut app: App) {
app.uuid_to_name.insert("uuid-bob".to_string(), "Bob".to_string());
let resolved_body = "@Bob is bold";
let mentions = vec![Mention { start: 0, length: 1, uuid: "uuid-bob".to_string() }];
let styles = vec![
TextStyle { start: 5, length: 4, style: StyleType::Strikethrough },
];
let resolved = app.resolve_text_styles(resolved_body, &styles, &mentions);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, 8);
assert_eq!(resolved[0].1, 12);
assert_eq!(resolved[0].2, StyleType::Strikethrough);
}
#[rstest]
fn text_style_ranges_empty_styles(app: App) {
let resolved = app.resolve_text_styles("hello world", &[], &[]);
assert!(resolved.is_empty());
}
#[test]
fn group_command_parsed() {
assert!(matches!(crate::input::parse_input("/group"), crate::input::InputAction::Group));
assert!(matches!(crate::input::parse_input("/g"), crate::input::InputAction::Group));
}
#[rstest]
fn group_menu_items_in_group(mut app: App) {
app.get_or_create_conversation("g1", "Family", true);
app.active_conversation = Some("g1".to_string());
let items = app.group_menu_items();
assert_eq!(items.len(), 5);
assert_eq!(items[0].label, "Members");
assert_eq!(items[items.len() - 1].label, "Leave");
}
#[rstest]
fn group_menu_items_not_in_group(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
let items = app.group_menu_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "Create group");
}
#[rstest]
fn group_menu_items_no_conversation(app: App) {
let items = app.group_menu_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "Create group");
}
#[rstest]
fn group_add_filter_excludes_existing_members(mut app: App) {
app.get_or_create_conversation("g1", "Family", true);
app.active_conversation = Some("g1".to_string());
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec!["+1".to_string(), "+2".to_string()],
member_uuids: vec![],
});
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.contact_names.insert("+2".to_string(), "Bob".to_string());
app.contact_names.insert("+3".to_string(), "Charlie".to_string());
app.refresh_group_add_filter();
assert_eq!(app.group_menu_filtered.len(), 1);
assert_eq!(app.group_menu_filtered[0].0, "+3");
}
#[rstest]
fn group_remove_filter_excludes_self(mut app: App) {
app.get_or_create_conversation("g1", "Family", true);
app.active_conversation = Some("g1".to_string());
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec!["+10000000000".to_string(), "+1".to_string(), "+2".to_string()],
member_uuids: vec![],
});
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.contact_names.insert("+2".to_string(), "Bob".to_string());
app.refresh_group_remove_filter();
assert_eq!(app.group_menu_filtered.len(), 2);
let phones: Vec<&str> = app.group_menu_filtered.iter().map(|(p, _)| p.as_str()).collect();
assert!(!phones.contains(&"+10000000000"));
assert!(phones.contains(&"+1"));
assert!(phones.contains(&"+2"));
}
#[rstest]
fn group_menu_state_transitions(mut app: App) {
app.get_or_create_conversation("g1", "Family", true);
app.active_conversation = Some("g1".to_string());
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec!["+1".to_string()],
member_uuids: vec![],
});
app.input_buffer = "/group".to_string();
app.input_cursor = 6;
app.handle_input();
assert_eq!(app.group_menu_state, Some(GroupMenuState::Menu));
app.handle_group_menu_key(KeyCode::Char('m'));
assert_eq!(app.group_menu_state, Some(GroupMenuState::Members));
app.handle_group_menu_key(KeyCode::Esc);
assert_eq!(app.group_menu_state, Some(GroupMenuState::Menu));
app.handle_group_menu_key(KeyCode::Char('l'));
assert_eq!(app.group_menu_state, Some(GroupMenuState::LeaveConfirm));
app.handle_group_menu_key(KeyCode::Char('n'));
assert_eq!(app.group_menu_state, Some(GroupMenuState::Menu));
app.handle_group_menu_key(KeyCode::Esc);
assert_eq!(app.group_menu_state, None);
}
#[rstest]
fn group_leave_produces_send_request(mut app: App) {
app.get_or_create_conversation("g1", "Family", true);
app.active_conversation = Some("g1".to_string());
app.groups.insert("g1".to_string(), Group {
id: "g1".to_string(),
name: "Family".to_string(),
members: vec![],
member_uuids: vec![],
});
app.group_menu_state = Some(GroupMenuState::LeaveConfirm);
let req = app.handle_group_menu_key(KeyCode::Char('y'));
assert!(req.is_some());
assert!(matches!(req, Some(SendRequest::LeaveGroup { group_id }) if group_id == "g1"));
assert_eq!(app.group_menu_state, None);
}
#[rstest]
fn group_create_produces_send_request(mut app: App) {
app.group_menu_state = Some(GroupMenuState::Create);
app.group_menu_input = "New Group".to_string();
let req = app.handle_group_menu_key(KeyCode::Enter);
assert!(req.is_some());
assert!(matches!(req, Some(SendRequest::CreateGroup { name }) if name == "New Group"));
assert_eq!(app.group_menu_state, None);
}
#[rstest]
fn group_rename_produces_send_request(mut app: App) {
app.get_or_create_conversation("g1", "Old Name", true);
app.active_conversation = Some("g1".to_string());
app.group_menu_state = Some(GroupMenuState::Rename);
app.group_menu_input = "New Name".to_string();
let req = app.handle_group_menu_key(KeyCode::Enter);
assert!(req.is_some());
assert!(matches!(req, Some(SendRequest::RenameGroup { group_id, name }) if group_id == "g1" && name == "New Name"));
assert_eq!(app.group_menu_state, None);
}
fn msg_from(source: &str) -> SignalMessage {
SignalMessage {
source: source.to_string(),
source_name: None,
timestamp: chrono::Utc::now(),
body: Some("hello".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
}
}
#[rstest]
fn unknown_sender_creates_unaccepted_conversation(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(!app.conversations["+1"].accepted);
}
#[rstest]
fn known_contact_creates_accepted_conversation(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(app.conversations["+1"].accepted);
}
#[rstest]
fn outgoing_sync_creates_accepted_conversation(mut app: App) {
let msg = SignalMessage {
source: "+10000000000".to_string(),
source_name: None,
timestamp: chrono::Utc::now(),
body: Some("hey".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: true,
destination: Some("+1".to_string()),
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.conversations["+1"].accepted);
}
#[rstest]
fn contact_sync_auto_accepts_matching_conversations(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(!app.conversations["+1"].accepted);
app.handle_signal_event(SignalEvent::ContactList(vec![
Contact { number: "+1".to_string(), name: Some("Alice".to_string()), uuid: None },
]));
assert!(app.conversations["+1"].accepted);
}
#[rstest]
fn accept_key_returns_send_request_and_marks_accepted(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
app.active_conversation = Some("+1".to_string());
app.show_message_request = true;
let req = app.handle_message_request_key(KeyCode::Char('a'));
assert!(app.conversations["+1"].accepted);
assert!(!app.show_message_request);
assert!(matches!(
req,
Some(SendRequest::MessageRequestResponse { ref response_type, .. })
if response_type == "accept"
));
}
#[rstest]
fn delete_key_removes_conversation(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
app.active_conversation = Some("+1".to_string());
app.show_message_request = true;
let req = app.handle_message_request_key(KeyCode::Char('d'));
assert!(!app.conversations.contains_key("+1"));
assert!(!app.conversation_order.contains(&"+1".to_string()));
assert!(app.active_conversation.is_none());
assert!(!app.show_message_request);
assert!(matches!(
req,
Some(SendRequest::MessageRequestResponse { ref response_type, .. })
if response_type == "delete"
));
}
#[rstest]
fn esc_closes_message_request_overlay(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
app.active_conversation = Some("+1".to_string());
app.show_message_request = true;
let req = app.handle_message_request_key(KeyCode::Esc);
assert!(req.is_none());
assert!(!app.show_message_request);
assert!(app.active_conversation.is_none());
}
#[rstest]
fn bell_skipped_for_unaccepted_conversation(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(!app.pending_bell);
}
#[rstest]
fn bell_skipped_for_blocked_conversation(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
if let Some(conv) = app.conversations.get_mut("+1") {
conv.accepted = true;
}
app.blocked_conversations.insert("+1".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(!app.pending_bell);
}
#[rstest]
fn read_receipts_not_sent_for_unaccepted(mut app: App) {
app.send_read_receipts = true;
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
app.queue_read_receipts_for_conv("+1", 0);
assert!(app.pending_read_receipts.is_empty());
}
#[rstest]
fn read_receipts_not_sent_for_blocked(mut app: App) {
app.send_read_receipts = true;
app.get_or_create_conversation("+1", "Alice", false);
if let Some(conv) = app.conversations.get_mut("+1") {
conv.accepted = true;
}
app.blocked_conversations.insert("+1".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
app.queue_read_receipts_for_conv("+1", 0);
assert!(app.pending_read_receipts.is_empty());
}
#[rstest]
fn block_adds_to_set_and_returns_send_request(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.input_buffer = "/block".to_string();
let req = app.handle_input();
assert!(app.blocked_conversations.contains("+1"));
assert!(matches!(req, Some(SendRequest::Block { ref recipient, is_group }) if recipient == "+1" && !is_group));
assert!(app.status_message.contains("blocked"));
}
#[rstest]
fn unblock_removes_from_set_and_returns_send_request(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.blocked_conversations.insert("+1".to_string());
app.input_buffer = "/unblock".to_string();
let req = app.handle_input();
assert!(!app.blocked_conversations.contains("+1"));
assert!(matches!(req, Some(SendRequest::Unblock { ref recipient, is_group }) if recipient == "+1" && !is_group));
assert!(app.status_message.contains("unblocked"));
}
#[rstest]
#[case("/block", true, "already blocked")]
#[case("/unblock", false, "not blocked")]
fn block_unblock_already_in_state(
mut app: App,
#[case] cmd: &str,
#[case] pre_blocked: bool,
#[case] expected_msg: &str,
) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
if pre_blocked {
app.blocked_conversations.insert("+1".to_string());
}
app.input_buffer = cmd.to_string();
let req = app.handle_input();
assert!(req.is_none());
assert!(app.status_message.contains(expected_msg));
}
#[rstest]
#[case("/block", "no active conversation")]
#[case("/unblock", "no active conversation")]
fn block_unblock_no_active_conversation(mut app: App, #[case] cmd: &str, #[case] expected_msg: &str) {
app.input_buffer = cmd.to_string();
let req = app.handle_input();
assert!(req.is_none());
assert!(app.status_message.contains(expected_msg));
}
fn mouse_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
}
}
fn mouse_scroll_up(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: col,
row,
modifiers: KeyModifiers::empty(),
}
}
fn mouse_scroll_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: col,
row,
modifiers: KeyModifiers::empty(),
}
}
#[rstest]
fn mouse_disabled_ignores_events(mut app: App) {
app.mouse_enabled = false;
app.mouse_messages_area = Rect::new(0, 0, 80, 20);
let result = app.handle_mouse_event(mouse_scroll_up(10, 10));
assert!(result.is_none());
assert_eq!(app.scroll_offset, 0);
}
#[rstest]
fn mouse_overlay_scroll_navigates_list(mut app: App) {
app.show_settings = true;
app.settings_index = 0;
app.mouse_messages_area = Rect::new(0, 0, 80, 20);
app.handle_mouse_event(mouse_scroll_down(10, 10));
assert_eq!(app.settings_index, 1);
assert_eq!(app.scroll_offset, 0); }
#[rstest]
#[case(0, true, 3)]
#[case(10, false, 7)]
#[case(1, false, 0)]
fn mouse_scroll_behavior(
mut app: App,
#[case] initial_offset: usize,
#[case] scroll_up: bool,
#[case] expected_offset: usize,
) {
app.mouse_messages_area = Rect::new(0, 0, 80, 20);
app.scroll_offset = initial_offset;
let event = if scroll_up {
mouse_scroll_up(10, 10)
} else {
mouse_scroll_down(10, 10)
};
app.handle_mouse_event(event);
assert_eq!(app.scroll_offset, expected_offset);
}
#[rstest]
fn mouse_sidebar_click_switches_conversation(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.get_or_create_conversation("+2", "Bob", false);
app.active_conversation = Some("+1".to_string());
app.mouse_sidebar_inner = Some(Rect::new(0, 0, 20, 10));
app.handle_mouse_event(mouse_down(5, 1));
assert_eq!(app.active_conversation.as_deref(), Some("+2"));
}
#[rstest]
fn mouse_input_click_positions_cursor(mut app: App) {
app.mode = InputMode::Normal;
app.input_buffer = "hello world".to_string();
app.input_cursor = 0;
app.mouse_input_area = Rect::new(10, 20, 40, 3);
app.mouse_input_prefix_len = 2;
app.handle_mouse_event(mouse_down(18, 21));
assert_eq!(app.mode, InputMode::Insert);
assert_eq!(app.input_cursor, 5);
}
#[rstest]
fn mouse_input_click_handles_multibyte(mut app: App) {
app.mode = InputMode::Normal;
app.input_buffer = "caf\u{e9} ok".to_string(); app.input_cursor = 0;
app.mouse_input_area = Rect::new(0, 0, 40, 3);
app.mouse_input_prefix_len = 2;
app.handle_mouse_event(mouse_down(7, 1));
assert_eq!(app.input_cursor, 5); }
#[rstest]
fn has_overlay_detects_all_overlays(mut app: App) {
assert!(!app.has_overlay());
app.show_settings = true;
assert!(app.has_overlay());
app.show_settings = false;
app.show_help = true;
assert!(app.has_overlay());
app.show_help = false;
app.show_contacts = true;
assert!(app.has_overlay());
app.show_contacts = false;
app.show_search = true;
assert!(app.has_overlay());
app.show_search = false;
app.show_file_browser = true;
assert!(app.has_overlay());
app.show_file_browser = false;
app.show_action_menu = true;
assert!(app.has_overlay());
app.show_action_menu = false;
app.show_reaction_picker = true;
assert!(app.has_overlay());
app.show_reaction_picker = false;
app.show_delete_confirm = true;
assert!(app.has_overlay());
app.show_delete_confirm = false;
app.group_menu_state = Some(GroupMenuState::Menu);
assert!(app.has_overlay());
app.group_menu_state = None;
app.show_message_request = true;
assert!(app.has_overlay());
app.show_message_request = false;
app.autocomplete_visible = true;
assert!(app.has_overlay());
app.autocomplete_visible = false;
app.show_pin_duration = true;
assert!(app.has_overlay());
app.show_pin_duration = false;
app.show_poll_vote = true;
assert!(app.has_overlay());
app.show_poll_vote = false;
app.show_about = true;
assert!(app.has_overlay());
app.show_about = false;
app.show_profile = true;
assert!(app.has_overlay());
app.show_profile = false;
app.show_forward = true;
assert!(app.has_overlay());
app.show_forward = false;
assert!(!app.has_overlay());
}
fn make_msg(source: &str, body: Option<&str>, group_id: Option<&str>, is_outgoing: bool) -> SignalMessage {
SignalMessage {
source: source.to_string(),
source_name: None,
timestamp: chrono::Utc::now(),
body: body.map(|s| s.to_string()),
attachments: vec![],
group_id: group_id.map(|s| s.to_string()),
group_name: None,
is_outgoing,
destination: None,
mentions: vec![],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
}
}
#[rstest]
fn typing_indicator_adds_and_removes(mut app: App) {
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
is_typing: true,
group_id: None,
});
assert!(app.typing_indicators.contains_key("+1"));
assert_eq!(app.contact_names.get("+1").unwrap(), "Alice");
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: None,
is_typing: false,
group_id: None,
});
assert!(!app.typing_indicators.contains_key("+1"));
}
#[rstest]
fn error_event_sets_status(mut app: App) {
app.handle_signal_event(SignalEvent::Error("connection lost".to_string()));
assert!(app.status_message.contains("connection lost"));
}
#[rstest]
fn message_with_image_attachment(mut app: App) {
let mut msg = make_msg("+1", None, None, false);
msg.attachments = vec![Attachment {
id: "a1".to_string(),
content_type: "image/jpeg".to_string(),
filename: Some("photo.jpg".to_string()),
local_path: None,
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let conv = &app.conversations["+1"];
assert!(conv.messages.iter().any(|m| m.body.contains("[image: photo.jpg]")));
}
#[rstest]
fn message_with_non_image_attachment(mut app: App) {
let mut msg = make_msg("+1", None, None, false);
msg.attachments = vec![Attachment {
id: "a1".to_string(),
content_type: "application/pdf".to_string(),
filename: Some("doc.pdf".to_string()),
local_path: None,
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let conv = &app.conversations["+1"];
assert!(conv.messages.iter().any(|m| m.body.contains("[attachment: doc.pdf]")));
}
#[rstest]
fn message_with_body_and_attachment(mut app: App) {
let mut msg = make_msg("+1", Some("look at this"), None, false);
msg.attachments = vec![Attachment {
id: "a1".to_string(),
content_type: "image/png".to_string(),
filename: Some("img.png".to_string()),
local_path: None,
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let conv = &app.conversations["+1"];
assert_eq!(conv.messages.len(), 2);
assert!(conv.messages[0].body.contains("look at this"));
assert!(conv.messages[1].body.contains("[image: img.png]"));
}
#[rstest]
fn attachment_without_filename_uses_content_type(mut app: App) {
let mut msg = make_msg("+1", None, None, false);
msg.attachments = vec![Attachment {
id: "a1".to_string(),
content_type: "audio/ogg".to_string(),
filename: None,
local_path: None,
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let conv = &app.conversations["+1"];
assert!(conv.messages.iter().any(|m| m.body.contains("[attachment: audio/ogg]")));
}
#[rstest]
fn bell_rings_for_background_dm(mut app: App) {
app.contact_names.insert("+1".to_string(), "Alice".to_string());
app.get_or_create_conversation("+other", "Other", false);
app.active_conversation = Some("+other".to_string());
app.notify_direct = true;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.pending_bell);
}
#[rstest]
fn bell_not_set_for_active_conversation(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.notify_direct = true;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.pending_bell);
}
#[rstest]
fn bell_skipped_when_notify_disabled(mut app: App) {
app.get_or_create_conversation("+other", "Other", false);
app.active_conversation = Some("+other".to_string());
app.notify_direct = false;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.pending_bell);
}
#[rstest]
fn bell_for_group_respects_setting(mut app: App) {
app.handle_signal_event(SignalEvent::GroupList(vec![
Group { id: "g1".to_string(), name: "Team".to_string(), members: vec![], member_uuids: vec![] },
]));
app.get_or_create_conversation("+other", "Other", false);
app.active_conversation = Some("+other".to_string());
app.notify_group = true;
let msg = make_msg("+1", Some("hi team"), Some("g1"), false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.pending_bell);
app.pending_bell = false;
app.notify_group = false;
let msg2 = make_msg("+2", Some("again"), Some("g1"), false);
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
assert!(!app.pending_bell);
}
#[rstest]
fn unread_increments_for_background(mut app: App) {
app.active_conversation = None;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+1"].unread, 1);
}
#[rstest]
fn unread_no_increment_for_active(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+1"].unread, 0);
}
#[rstest]
fn active_conv_queues_read_receipt(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
app.active_conversation = Some("+1".to_string());
app.send_read_receipts = true;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.pending_read_receipts.is_empty(), "expected read receipt to be queued");
let (recipient, _) = &app.pending_read_receipts[0];
assert_eq!(recipient, "+1");
}
#[rstest]
fn handle_message_syncs_expiration_timer(mut app: App) {
app.get_or_create_conversation("+1", "Alice", false);
assert_eq!(app.conversations["+1"].expiration_timer, 0);
let mut msg = make_msg("+1", Some("secret"), None, false);
msg.expires_in_seconds = 3600;
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.conversations["+1"].expiration_timer, 3600);
}
#[rstest]
fn paste_text_inserts_into_input_buffer(mut app: App) {
app.mode = InputMode::Insert;
app.active_conversation = Some("test-conv".to_string());
app.handle_paste_text("hello world");
assert_eq!(app.input_buffer, "hello world");
}
#[rstest]
fn paste_file_path_inserts_as_text(mut app: App) {
let path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR"));
app.mode = InputMode::Insert;
app.active_conversation = Some("test-conv".to_string());
app.handle_paste_text(&path);
assert!(app.pending_attachment.is_none());
assert_eq!(app.input_buffer, path);
}
#[rstest]
fn paste_empty_text_shows_status_message(mut app: App) {
app.active_conversation = Some("test-conv".to_string());
app.handle_paste_text(" ");
assert!(app.status_message.contains("empty"));
assert!(app.pending_attachment.is_none());
assert!(app.input_buffer.is_empty());
}
#[rstest]
fn paste_clipboard_image_saves_png_as_attachment(mut app: App) {
let img_data = arboard::ImageData {
width: 2,
height: 2,
bytes: std::borrow::Cow::Owned(vec![
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 255, 0, 255,
]),
};
app.active_conversation = Some("test-conv".to_string());
app.handle_clipboard_image(img_data);
assert!(app.pending_attachment.is_some());
let path = app.pending_attachment.as_ref().unwrap();
assert!(path.exists(), "PNG file should have been written to disk");
assert!(path.to_string_lossy().contains("clipboard_"));
assert!(path.extension().is_some_and(|e| e == "png"));
let _ = std::fs::remove_file(path);
}
#[rstest]
fn paste_command_without_active_conversation_sets_error(mut app: App) {
app.handle_paste_command();
assert!(app.status_message.contains("No active conversation"));
}
#[rstest]
fn group_typing_indicator_keyed_by_group_not_sender(mut app: App) {
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
is_typing: true,
group_id: Some("group-a".to_string()),
});
assert!(app.typing_indicators.contains_key("group-a"),
"typing indicator should be keyed by group ID");
assert!(!app.typing_indicators.contains_key("+1"),
"typing indicator must NOT be keyed by sender phone");
assert_eq!(app.typing_indicators["group-a"].0, "+1");
}
#[rstest]
fn group_typing_does_not_bleed_into_other_group(mut app: App) {
app.get_or_create_conversation("group-a", "Group A", true);
app.get_or_create_conversation("group-b", "Group B", true);
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
is_typing: true,
group_id: Some("group-a".to_string()),
});
assert!(!app.typing_indicators.contains_key("group-b"),
"group-a typing must not bleed into group-b");
}
#[rstest]
fn direct_typing_indicator_keyed_by_sender(mut app: App) {
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: None,
is_typing: true,
group_id: None,
});
assert!(app.typing_indicators.contains_key("+1"),
"1:1 typing indicator should be keyed by sender phone");
}
}