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::domain::{FilePickerState, SearchAction, SearchState, TypingState};
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};
pub const PASTE_CLEANUP_SENTINEL_SECS: u64 = 3600;
const PASTE_CLEANUP_DELAY_SECS: u64 = 10;
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 sidebar_filter_active: bool,
pub sidebar_filter: String,
pub sidebar_filtered: Vec<String>,
pub typing: TypingState,
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 jump_stack: Vec<(usize, Option<usize>)>,
pub show_reaction_picker: bool,
pub reaction_picker_index: usize,
pub emoji_to_text: bool,
pub show_reactions: bool,
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 file_picker: FilePickerState,
pub pending_attachment: Option<PathBuf>,
pub paste_temp_path: PathBuf,
pub pending_paste_cleanups: HashMap<String, (PathBuf, Instant)>,
pub reply_target: Option<(String, String, i64)>,
pub show_delete_confirm: bool,
pub editing_message: Option<(i64, String)>,
pub search: SearchState,
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)>,
}
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: "Emoji to text",
hint: "Convert emoji to text emoticons/shortcodes",
get: |a| a.emoji_to_text,
set: |a, v| a.emoji_to_text = v,
save: Some(|c, v| c.emoji_to_text = v),
on_toggle: None,
},
SettingDef {
label: "Show reactions",
hint: "Show emoji reactions on messages",
get: |a| a.show_reactions,
set: |a, v| a.show_reactions = v,
save: Some(|c, v| c.show_reactions = 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())
};
let is_remove = msg.reactions.iter().any(|r| r.sender == "you" && r.emoji == emoji);
if let Some(conv) = self.conversations.get_mut(&conv_id) {
if let Some(msg) = conv.messages.get_mut(index) {
if is_remove {
msg.reactions.retain(|r| !(r.sender == "you" && r.emoji == emoji));
} else {
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(),
});
}
}
}
}
if is_remove {
self.db_warn_visible(
self.db.remove_reaction(&conv_id, target_timestamp, &target_author, "you"),
"remove_reaction",
);
} else {
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: is_remove,
})
}
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) {
let active = self.active_conversation.as_deref().map(str::to_owned);
let action = self.search.handle_key(code, active.as_deref(), &self.db);
self.dispatch_search_action(action);
}
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_quote(&mut self) {
let msg = match self.selected_message() {
Some(m) => m,
None => return,
};
let quote_ts = match &msg.quote {
Some(q) => q.timestamp_ms,
None => {
self.status_message = "No quote on this message".to_string();
return;
}
};
self.jump_stack.push((self.scroll_offset, self.focused_msg_index));
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id.clone(),
None => return,
};
let found = self.conversations.get(&conv_id)
.and_then(|c| c.find_msg_idx(quote_ts))
.is_some();
if found {
self.jump_to_message_timestamp(quote_ts);
} else {
self.jump_stack.pop();
self.status_message = "Quoted message not in loaded history".to_string();
}
}
fn jump_back(&mut self) {
if let Some((offset, index)) = self.jump_stack.pop() {
self.scroll_offset = offset;
self.focused_msg_index = index;
}
}
fn jump_to_search_result(&mut self, forward: bool) {
let active = self.active_conversation.as_deref();
let action = self.search.jump_to_result(forward, active);
self.dispatch_search_action(action);
}
fn dispatch_search_action(&mut self, action: SearchAction) {
match action {
SearchAction::Select { conv_id, timestamp_ms, status } => {
self.join_conversation(&conv_id);
self.jump_to_message_timestamp(timestamp_ms);
if let Some(msg) = status {
self.status_message = msg;
}
}
SearchAction::Status(msg) => {
self.status_message = msg;
}
SearchAction::None => {}
}
}
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.file_picker.open();
}
pub fn handle_file_browser_key(&mut self, code: KeyCode) {
if let Some(path) = self.file_picker.handle_key(code) {
self.pending_attachment = Some(path);
}
}
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,
sidebar_filter_active: false,
sidebar_filter: String::new(),
sidebar_filtered: Vec::new(),
typing: TypingState::default(),
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,
jump_stack: Vec::new(),
show_reaction_picker: false,
reaction_picker_index: 0,
emoji_to_text: false,
show_reactions: true,
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,
file_picker: FilePickerState::default(),
pending_attachment: None,
pending_paste_cleanups: HashMap::new(),
paste_temp_path: {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("siggy-paste-{}-{}", std::process::id(), unique));
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,
search: SearchState::default(),
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(crate) fn refresh_sidebar_filter(&mut self) {
let query = self.sidebar_filter.to_lowercase();
self.sidebar_filtered = self
.conversation_order
.iter()
.filter(|id| {
self.conversations
.get(*id)
.is_some_and(|c| c.name.to_lowercase().contains(&query))
})
.cloned()
.collect();
}
fn clear_sidebar_filter(&mut self) {
self.sidebar_filter_active = false;
self.sidebar_filter.clear();
self.sidebar_filtered.clear();
}
fn handle_sidebar_filter_key(&mut self, code: KeyCode) {
match code {
KeyCode::Esc => {
self.clear_sidebar_filter();
}
KeyCode::Enter => {
let target = if self.sidebar_filtered.is_empty() {
None
} else {
Some(self.sidebar_filtered[0].clone())
};
self.clear_sidebar_filter();
if let Some(conv_id) = target {
self.join_conversation(&conv_id);
}
}
KeyCode::Char(c) => {
self.sidebar_filter.push(c);
self.refresh_sidebar_filter();
}
KeyCode::Backspace => {
self.sidebar_filter.pop();
if self.sidebar_filter.is_empty() {
self.clear_sidebar_filter();
} else {
self.refresh_sidebar_filter();
}
}
_ => {}
}
}
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]));
}
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.check_timeout() {
self.build_typing_request(true)
} else {
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.reset() {
self.pending_typing_stop = self.build_typing_request(true);
}
}
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
}
Some(KeyAction::SidebarSearch) => {
self.sidebar_visible = true;
self.sidebar_filter_active = true;
self.sidebar_filter.clear();
self.sidebar_filtered.clear();
true
}
_ => false,
}
}
pub fn handle_overlay_key(&mut self, code: KeyCode) -> (bool, Option<SendRequest>) {
if self.sidebar_filter_active {
self.handle_sidebar_filter_key(code);
return (true, None);
}
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.file_picker.visible {
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.search.visible {
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::SidebarSearch) => {
self.sidebar_visible = true;
self.sidebar_filter_active = true;
self.sidebar_filter.clear();
self.sidebar_filtered.clear();
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(),
Some(KeyAction::JumpToQuote) => { self.jump_to_quote(); None }
Some(KeyAction::JumpBack) => { self.jump_back(); None }
_ => 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.reset() {
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.reset();
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(),
Some(KeyAction::JumpToQuote) => { self.jump_to_quote(); None }
Some(KeyAction::JumpBack) => { self.jump_back(); None }
_ => {
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((path, _)) = self.pending_paste_cleanups.remove(rpc_id) {
self.pending_paste_cleanups.insert(
rpc_id.to_string(),
(path, Instant::now() + std::time::Duration::from_secs(PASTE_CLEANUP_DELAY_SECS)),
);
}
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((path, _)) = self.pending_paste_cleanups.remove(rpc_id) {
self.pending_paste_cleanups.insert(
rpc_id.to_string(),
(path, Instant::now() + std::time::Duration::from_secs(PASTE_CLEANUP_DELAY_SECS)),
);
}
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.open(query, self.active_conversation.as_deref(), &self.db);
}
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::Export(limit) => {
self.export_chat_history(limit);
}
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.clear_sidebar_filter();
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.clear_sidebar_filter();
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 cleanup_paste_files(&mut self) {
self.pending_paste_cleanups.retain(|_rpc_id, (path, delete_after)| {
if Instant::now() >= *delete_after {
let _ = std::fs::remove_file(path);
false
} else {
true
}
});
}
pub fn has_overlay(&self) -> bool {
self.show_settings
|| self.show_help
|| self.show_contacts
|| self.search.visible
|| self.file_picker.visible
|| 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;
let sidebar_list = if self.sidebar_filter_active && !self.sidebar_filtered.is_empty() {
&self.sidebar_filtered
} else {
&self.conversation_order
};
if index < sidebar_list.len() {
let conv_id = sidebar_list[index].clone();
self.clear_sidebar_filter();
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 export_chat_history(&mut self, limit: Option<usize>) {
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id.clone(),
None => {
self.status_message = "No active conversation to export".to_string();
return;
}
};
let conv = match self.conversations.get(&conv_id) {
Some(c) => c,
None => return,
};
let messages = &conv.messages;
let export_msgs: &[DisplayMessage] = match limit {
Some(n) => &messages[messages.len().saturating_sub(n)..],
None => messages,
};
if export_msgs.is_empty() {
self.status_message = "No messages to export".to_string();
return;
}
let mut output = String::new();
let safe_name: String = conv.name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let date = chrono::Local::now().format("%Y-%m-%d");
let filename = format!("siggy-export-{safe_name}-{date}.txt");
output.push_str(&format!("Chat export: {}\n", conv.name));
output.push_str(&format!("Exported: {}\n", chrono::Local::now().format("%Y-%m-%d %H:%M")));
output.push_str(&format!("Messages: {}\n", export_msgs.len()));
output.push_str(&"-".repeat(60));
output.push('\n');
for msg in export_msgs {
let time = msg.timestamp.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M");
if msg.is_system {
output.push_str(&format!("[{time}] * {}\n", msg.body));
} else {
let prefix = if msg.is_edited { "(edited) " } else { "" };
output.push_str(&format!("[{time}] <{}> {prefix}{}\n", msg.sender, msg.body));
if let Some(ref q) = msg.quote {
output.push_str(&format!(" > <{}> {}\n", q.author, q.body));
}
}
}
let dir = dirs::download_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = dir.join(&filename);
match std::fs::write(&path, &output) {
Ok(()) => {
self.status_message = format!("Exported {} messages to {}", export_msgs.len(), path.display());
}
Err(e) => {
self.status_message = format!("Export failed: {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());
if self.sidebar_filter_active {
self.refresh_sidebar_filter();
}
}
}
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()
}
}
impl App {
pub(crate) fn populate_demo_data(&mut self, base_date: chrono::NaiveDate) {
use chrono::{Local, TimeZone};
use crate::signal::types::{
Group, LinkPreview, MessageStatus, PollData, PollOption, PollVote, Reaction, StyleType,
};
let today = base_date;
let ts = |hour: u32, min: u32| -> chrono::DateTime<chrono::Utc> {
let naive = today
.and_hms_opt(hour, min, 0)
.unwrap_or_else(|| today.and_hms_opt(12, 0, 0).unwrap());
Local
.from_local_datetime(&naive)
.single()
.expect("ambiguous or invalid local time in demo data")
.with_timezone(&chrono::Utc)
};
let dm = |sender: &str, time: chrono::DateTime<Utc>, body: &str| -> DisplayMessage {
let is_outgoing = sender == "you";
DisplayMessage {
sender: sender.to_string(),
timestamp: time,
body: body.to_string(),
is_system: false,
image_lines: None,
image_path: None,
status: if is_outgoing { Some(MessageStatus::Sent) } else { None },
timestamp_ms: time.timestamp_millis(),
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,
}
};
let alice_id = "+15550001111".to_string();
let mut alice_msgs = vec![
dm("Alice", ts(8, 0), "Good morning! How's your day going?"),
dm("you", ts(8, 5), "Just getting started, coffee in hand"),
dm("Alice", ts(8, 10), "Nice! I've been up since 6, went for a run"),
dm("you", ts(8, 15), "Impressive. I can barely get out of bed before 7"),
dm("Alice", ts(8, 20), "Ha! It gets easier once you build the habit"),
dm("you", ts(8, 25), "That's what everyone says..."),
dm("Alice", ts(8, 30), "Trust me, after a week it becomes automatic"),
];
let mut alice_reply = dm("Alice", ts(8, 35), "Honestly same, I need my coffee first too");
alice_reply.quote = Some(Quote {
author: "you".to_string(),
body: "Just getting started, coffee in hand".to_string(),
timestamp_ms: ts(8, 5).timestamp_millis(),
author_id: String::new(),
});
alice_msgs.push(alice_reply);
alice_msgs.push(dm("you", ts(8, 40), "Are you free this weekend?"));
alice_msgs.push(dm("Alice", ts(8, 42), "Yeah! What did you have in mind?"));
let mut link_msg = dm("Alice", ts(8, 45), "There's this farmers market: https://localmarket.example.com");
link_msg.preview = Some(LinkPreview {
url: "https://localmarket.example.com".to_string(),
title: Some("Downtown Farmers Market".to_string()),
description: Some("Fresh produce, artisan goods, and live music every Saturday 8am-1pm".to_string()),
image_path: None,
});
alice_msgs.push(link_msg);
alice_msgs.push(dm("you", ts(8, 47), "Oh nice, what time should we go?"));
alice_msgs.push(dm("Alice", ts(8, 48), "Opens at 8, but 9 is fine. Less crowded."));
alice_msgs.push(dm("you", ts(8, 50), "Perfect, let's do 9"));
alice_msgs.push(dm("Alice", ts(8, 52), "I'll pick you up at 8:45"));
let mut edited_msg = dm("you", ts(8, 55), "Actually make it 8:30, I want to browse early");
edited_msg.is_edited = true;
alice_msgs.push(edited_msg);
alice_msgs.push(dm("Alice", ts(8, 57), "Even better! See you Saturday"));
alice_msgs[1].status = Some(MessageStatus::Read); alice_msgs[3].status = Some(MessageStatus::Read); alice_msgs[5].status = Some(MessageStatus::Read); alice_msgs[8].status = Some(MessageStatus::Delivered); alice_msgs[12].status = Some(MessageStatus::Delivered); alice_msgs[14].status = Some(MessageStatus::Sent);
let alice = Conversation {
name: "Alice".to_string(),
id: alice_id.clone(),
messages: alice_msgs,
unread: 0,
is_group: false,
expiration_timer: 0,
accepted: true,
};
let bob_id = "+15550002222".to_string();
let mut bob_styled = dm("Bob", ts(10, 5), "Can you review my PR? It's the auth refactor");
bob_styled.style_ranges = vec![(33, 47, StyleType::Bold)];
let mut bob_code = dm("Bob", ts(10, 8), "The key change is in verify_token() — switched from HMAC to Ed25519");
bob_code.style_ranges = vec![(22, 36, StyleType::Monospace)];
let mut bob_reply = dm("you", ts(10, 12), "Looks good! Left a few comments on the error handling");
bob_reply.status = Some(MessageStatus::Read);
let bob_thanks = dm("Bob", ts(10, 15), "Thanks! I'll address those. Also the migration is backwards-compatible so no rush on deploy");
let mut bob_followup = dm("Bob", ts(10, 20), "Fixed those error handling bits, PTAL");
bob_followup.quote = Some(Quote {
author: "you".to_string(),
body: "Looks good! Left a few comments on the error handling".to_string(),
timestamp_ms: ts(10, 12).timestamp_millis(),
author_id: String::new(),
});
let mut bob_lgtm = dm("you", ts(10, 25), "LGTM, approved!");
bob_lgtm.status = Some(MessageStatus::Delivered);
bob_lgtm.style_ranges = vec![(0, 4, StyleType::Bold)];
let bob = Conversation {
name: "Bob".to_string(),
id: bob_id.clone(),
messages: vec![bob_styled, bob_code, bob_reply, bob_thanks, bob_followup, bob_lgtm],
unread: 0,
is_group: false,
expiration_timer: 0,
accepted: true,
};
let carol_id = "+15550003333".to_string();
let carol = Conversation {
name: "Carol".to_string(),
id: carol_id.clone(),
messages: vec![
dm("Carol", ts(11, 45), "Did you see the announcement about the office move?"),
],
unread: 1,
is_group: false,
expiration_timer: 0,
accepted: true,
};
let dave_id = "+15550004444".to_string();
let mut dave_sys = dm("system", ts(7, 55), "Disappearing messages set to 1 day");
dave_sys.is_system = true;
let mut dave_msg1 = dm("Dave", ts(8, 0), "Meetup is at the usual place, 7pm");
dave_msg1.expires_in_seconds = 86400;
dave_msg1.expiration_start_ms = ts(8, 0).timestamp_millis();
let mut dave_msg2 = dm("you", ts(8, 5), "Got it, I'll be there");
dave_msg2.status = Some(MessageStatus::Read);
dave_msg2.expires_in_seconds = 86400;
dave_msg2.expiration_start_ms = ts(8, 5).timestamp_millis();
let mut dave_msg3 = dm("Dave", ts(8, 6), "Bring your laptop if you want to hack on stuff");
dave_msg3.expires_in_seconds = 86400;
dave_msg3.expiration_start_ms = ts(8, 6).timestamp_millis();
let dave = Conversation {
name: "Dave".to_string(),
id: dave_id.clone(),
messages: vec![dave_sys, dave_msg1, dave_msg2, dave_msg3],
unread: 0,
is_group: false,
expiration_timer: 86400,
accepted: true,
};
let rust_id = "group_rustdevs".to_string();
let mut pinned_msg = dm("Alice", ts(10, 30), "Has anyone tried the new async trait syntax?");
pinned_msg.is_pinned = true;
let mut bob_group = dm("Bob", ts(10, 32), "Yeah, it's so much cleaner than the pin-based approach");
bob_group.style_ranges = vec![(9, 24, StyleType::Italic)];
let dave_group = dm("Dave", ts(10, 35), "I'm still wrapping my head around it");
let mut you_group = dm("you", ts(10, 40), "The desugaring docs helped me a lot");
you_group.status = Some(MessageStatus::Read);
let mut alice_mention = dm("Alice", ts(10, 42), "Can you share the link? @Bob might want it too");
alice_mention.mention_ranges = vec![(24, 28)];
let mut you_link = dm("you", ts(10, 43), "Here you go: https://blog.rust-lang.org/async-traits");
you_link.status = Some(MessageStatus::Delivered);
you_link.preview = Some(LinkPreview {
url: "https://blog.rust-lang.org/async-traits".to_string(),
title: Some("Async Trait Methods in Stable Rust".to_string()),
description: Some("A deep dive into the stabilization of async fn in traits".to_string()),
image_path: None,
});
let mut poll_msg = dm("Dave", ts(10, 50), "");
poll_msg.poll_data = Some(PollData {
question: "Which async runtime do you prefer?".to_string(),
options: vec![
PollOption { id: 0, text: "Tokio".to_string() },
PollOption { id: 1, text: "async-std".to_string() },
PollOption { id: 2, text: "smol".to_string() },
],
allow_multiple: false,
closed: false,
});
poll_msg.poll_votes = vec![
PollVote { voter: "+15550001111".to_string(), voter_name: Some("Alice".to_string()), option_indexes: vec![0], vote_count: 1 },
PollVote { voter: "+15550002222".to_string(), voter_name: Some("Bob".to_string()), option_indexes: vec![0], vote_count: 1 },
PollVote { voter: "+15550004444".to_string(), voter_name: Some("Dave".to_string()), option_indexes: vec![2], vote_count: 1 },
PollVote { voter: "you".to_string(), voter_name: Some("you".to_string()), option_indexes: vec![0], vote_count: 1 },
];
let rust_group = Conversation {
name: "#Rust Devs".to_string(),
id: rust_id.clone(),
messages: vec![pinned_msg, bob_group, dave_group, you_group, alice_mention, you_link, poll_msg],
unread: 0,
is_group: true,
expiration_timer: 0,
accepted: true,
};
let family_id = "group_family".to_string();
let mom_id = "+15550005555".to_string();
let dad_id = "+15550006666".to_string();
let mom_dinner = dm("Mom", ts(12, 0), "Dinner at our place Sunday?");
let dad_grill = dm("Dad", ts(12, 5), "I'll fire up the grill");
let mut you_family = dm("you", ts(12, 10), "Count me in!");
you_family.status = Some(MessageStatus::Read);
let mom_dessert = dm("Mom", ts(13, 30), "Great! Bring dessert if you can");
let mut dad_reply = dm("Dad", ts(13, 35), "Got the burgers and corn ready");
dad_reply.quote = Some(Quote {
author: "Dad".to_string(),
body: "I'll fire up the grill".to_string(),
timestamp_ms: ts(12, 5).timestamp_millis(),
author_id: dad_id.clone(),
});
let family_group = Conversation {
name: "#Family".to_string(),
id: family_id.clone(),
messages: vec![mom_dinner, dad_grill, you_family, mom_dessert, dad_reply],
unread: 2,
is_group: true,
expiration_timer: 0,
accepted: true,
};
let eve_id = "+15550007777".to_string();
let eve = Conversation {
name: "+15550007777".to_string(),
id: eve_id.clone(),
messages: vec![
dm("+15550007777", ts(14, 0), "Hey, I got your number from the meetup. Is this the right person?"),
],
unread: 1,
is_group: false,
expiration_timer: 0,
accepted: false,
};
let order = vec![
eve_id.clone(),
family_id.clone(),
carol_id.clone(),
rust_id.clone(),
bob_id.clone(),
alice_id.clone(),
dave_id.clone(),
];
for conv in [alice, bob, carol, dave, rust_group, family_group, eve] {
let id = conv.id.clone();
let msg_count = conv.messages.len();
let unread = conv.unread;
self.conversations.insert(id.clone(), conv);
if msg_count > 0 {
self.last_read_index
.insert(id, msg_count.saturating_sub(unread));
}
}
self.conversation_order = order;
self.active_conversation = Some(alice_id.clone());
self.status_message = "connected | demo mode".to_string();
let demo_contacts: Vec<(&str, &str, &str)> = vec![
(&alice_id, "Alice", "aaaa-alice-uuid"),
(&bob_id, "Bob", "bbbb-bob-uuid"),
(&carol_id, "Carol", "cccc-carol-uuid"),
(&dave_id, "Dave", "dddd-dave-uuid"),
(&mom_id, "Mom", "eeee-mom-uuid"),
(&dad_id, "Dad", "ffff-dad-uuid"),
];
for (phone, name, uuid) in &demo_contacts {
self.contact_names.insert(phone.to_string(), name.to_string());
self.uuid_to_name.insert(uuid.to_string(), name.to_string());
self.number_to_uuid.insert(phone.to_string(), uuid.to_string());
}
self.groups.insert(
rust_id.clone(),
Group {
id: rust_id,
name: "#Rust Devs".to_string(),
members: vec![alice_id.clone(), bob_id.clone(), dave_id.clone()],
member_uuids: vec![],
},
);
self.groups.insert(
family_id.clone(),
Group {
id: family_id,
name: "#Family".to_string(),
members: vec![mom_id, dad_id],
member_uuids: vec![],
},
);
if let Some(conv) = self.conversations.get_mut(&alice_id) {
if let Some(msg) = conv.messages.get_mut(0) {
msg.reactions.push(Reaction { emoji: "\u{1f44d}".to_string(), sender: "you".to_string() });
}
if let Some(msg) = conv.messages.get_mut(1) {
msg.reactions.push(Reaction { emoji: "\u{2764}\u{fe0f}".to_string(), sender: "Alice".to_string() });
}
if let Some(msg) = conv.messages.last_mut() {
msg.reactions.push(Reaction { emoji: "\u{1f389}".to_string(), sender: "you".to_string() });
}
}
if let Some(conv) = self.conversations.get_mut("group_rustdevs") {
if let Some(msg) = conv.messages.get_mut(3) {
msg.reactions.push(Reaction { emoji: "\u{1f44d}".to_string(), sender: "Alice".to_string() });
msg.reactions.push(Reaction { emoji: "\u{1f44d}".to_string(), sender: "Bob".to_string() });
msg.reactions.push(Reaction { emoji: "\u{2764}\u{fe0f}".to_string(), sender: "Dave".to_string() });
}
if let Some(msg) = conv.messages.get_mut(0) {
msg.reactions.push(Reaction { emoji: "\u{1f4cc}".to_string(), sender: "Dave".to_string() });
}
}
if let Some(conv) = self.conversations.get_mut("group_family") {
if let Some(msg) = conv.messages.get_mut(2) {
msg.reactions.push(Reaction { emoji: "\u{2764}\u{fe0f}".to_string(), sender: "Mom".to_string() });
msg.reactions.push(Reaction { emoji: "\u{2764}\u{fe0f}".to_string(), sender: "Dad".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 send_timestamp_resets_paste_cleanup_deadline(mut app: App) {
let tmp = std::env::temp_dir().join("test-paste-dummy.png");
let sentinel = Instant::now() + std::time::Duration::from_secs(PASTE_CLEANUP_SENTINEL_SECS);
app.pending_paste_cleanups.insert("rpc-1".to_string(), (tmp.clone(), sentinel));
app.handle_signal_event(SignalEvent::SendTimestamp {
rpc_id: "rpc-1".to_string(),
server_ts: 0,
});
let (_, deadline) = app.pending_paste_cleanups.get("rpc-1").expect("entry should still exist");
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
remaining <= std::time::Duration::from_secs(PASTE_CLEANUP_DELAY_SECS),
"deadline should be reset to ~{PASTE_CLEANUP_DELAY_SECS}s, got {remaining:?}"
);
}
#[rstest]
fn send_failed_resets_paste_cleanup_deadline(mut app: App) {
let tmp = std::env::temp_dir().join("test-paste-dummy-fail.png");
let sentinel = Instant::now() + std::time::Duration::from_secs(PASTE_CLEANUP_SENTINEL_SECS);
app.pending_paste_cleanups.insert("rpc-2".to_string(), (tmp.clone(), sentinel));
app.handle_signal_event(SignalEvent::SendFailed {
rpc_id: "rpc-2".to_string(),
});
let (_, deadline) = app.pending_paste_cleanups.get("rpc-2").expect("entry should still exist");
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
remaining <= std::time::Duration::from_secs(PASTE_CLEANUP_DELAY_SECS),
"deadline should be reset to ~{PASTE_CLEANUP_DELAY_SECS}s, got {remaining:?}"
);
}
#[rstest]
fn cleanup_paste_files_removes_file_after_deadline(mut app: App) {
let tmp = std::env::temp_dir().join(format!("test-paste-cleanup-{}.png", std::process::id()));
std::fs::write(&tmp, b"fake image data").expect("write temp file");
assert!(tmp.exists());
let past = Instant::now() - std::time::Duration::from_secs(1);
app.pending_paste_cleanups.insert("rpc-3".to_string(), (tmp.clone(), past));
app.cleanup_paste_files();
assert!(!tmp.exists(), "temp file should have been deleted");
assert!(app.pending_paste_cleanups.is_empty(), "entry should be removed");
}
#[rstest]
fn cleanup_paste_files_keeps_file_before_deadline(mut app: App) {
let tmp = std::env::temp_dir().join(format!("test-paste-keep-{}.png", std::process::id()));
std::fs::write(&tmp, b"fake image data").expect("write temp file");
let future = Instant::now() + std::time::Duration::from_secs(60);
app.pending_paste_cleanups.insert("rpc-4".to_string(), (tmp.clone(), future));
app.cleanup_paste_files();
assert!(tmp.exists(), "temp file should not have been deleted yet");
let _ = std::fs::remove_file(&tmp);
assert!(!app.pending_paste_cleanups.is_empty(), "entry should still be present");
}
#[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.file_picker.visible);
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.search.visible);
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.search.visible);
assert!(app.status_message.contains("requires"));
}
#[rstest]
fn search_overlay_esc_closes(mut app: App) {
app.search.visible = true;
app.search.query = "test".to_string();
app.handle_search_key(KeyCode::Esc);
assert!(!app.search.visible);
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.search.visible = true;
app.search.query = "hello".to_string();
app.search.run(app.active_conversation.as_deref(), &app.db);
assert_eq!(app.search.results.len(), 1);
app.search.query = "world".to_string();
app.search.run(app.active_conversation.as_deref(), &app.db);
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.search.visible = true;
assert!(app.has_overlay());
app.search.visible = false;
app.file_picker.visible = true;
assert!(app.has_overlay());
app.file_picker.visible = 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");
}
}