use chrono::{DateTime, 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;
pub use crate::autocomplete::AutocompleteMode;
use crate::autocomplete::AutocompleteState;
pub use crate::conversation_store::{Conversation, DisplayMessage, Quote};
use crate::conversation_store::{ConversationStore, db_warn};
use crate::db::Database;
use crate::domain::{
ActionMenuState, ContactsOverlayState, EmojiPickerAction, EmojiPickerSource, EmojiPickerState,
FilePickerState, ForwardOverlayState, GroupMenuOverlayState, ImageState, InputState,
KeybindingsOverlayState, LockState, MouseState, NotificationState, PendingState,
PinDurationOverlayState, PollVoteOverlayState, ProfileOverlayState, ReactionState, ScrollState,
SearchAction, SearchState, SettingsOverlayState, SettingsProfileOverlayState, ThemePickerState,
TypingState, VerifyOverlayState,
};
use crate::image_render;
use crate::image_render::ImageProtocol;
use crate::input::COMMANDS;
use crate::keybindings::{self, BindingMode, KeyAction, KeyBindings};
use crate::list_overlay::{self, ListKeyAction, classify_list_key};
use crate::mute::MuteState;
use crate::signal::types::{MessageStatus, PollOption, Reaction, SignalEvent, TrustLevel};
use crate::theme::{self, Theme};
pub const PASTE_CLEANUP_SENTINEL_SECS: u64 = 3600;
pub(crate) 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
}
#[derive(Default, Clone)]
pub struct WireQuote {
pub author: Option<String>,
pub body: Option<String>,
pub timestamp: Option<i64>,
}
impl App {
pub(crate) 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}");
}
}
pub(crate) fn on_message_added(
&mut self,
conv_id: &str,
msg: DisplayMessage,
wire_quote: WireQuote,
ordered_insert: bool,
) -> Option<usize> {
let ts_rfc3339 = msg.timestamp.to_rfc3339();
let sender = msg.sender.clone();
let sender_id = msg.sender_id.clone();
let body = msg.body.clone();
let is_system = msg.is_system;
let status = msg.status;
let timestamp_ms = msg.timestamp_ms;
let expires_in_seconds = msg.expires_in_seconds;
let expiration_start_ms = msg.expiration_start_ms;
let insert_idx = {
let conv = self.store.conversations.get_mut(conv_id)?;
let pos = if ordered_insert {
conv.messages
.partition_point(|m| m.timestamp_ms <= timestamp_ms)
} else {
conv.messages.len()
};
conv.messages.insert(pos, msg);
pos
};
if ordered_insert
&& let Some(read_idx) = self.store.last_read_index.get_mut(conv_id)
&& insert_idx <= *read_idx
{
*read_idx += 1;
}
if expires_in_seconds > 0 {
self.expiring_msg_count += 1;
}
let db_result = if is_system {
self.db.insert_message(
conv_id,
&sender,
&ts_rfc3339,
&body,
true,
status,
timestamp_ms,
)
} else {
self.db.insert_message_full(
conv_id,
&sender,
&ts_rfc3339,
&body,
false,
status,
timestamp_ms,
&sender_id,
wire_quote.author.as_deref(),
wire_quote.body.as_deref(),
wire_quote.timestamp,
expires_in_seconds,
expiration_start_ms,
)
};
self.db_warn_visible(db_result, "on_message_added");
if !is_system
&& self.store.move_conversation_to_top(conv_id)
&& self.is_overlay(OverlayKind::SidebarFilter)
{
self.refresh_sidebar_filter();
}
Some(insert_idx)
}
}
pub(crate) fn show_desktop_notification(
sender: &str,
body: &str,
is_group: bool,
group_name: Option<&str>,
preview_level: crate::domain::NotificationPreview,
) {
use crate::domain::NotificationPreview;
let sender_title = || -> String {
if is_group {
match group_name {
Some(gn) => format!("{} - {}", gn, sender),
None => sender.to_string(),
}
} else {
sender.to_string()
}
};
let (title, preview) = match preview_level {
NotificationPreview::Minimal => ("New message".to_string(), String::new()),
NotificationPreview::Sender => (sender_title(), "New message".to_string()),
NotificationPreview::Full => (sender_title(), 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(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverlayKind {
SidebarFilter,
PollVote,
PinDuration,
ActionMenu,
DeleteConfirm,
DeleteConversationConfirm,
FilePicker,
EmojiPicker,
ReactionPicker,
MessageRequest,
GroupMenu,
About,
Profile,
Help,
Verify,
Forward,
Contacts,
Search,
SettingsProfiles,
ThemePicker,
Keybindings,
Customize,
Settings,
Autocomplete,
}
#[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>,
pub pre_native_png: Option<(String, String, u32, u32)>,
pub pre_sixel: Option<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Insert,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupMenuState {
Menu, Members, AddMember, RemoveMember, Rename, Create, LeaveConfirm, }
pub struct GroupMenuItem {
pub label: &'static str,
pub key_hint: &'static str,
pub nerd_icon: &'static str,
}
pub struct ActionMenuItem {
pub label: &'static str,
pub key_hint: ActionMenuHint,
pub nerd_icon: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionMenuHint {
Reply,
Edit,
React,
Forward,
Copy,
Delete,
PinToggle,
Vote,
EndPoll,
OpenAttachment,
OpenLink,
}
impl ActionMenuHint {
pub fn key_char(self) -> char {
match self {
Self::Reply => 'q',
Self::Edit => 'e',
Self::React => 'r',
Self::Forward => 'f',
Self::Copy => 'y',
Self::Delete => 'd',
Self::PinToggle => 'p',
Self::Vote => 'v',
Self::EndPoll => 'x',
Self::OpenAttachment => 'o',
Self::OpenLink => 'l',
}
}
pub fn from_char(c: char) -> Option<Self> {
Some(match c {
'q' => Self::Reply,
'e' => Self::Edit,
'r' => Self::React,
'f' => Self::Forward,
'y' => Self::Copy,
'd' => Self::Delete,
'p' => Self::PinToggle,
'v' => Self::Vote,
'x' => Self::EndPoll,
'o' => Self::OpenAttachment,
'l' => Self::OpenLink,
_ => return None,
})
}
}
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>,
}
pub struct SyncState {
pub active: bool,
pub message_count: usize,
pub last_message_time: Option<Instant>,
pub started_at: Instant,
pub suppressed_notifications: HashMap<String, usize>,
pub user_scrolled: bool,
pub pin: Option<(DateTime<Utc>, usize)>,
}
impl SyncState {
pub fn new() -> Self {
Self {
active: true,
message_count: 0,
last_message_time: None,
started_at: Instant::now(),
suppressed_notifications: HashMap::new(),
user_scrolled: false,
pin: None,
}
}
pub fn should_end(&self) -> bool {
let elapsed = self.started_at.elapsed();
if elapsed.as_secs() < 10 {
return false;
}
match self.last_message_time {
None => true,
Some(last) => last.elapsed().as_secs() >= 3,
}
}
}
pub struct App {
pub store: ConversationStore,
pub active_conversation: Option<String>,
pub input: InputState,
pub sidebar_visible: bool,
pub scroll: ScrollState,
pub status_message: String,
pub should_quit: bool,
pub quit_confirm: bool,
pub account: String,
pub sidebar_width: u16,
pub sidebar_on_right: bool,
pub sidebar_filter: String,
pub sidebar_filtered: Vec<String>,
pub typing: TypingState,
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 notifications: NotificationState,
pub muted_conversations: HashMap<String, MuteState>,
pub blocked_conversations: HashSet<String>,
pub autocomplete: AutocompleteState,
pub settings_overlay: SettingsOverlayState,
pub contacts_overlay: ContactsOverlayState,
pub verify: VerifyOverlayState,
pub identity_trust: HashMap<String, TrustLevel>,
pub image: ImageState,
pub prev_active_conversation: Option<String>,
pub incognito: bool,
pub date_separators: bool,
pub show_receipts: bool,
pub color_receipts: bool,
pub nerd_fonts: bool,
pub pending: PendingState,
pub pending_normal_key: Option<char>,
pub reactions: ReactionState,
pub emoji_picker: EmojiPickerState,
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 editing_message: Option<(i64, String)>,
pub search: SearchState,
pub send_read_receipts: bool,
pub action_menu: ActionMenuState,
pub forward: ForwardOverlayState,
pub group_menu: GroupMenuOverlayState,
pub mouse: MouseState,
pub theme: Theme,
pub theme_picker: ThemePickerState,
pub keybindings: KeyBindings,
pub keybindings_overlay: KeybindingsOverlayState,
pub pin_duration: PinDurationOverlayState,
pub poll_vote: PollVoteOverlayState,
pub expiring_msg_count: usize,
pub profile: ProfileOverlayState,
pub settings_profiles: SettingsProfileOverlayState,
pub sync: SyncState,
pub current_overlay: Option<OverlayKind>,
pub lock: LockState,
}
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)>,
}
pub const SETTINGS_SECTION_DISPLAY: usize = 3;
pub const SETTINGS_SECTION_MESSAGES: usize = 9;
pub const SETTINGS_SECTION_INTERFACE: usize = 12;
pub const SETTINGS_VISUAL_ORDER: &[usize] = &[
0, 1, 2, 15, 3, 4, 5, 6, 7, 8, 16, 9, 10, 11, 12, 13, 14, 17, ];
pub const SETTINGS: &[SettingDef] = &[
SettingDef {
label: "Direct message notifications",
hint: "Play a sound for incoming direct messages",
get: |a| a.notifications.notify_direct,
set: |a, v| a.notifications.notify_direct = v,
save: Some(|c, v| c.notify_direct = v),
},
SettingDef {
label: "Group message notifications",
hint: "Play a sound for incoming group messages",
get: |a| a.notifications.notify_group,
set: |a, v| a.notifications.notify_group = v,
save: Some(|c, v| c.notify_group = v),
},
SettingDef {
label: "Desktop notifications",
hint: "Show system notifications for new messages",
get: |a| a.notifications.desktop_notifications,
set: |a, v| a.notifications.desktop_notifications = v,
save: Some(|c, v| c.desktop_notifications = v),
},
SettingDef {
label: "Link previews",
hint: "Show title and thumbnail for URLs",
get: |a| a.image.show_link_previews,
set: |a, v| a.image.show_link_previews = v,
save: Some(|c, v| c.show_link_previews = v),
},
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),
},
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),
},
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),
},
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),
},
SettingDef {
label: "Emoji to text",
hint: "Convert emoji to text emoticons/shortcodes",
get: |a| a.reactions.emoji_to_text,
set: |a, v| a.reactions.emoji_to_text = v,
save: Some(|c, v| c.emoji_to_text = v),
},
SettingDef {
label: "Show reactions",
hint: "Show emoji reactions on messages",
get: |a| a.reactions.show_reactions,
set: |a, v| a.reactions.show_reactions = v,
save: Some(|c, v| c.show_reactions = v),
},
SettingDef {
label: "Verbose reactions",
hint: "Show names instead of just emoji counts",
get: |a| a.reactions.verbose,
set: |a, v| a.reactions.verbose = v,
save: Some(|c, v| c.reaction_verbose = v),
},
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),
},
SettingDef {
label: "Sidebar visible",
hint: "Show the conversation list sidebar",
get: |a| a.sidebar_visible,
set: |a, v| a.sidebar_visible = v,
save: 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),
},
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),
},
];
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);
}
}
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_profiles.name.clone();
config.notification_preview = self.notifications.notification_preview;
config.image_mode = Some(self.image.image_mode);
config.sidebar_width = self.sidebar_width;
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.image_render_rx.try_recv() {
self.image.image_render_in_flight.remove(&(
result.conv_id.clone(),
result.timestamp_ms,
result.is_preview,
));
if let Some(conv) = self.store.conversations.get_mut(&result.conv_id)
&& 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());
}
if let Some((path, b64, pw, ph)) = result.pre_native_png {
self.image
.native_image_cache
.entry(path)
.or_insert((b64, pw, ph));
}
if let Some((path, sixel)) = result.pre_sixel {
self.image.sixel_cache.entry(path).or_insert(sixel);
}
drained = true;
}
}
if self.image.image_mode == crate::domain::ImageMode::None {
return drained;
}
let Some(ref id) = self.active_conversation else {
return drained;
};
let id = id.clone();
let Some(conv) = self.store.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.image_render_in_flight.len() + work.len() >= 4 {
break;
}
if msg.body.starts_with("[image:")
&& msg.image_lines.is_none()
&& let Some(ref p) = msg.image_path
{
let key = (id.clone(), msg.timestamp_ms, false);
if !self.image.image_render_in_flight.contains(&key) {
work.push((msg.timestamp_ms, p.clone(), 40, false));
}
}
if self.image.show_link_previews
&& msg.preview_image_lines.is_none()
&& let Some(ref preview) = msg.preview
&& let Some(ref p) = preview.image_path
{
let key = (id.clone(), msg.timestamp_ms, true);
if !self.image.image_render_in_flight.contains(&key) {
work.push((msg.timestamp_ms, p.clone(), 30, true));
}
}
}
let is_native = self.image.image_mode == crate::domain::ImageMode::Native;
let is_sixel = self.image.image_protocol == image_render::ImageProtocol::Sixel;
let cell_px = self.image.cell_px;
for (ts, path, max_width, is_preview) in work {
self.image
.image_render_in_flight
.insert((id.clone(), ts, is_preview));
let tx = self.image.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 (pre_native_png, pre_sixel) = if is_native {
let cell_w = lines
.as_ref()
.and_then(|l| l.first())
.map(|l| l.width().saturating_sub(2) as u32)
.unwrap_or(0);
let cell_h = lines.as_ref().map(|l| l.len() as u32).unwrap_or(0);
if cell_w > 0 && cell_h > 0 {
let png = image_render::encode_native_png(Path::new(&path), cell_w, cell_h);
let sixel = if is_sixel {
png.as_ref().and_then(|p| {
image_render::encode_sixel(
&p.0,
cell_w as u16,
cell_h as u16,
cell_px,
)
})
} else {
None
};
let pre_png = png.map(|(b64, pw, ph)| (path.clone(), b64, pw, ph));
let pre_six = sixel.map(|s| (path.clone(), s));
(pre_png, pre_six)
} else {
(None, None)
}
} else {
(None, None)
};
let _ = tx.send(ImageRenderResult {
conv_id: cid,
timestamp_ms: ts,
is_preview,
lines,
image_path: if is_preview { Some(path) } else { None },
pre_native_png,
pre_sixel,
});
});
}
drained
}
pub fn handle_settings_key(&mut self, code: KeyCode) {
let preview_index = SETTINGS.len();
let image_mode_index = SETTINGS.len() + 1;
let customize_index = SETTINGS.len() + 2;
let visual_pos = SETTINGS_VISUAL_ORDER
.iter()
.position(|&i| i == self.settings_overlay.index)
.unwrap_or(0);
match code {
KeyCode::Char('j') | KeyCode::Down if visual_pos + 1 < SETTINGS_VISUAL_ORDER.len() => {
self.settings_overlay.index = SETTINGS_VISUAL_ORDER[visual_pos + 1];
}
KeyCode::Char('k') | KeyCode::Up if visual_pos > 0 => {
self.settings_overlay.index = SETTINGS_VISUAL_ORDER[visual_pos - 1];
}
KeyCode::Char(' ') | KeyCode::Enter | KeyCode::Tab => {
if self.settings_overlay.index == preview_index {
self.notifications.notification_preview =
self.notifications.notification_preview.cycle();
} else if self.settings_overlay.index == image_mode_index {
self.image.image_mode = self.image.image_mode.cycle();
} else if self.settings_overlay.index == customize_index {
self.open_overlay(OverlayKind::Customize);
self.settings_overlay.customize_index = 0;
} else {
self.toggle_setting(self.settings_overlay.index);
}
}
KeyCode::Esc | KeyCode::Char('q') => {
self.close_overlay();
self.save_settings();
self.fire_deferred_settings_hooks();
}
_ => {}
}
}
pub fn handle_customize_key(&mut self, code: KeyCode) {
const ITEMS: usize = 3; match code {
KeyCode::Char('j') | KeyCode::Down
if self.settings_overlay.customize_index + 1 < ITEMS =>
{
self.settings_overlay.customize_index += 1;
}
KeyCode::Char('k') | KeyCode::Up => {
self.settings_overlay.customize_index =
self.settings_overlay.customize_index.saturating_sub(1);
}
KeyCode::Char(' ') | KeyCode::Enter | KeyCode::Tab => {
self.close_overlay();
self.save_settings();
match self.settings_overlay.customize_index {
0 => {
self.open_overlay(OverlayKind::ThemePicker);
self.theme_picker.index = self
.theme_picker
.available_themes
.iter()
.position(|t| t.name == self.theme.name)
.unwrap_or(0);
}
1 => {
self.open_overlay(OverlayKind::Keybindings);
self.keybindings_overlay.index = 0;
}
2 => {
self.open_settings_profile_manager();
}
_ => {}
}
}
KeyCode::Esc | KeyCode::Char('q') => {
self.close_overlay();
}
_ => {}
}
}
fn apply_settings_profile_deferred(
&mut self,
profile: &crate::settings_profile::SettingsProfile,
) {
profile.apply_to(self);
self.settings_profiles.name = profile.name.clone();
}
fn fire_deferred_settings_hooks(&mut self) {
if self.mouse.enabled != self.settings_overlay.mouse_snapshot {
self.mouse.pending_toggle = Some(self.mouse.enabled);
}
}
fn open_settings_profile_manager(&mut self) {
self.settings_profiles.available = crate::settings_profile::all_settings_profiles();
self.settings_profiles.index = self
.settings_profiles
.available
.iter()
.position(|p| p.name == self.settings_profiles.name)
.unwrap_or(0);
self.open_overlay(OverlayKind::SettingsProfiles);
self.settings_profiles.save_as = false;
self.settings_profiles.save_as_input.clear();
}
pub fn handle_settings_profile_manager_key(&mut self, code: KeyCode) {
if self.settings_profiles.save_as {
match code {
KeyCode::Enter => {
let name = self.settings_profiles.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_profiles.name = name;
self.settings_profiles.available =
crate::settings_profile::all_settings_profiles();
self.settings_profiles.index = self
.settings_profiles
.available
.iter()
.position(|p| p.name == self.settings_profiles.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_profiles.save_as = false;
}
}
KeyCode::Esc => {
self.settings_profiles.save_as = false;
}
KeyCode::Backspace => {
self.settings_profiles.save_as_input.pop();
}
KeyCode::Char(c) if self.settings_profiles.save_as_input.len() < 30 => {
self.settings_profiles.save_as_input.push(c);
}
_ => {}
}
return;
}
match code {
KeyCode::Char('j') | KeyCode::Down
if self.settings_profiles.index
< self.settings_profiles.available.len().saturating_sub(1) =>
{
self.settings_profiles.index += 1;
}
KeyCode::Char('k') | KeyCode::Up => {
self.settings_profiles.index = self.settings_profiles.index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(profile) = self
.settings_profiles
.available
.get(self.settings_profiles.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
.settings_profiles
.available
.get(self.settings_profiles.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_profiles.name = updated.name.clone();
self.settings_profiles.available =
crate::settings_profile::all_settings_profiles();
self.settings_profiles.index = self
.settings_profiles
.available
.iter()
.position(|p| p.name == self.settings_profiles.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
.settings_profiles
.available
.iter()
.any(|p| p.name == self.settings_profiles.name && p.matches_app(self));
if has_changes {
self.settings_profiles.save_as = true;
self.settings_profiles.save_as_input.clear();
}
}
KeyCode::Char('d') => {
if let Some(profile) = self
.settings_profiles
.available
.get(self.settings_profiles.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_profiles.name == name {
self.settings_profiles.name = "Default".to_string();
}
self.settings_profiles.available =
crate::settings_profile::all_settings_profiles();
if self.settings_profiles.index
>= self.settings_profiles.available.len()
{
self.settings_profiles.index =
self.settings_profiles.available.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.close_overlay();
self.fire_deferred_settings_hooks();
}
_ => {}
}
}
pub fn handle_theme_key(&mut self, code: KeyCode) {
let code = match code {
KeyCode::Char(' ') => KeyCode::Enter,
KeyCode::Char('q') => KeyCode::Esc,
other => other,
};
let action = classify_list_key(code, false);
if list_overlay::apply_nav(
&action,
&mut self.theme_picker.index,
self.theme_picker.available_themes.len(),
) {
return;
}
match action {
ListKeyAction::Select => {
if let Some(selected) = self
.theme_picker
.available_themes
.get(self.theme_picker.index)
{
self.theme = selected.clone();
self.save_settings();
}
self.close_overlay();
}
ListKeyAction::Close => {
self.close_overlay();
}
_ => {}
}
}
pub fn handle_keybindings_key(&mut self, code: KeyCode) {
if self.keybindings_overlay.profile_picker {
match code {
KeyCode::Char('j') | KeyCode::Down
if self.keybindings_overlay.profile_index
< self
.keybindings_overlay
.available_profiles
.len()
.saturating_sub(1) =>
{
self.keybindings_overlay.profile_index += 1;
}
KeyCode::Char('k') | KeyCode::Up => {
self.keybindings_overlay.profile_index =
self.keybindings_overlay.profile_index.saturating_sub(1);
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some(name) = self
.keybindings_overlay
.available_profiles
.get(self.keybindings_overlay.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_overlay.profile_picker = false;
}
KeyCode::Esc => {
self.keybindings_overlay.profile_picker = false;
}
_ => {}
}
return;
}
if let Some((displaced_action, _combo)) = self.keybindings_overlay.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_overlay.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_overlay.index < total.saturating_sub(1) {
self.keybindings_overlay.index += 1;
}
while self.keybindings_overlay.index < total
&& self
.keybindings_overlay_item(self.keybindings_overlay.index)
.1
.is_none()
{
self.keybindings_overlay.index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.keybindings_overlay.index = self.keybindings_overlay.index.saturating_sub(1);
while self.keybindings_overlay.index > 0
&& self
.keybindings_overlay_item(self.keybindings_overlay.index)
.1
.is_none()
{
self.keybindings_overlay.index =
self.keybindings_overlay.index.saturating_sub(1);
}
}
KeyCode::Enter => {
if self.keybindings_overlay.index == 0 {
self.keybindings_overlay.profile_picker = true;
self.keybindings_overlay.profile_index = self
.keybindings_overlay
.available_profiles
.iter()
.position(|n| *n == self.keybindings.profile_name)
.unwrap_or(0);
} else {
let (_, action) = self.keybindings_overlay_item(self.keybindings_overlay.index);
if action.is_some() {
self.keybindings_overlay.capturing = true;
self.status_message = "Press a key combo...".to_string();
}
}
}
KeyCode::Backspace => {
let (mode, action) = self.keybindings_overlay_item(self.keybindings_overlay.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.close_overlay();
self.save_settings();
}
_ => {}
}
}
pub fn handle_keybinding_capture(&mut self, modifiers: KeyModifiers, code: KeyCode) {
if code == KeyCode::Esc && modifiers == KeyModifiers::NONE {
self.keybindings_overlay.capturing = false;
self.status_message.clear();
return;
}
let (mode, action) = self.keybindings_overlay_item(self.keybindings_overlay.index);
let Some(action) = action else {
self.keybindings_overlay.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_overlay.capturing = false;
if let Some(displaced_action) = displaced
&& 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_overlay.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_overlay.filter.to_lowercase();
let mut contacts: Vec<(String, String)> = self
.store
.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_key(|a| a.1.to_lowercase());
self.contacts_overlay.filtered = contacts;
list_overlay::clamp_index(
&mut self.contacts_overlay.index,
self.contacts_overlay.filtered.len(),
);
}
pub fn group_menu_items(&self) -> Vec<GroupMenuItem> {
let is_group = self
.active_conversation
.as_ref()
.and_then(|id| self.store.conversations.get(id))
.is_some_and(|c| c.is_group);
if is_group {
vec![
GroupMenuItem {
label: "Members",
key_hint: "m",
nerd_icon: "\u{f0849}",
},
GroupMenuItem {
label: "Add member",
key_hint: "a",
nerd_icon: "\u{f0234}",
},
GroupMenuItem {
label: "Remove member",
key_hint: "r",
nerd_icon: "\u{f0235}",
},
GroupMenuItem {
label: "Rename",
key_hint: "n",
nerd_icon: "\u{f03eb}",
},
GroupMenuItem {
label: "Leave",
key_hint: "l",
nerd_icon: "\u{f0a79}",
},
]
} else {
vec![GroupMenuItem {
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.store.groups.get(id))
.map(|g| g.members.iter().map(|s| s.as_str()).collect())
.unwrap_or_default();
let mut contacts: Vec<(String, String)> = self
.store
.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_key(|a| a.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.store.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
.store
.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_key(|a| a.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;
self.close_overlay();
}
_ => {}
}
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.open_overlay(OverlayKind::GroupMenu);
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.close_overlay();
self.group_menu.filter.clear();
return Some(SendRequest::AddGroupMembers {
group_id,
members: vec![phone],
});
}
}
KeyCode::Esc => {
self.open_overlay(OverlayKind::GroupMenu);
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.close_overlay();
self.group_menu.filter.clear();
return Some(SendRequest::RemoveGroupMembers {
group_id,
members: vec![phone],
});
}
}
KeyCode::Esc => {
self.open_overlay(OverlayKind::GroupMenu);
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.close_overlay();
self.group_menu.input.clear();
return Some(SendRequest::RenameGroup { group_id, name });
}
}
KeyCode::Esc => {
self.open_overlay(OverlayKind::GroupMenu);
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.close_overlay();
self.group_menu.input.clear();
return Some(SendRequest::CreateGroup { name });
}
}
KeyCode::Esc => {
self.group_menu.state = None;
self.close_overlay();
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;
self.close_overlay();
return Some(SendRequest::LeaveGroup { group_id });
}
KeyCode::Char('n') | KeyCode::Esc => {
self.open_overlay(OverlayKind::GroupMenu);
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.store.groups.get(id))
.map(|g| {
g.members
.iter()
.map(|phone| {
let name = self
.store
.contact_names
.get(phone)
.cloned()
.unwrap_or_else(|| phone.clone());
(phone.clone(), name)
})
.collect()
})
.unwrap_or_default();
self.group_menu.filtered = members;
self.open_overlay(OverlayKind::GroupMenu);
self.group_menu.state = Some(GroupMenuState::Members);
}
"a" => {
self.refresh_group_add_filter();
self.open_overlay(OverlayKind::GroupMenu);
self.group_menu.state = Some(GroupMenuState::AddMember);
}
"r" => {
self.refresh_group_remove_filter();
self.open_overlay(OverlayKind::GroupMenu);
self.group_menu.state = Some(GroupMenuState::RemoveMember);
}
"n" => {
let name = self
.active_conversation
.as_ref()
.and_then(|id| self.store.conversations.get(id))
.map(|c| c.name.clone())
.unwrap_or_default();
self.group_menu.input = name;
self.open_overlay(OverlayKind::GroupMenu);
self.group_menu.state = Some(GroupMenuState::Rename);
}
"l" => {
self.open_overlay(OverlayKind::GroupMenu);
self.group_menu.state = Some(GroupMenuState::LeaveConfirm);
}
"c" => {
self.open_overlay(OverlayKind::GroupMenu);
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.close_overlay();
return None;
}
};
match code {
KeyCode::Char('a') => {
let is_group = self
.store
.conversations
.get(&conv_id)
.map(|c| c.is_group)
.unwrap_or(false);
if let Some(conv) = self.store.conversations.get_mut(&conv_id) {
conv.accepted = true;
}
self.db_warn_visible(self.db.update_accepted(&conv_id, true), "update_accepted");
self.close_overlay();
Some(SendRequest::MessageRequestResponse {
recipient: conv_id,
is_group,
response_type: "accept".to_string(),
})
}
KeyCode::Char('d') => {
self.close_overlay();
self.delete_active_conversation()
}
KeyCode::Esc => {
self.close_overlay();
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.reactions.picker_index = self.reactions.picker_index.saturating_sub(1);
None
}
KeyCode::Char('l') | KeyCode::Right => {
if self.reactions.picker_index < QUICK_REACTIONS.len() - 1 {
self.reactions.picker_index += 1;
}
None
}
KeyCode::Char(c @ '1'..='8') => {
let idx = (c as u8 - b'1') as usize;
if idx < QUICK_REACTIONS.len() {
self.reactions.picker_index = idx;
self.close_overlay();
self.prepare_reaction_send()
} else {
None
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.close_overlay();
self.prepare_reaction_send()
}
KeyCode::Char('e') | KeyCode::Char('/') => {
self.emoji_picker.open(EmojiPickerSource::Reaction, None);
self.open_overlay(OverlayKind::EmojiPicker);
None
}
KeyCode::Esc => {
self.close_overlay();
None
}
_ => None,
}
}
fn prepare_reaction_send(&mut self) -> Option<SendRequest> {
let emoji = QUICK_REACTIONS
.get(self.reactions.picker_index)?
.to_string();
self.prepare_reaction_send_emoji(&emoji)
}
fn prepare_reaction_send_emoji(&mut self, emoji: &str) -> Option<SendRequest> {
let conv_id = self.active_conversation.clone()?;
let conv = self.store.conversations.get(&conv_id)?;
let is_group = conv.is_group;
let index = self
.scroll
.focused_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.is_outgoing() {
self.account.clone()
} else {
self.store
.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.is_from_me() && r.emoji == emoji);
if let Some(conv) = self.store.conversations.get_mut(&conv_id)
&& let Some(msg) = conv.messages.get_mut(index)
{
if is_remove {
msg.reactions
.retain(|r| !(r.is_from_me() && r.emoji == emoji));
} else {
if let Some(existing) = msg.reactions.iter_mut().find(|r| r.is_from_me()) {
existing.emoji = emoji.to_string();
} else {
msg.reactions.push(Reaction {
emoji: emoji.to_string(),
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: emoji.to_string(),
is_group,
target_author,
target_timestamp,
remove: is_remove,
})
}
pub fn action_menu_items(&self) -> Vec<ActionMenuItem> {
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(ActionMenuItem {
label: "Reply",
key_hint: ActionMenuHint::Reply,
nerd_icon: "\u{f045a}",
});
}
if msg.is_outgoing() && !msg.is_system && !msg.is_deleted {
items.push(ActionMenuItem {
label: "Edit",
key_hint: ActionMenuHint::Edit,
nerd_icon: "\u{f03eb}",
});
}
if !msg.is_system {
items.push(ActionMenuItem {
label: "React",
key_hint: ActionMenuHint::React,
nerd_icon: "\u{f0785}",
});
}
if !msg.is_system && !msg.is_deleted {
items.push(ActionMenuItem {
label: "Forward",
key_hint: ActionMenuHint::Forward,
nerd_icon: "\u{f04d6}",
});
}
items.push(ActionMenuItem {
label: "Copy",
key_hint: ActionMenuHint::Copy,
nerd_icon: "\u{f018f}",
});
if !msg.is_system && !msg.is_deleted {
items.push(ActionMenuItem {
label: "Delete",
key_hint: ActionMenuHint::Delete,
nerd_icon: "\u{f0a79}",
});
}
if !msg.is_system && !msg.is_deleted {
items.push(ActionMenuItem {
label: if msg.is_pinned { "Unpin" } else { "Pin" },
key_hint: ActionMenuHint::PinToggle,
nerd_icon: "\u{f0403}",
});
}
if let Some(ref poll) = msg.poll_data {
if !poll.closed {
items.push(ActionMenuItem {
label: "Vote",
key_hint: ActionMenuHint::Vote,
nerd_icon: "\u{f0e73}",
});
}
if msg.is_outgoing() && !poll.closed {
items.push(ActionMenuItem {
label: "End Poll",
key_hint: ActionMenuHint::EndPoll,
nerd_icon: "\u{f073a}",
});
}
}
if !msg.is_deleted {
if extract_file_uri(&msg.body).is_some() {
items.push(ActionMenuItem {
label: "Open attachment",
key_hint: ActionMenuHint::OpenAttachment,
nerd_icon: "\u{f15b5}",
});
}
if extract_http_url(&msg.body).is_some() {
items.push(ActionMenuItem {
label: "Open link",
key_hint: ActionMenuHint::OpenLink,
nerd_icon: "\u{f0337}",
});
}
}
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.close_overlay();
return None;
}
let action = classify_list_key(code, false);
if list_overlay::apply_nav(&action, &mut self.action_menu.index, item_count) {
return None;
}
match action {
ListKeyAction::Select => {
let items = self.action_menu_items();
if let Some(action) = items.get(self.action_menu.index) {
let hint = action.key_hint;
self.close_overlay();
self.execute_action_by_hint(hint)
} else {
self.close_overlay();
None
}
}
ListKeyAction::Close => {
self.close_overlay();
None
}
ListKeyAction::None => {
let KeyCode::Char(c) = code else { return None };
let hint = ActionMenuHint::from_char(c)?;
let items = self.action_menu_items();
if items.iter().any(|a| a.key_hint == hint) {
self.close_overlay();
self.execute_action_by_hint(hint)
} else {
None
}
}
_ => None,
}
}
fn execute_action_by_hint(&mut self, hint: ActionMenuHint) -> Option<SendRequest> {
match hint {
ActionMenuHint::Reply => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
let phone = msg.route_author(&self.account).to_string();
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;
self.reply_target = Some((phone, snippet, ts));
self.mode = InputMode::Insert;
}
None
}
ActionMenuHint::Edit => {
if let Some(msg) = self.selected_message()
&& msg.is_outgoing()
&& !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
}
ActionMenuHint::React => {
if self.selected_message().is_some_and(|m| !m.is_system) {
self.open_overlay(OverlayKind::ReactionPicker);
self.reactions.picker_index = 0;
}
None
}
ActionMenuHint::Forward => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
self.forward.body = msg.body.clone();
self.open_forward_picker();
}
None
}
ActionMenuHint::Copy => {
self.copy_selected_message(false);
None
}
ActionMenuHint::Delete => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
self.open_overlay(OverlayKind::DeleteConfirm);
}
None
}
ActionMenuHint::PinToggle => {
crate::handlers::keys::execute_pin_toggle(self)
}
ActionMenuHint::Vote => {
if let Some(msg) = self.selected_message()
&& let Some(ref poll) = msg.poll_data
&& !poll.closed
{
let conv_id = self.active_conversation.clone().unwrap_or_default();
let is_group = self
.store
.conversations
.get(&conv_id)
.map(|c| c.is_group)
.unwrap_or(false);
let poll_author = msg.route_author(&self.account).to_string();
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.open_overlay(OverlayKind::PollVote);
}
None
}
ActionMenuHint::EndPoll => {
if let Some(msg) = self.selected_message()
&& msg.is_outgoing()
&& msg.poll_data.as_ref().is_some_and(|p| !p.closed)
{
let conv_id = self.active_conversation.clone()?;
let is_group = self
.store
.conversations
.get(&conv_id)
.map(|c| c.is_group)
.unwrap_or(false);
let poll_timestamp = msg.timestamp_ms;
if let Some(conv) = self.store.conversations.get_mut(&conv_id)
&& let Some(idx) = conv.find_msg_idx(poll_timestamp)
&& 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
}
ActionMenuHint::OpenAttachment => {
if let Some(msg) = self.selected_message()
&& let Some(uri) = extract_file_uri(&msg.body)
{
self.open_file(&uri);
}
None
}
ActionMenuHint::OpenLink => {
if let Some(msg) = self.selected_message()
&& let Some(url) = extract_http_url(&msg.body)
{
self.open_url(&url);
}
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.close_overlay();
}
_ => {
self.verify.confirming = false;
}
}
None
}
fn open_forward_picker(&mut self) {
self.open_overlay(OverlayKind::Forward);
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
.store
.conversation_order
.iter()
.filter_map(|id| {
let conv = self.store.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();
list_overlay::clamp_index(&mut self.forward.index, self.forward.filtered.len());
}
pub fn handle_forward_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let action = classify_list_key(code, true);
if list_overlay::apply_nav(
&action,
&mut self.forward.index,
self.forward.filtered.len(),
) {
return None;
}
match action {
ListKeyAction::Select => {
if let Some((conv_id, name)) =
self.forward.filtered.get(self.forward.index).cloned()
{
let is_group = self
.store
.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.close_overlay();
self.status_message = format!("Forwarded to {name}");
self.store.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,
});
}
}
ListKeyAction::Close => {
self.close_overlay();
}
ListKeyAction::FilterPush(c) => {
if !c.is_control() {
self.forward.filter.push(c);
self.update_forward_filter();
}
}
ListKeyAction::FilterPop => {
self.forward.filter.pop();
self.update_forward_filter();
}
ListKeyAction::None | ListKeyAction::Up | ListKeyAction::Down => {}
}
None
}
pub fn handle_contacts_key(&mut self, code: KeyCode) {
let action = classify_list_key(code, true);
if list_overlay::apply_nav(
&action,
&mut self.contacts_overlay.index,
self.contacts_overlay.filtered.len(),
) {
return;
}
match action {
ListKeyAction::Select => {
if let Some((number, _)) = self
.contacts_overlay
.filtered
.get(self.contacts_overlay.index)
{
let number = number.clone();
self.close_overlay();
self.contacts_overlay.filter.clear();
self.join_conversation(&number);
}
}
ListKeyAction::Close => {
self.close_overlay();
self.contacts_overlay.filter.clear();
}
ListKeyAction::FilterPush(c) => {
self.contacts_overlay.filter.push(c);
self.refresh_contacts_filter();
}
ListKeyAction::FilterPop => {
self.contacts_overlay.filter.pop();
self.refresh_contacts_filter();
}
ListKeyAction::None | ListKeyAction::Up | ListKeyAction::Down => {}
}
}
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.store.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.scroll.focused_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.scroll
.jump_stack
.push((self.scroll.offset, self.scroll.focused_index));
let conv_id = match self.active_conversation.as_ref() {
Some(id) => id.clone(),
None => return,
};
let found = self
.store
.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.scroll.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.scroll.jump_stack.pop() {
self.scroll.offset = offset;
self.scroll.focused_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.close_overlay();
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::Cancel => {
self.close_overlay();
}
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();
self.open_overlay(OverlayKind::FilePicker);
}
pub fn handle_file_browser_key(&mut self, code: KeyCode) {
match self.file_picker.handle_key(code) {
crate::domain::FilePickerOutcome::Continue => {}
crate::domain::FilePickerOutcome::Selected(path) => {
self.pending_attachment = Some(path);
self.close_overlay();
}
crate::domain::FilePickerOutcome::Cancelled => {
self.close_overlay();
}
}
}
pub fn handle_autocomplete_key(&mut self, code: KeyCode) -> Option<SendRequest> {
let list_len = self.autocomplete.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.clear();
if self.is_overlay(OverlayKind::Autocomplete) {
self.close_overlay();
}
}
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, config_path: &std::path::Path) -> Self {
let (image_render_tx, image_render_rx) = mpsc::channel();
Self {
store: ConversationStore::new(),
active_conversation: None,
input: InputState::default(),
sidebar_visible: true,
scroll: ScrollState::default(),
status_message: "connecting...".to_string(),
should_quit: false,
quit_confirm: false,
account,
sidebar_width: 22,
sidebar_on_right: false,
sidebar_filter: String::new(),
sidebar_filtered: Vec::new(),
typing: TypingState::default(),
connected: false,
loading: true,
startup_status: "Starting signal-cli...".to_string(),
spinner_tick: 0,
mode: InputMode::Insert,
db,
connection_error: None,
notifications: NotificationState::new(),
muted_conversations: HashMap::new(),
blocked_conversations: HashSet::new(),
autocomplete: AutocompleteState::new(),
settings_overlay: SettingsOverlayState {
mouse_snapshot: true,
..Default::default()
},
contacts_overlay: ContactsOverlayState::default(),
verify: VerifyOverlayState::default(),
identity_trust: HashMap::new(),
image: ImageState::new(image_render_tx, image_render_rx),
prev_active_conversation: None,
incognito: false,
date_separators: true,
show_receipts: true,
color_receipts: true,
nerd_fonts: false,
pending: PendingState::default(),
pending_normal_key: None,
reactions: ReactionState::new(),
emoji_picker: EmojiPickerState::default(),
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,
editing_message: None,
search: SearchState::default(),
send_read_receipts: true,
action_menu: ActionMenuState::default(),
forward: ForwardOverlayState::default(),
group_menu: GroupMenuOverlayState::default(),
mouse: MouseState {
enabled: true,
..MouseState::default()
},
theme: theme::default_theme(),
theme_picker: ThemePickerState {
available_themes: theme::all_themes(),
..Default::default()
},
keybindings: keybindings::default_profile(),
keybindings_overlay: KeybindingsOverlayState {
available_profiles: keybindings::all_profile_names(),
..Default::default()
},
pin_duration: PinDurationOverlayState::default(),
poll_vote: PollVoteOverlayState::default(),
expiring_msg_count: 0,
profile: ProfileOverlayState::default(),
settings_profiles: SettingsProfileOverlayState {
available: crate::settings_profile::all_settings_profiles(),
..Default::default()
},
sync: SyncState::new(),
current_overlay: None,
lock: LockState {
hash_path: crate::domain::lock_hash_path(config_path),
..Default::default()
},
}
}
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
&& Path::new(&p).exists()
{
msg.image_path = Some(p);
}
}
}
if msg_count >= Self::PAGE_SIZE {
self.store.has_more_messages.insert(id.clone());
}
self.store.conversations.insert(id.clone(), conv);
if msg_count > 0 {
let read_index = msg_count.saturating_sub(unread);
self.store.last_read_index.insert(id, read_index);
}
}
self.store.conversation_order = order;
self.muted_conversations = self.db.load_mutes()?;
self.blocked_conversations = self.db.load_blocked()?;
for conv in self.store.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.scroll.at_top = false;
let conv_id = match self.active_conversation.as_ref() {
Some(id) if self.store.has_more_messages.contains(id) => id.clone(),
_ => return,
};
let already_loaded = self
.store
.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.store.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
&& Path::new(&p).exists()
{
msg.image_path = Some(p);
}
}
msg
})
.collect();
if let Some(conv) = self.store.conversations.get_mut(&conv_id) {
processed.append(&mut conv.messages);
conv.messages = processed;
}
if let Some(read_idx) = self.store.last_read_index.get_mut(&conv_id) {
*read_idx += prepend_count;
}
if self.active_conversation.as_ref() == Some(&conv_id)
&& let Some(ref mut fi) = self.scroll.focused_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;
self.save_settings();
}
pub(crate) fn refresh_sidebar_filter(&mut self) {
let query = self.sidebar_filter.to_lowercase();
self.sidebar_filtered = self
.store
.conversation_order
.iter()
.filter(|id| {
self.store
.conversations
.get(*id)
.is_some_and(|c| c.name.to_lowercase().contains(&query))
})
.cloned()
.collect();
}
fn clear_sidebar_filter(&mut self) {
if self.is_overlay(OverlayKind::SidebarFilter) {
self.close_overlay();
}
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.store.conversations.get(conv_id) {
self.store
.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",
);
}
}
}
pub fn end_sync(&mut self) {
self.sync.active = false;
self.sync.pin = None;
if !self.sync.user_scrolled {
self.scroll.offset = 0;
}
let total: usize = self.sync.suppressed_notifications.values().sum();
let conv_count = self.sync.suppressed_notifications.len();
if total > 0 {
self.notifications.pending_bell = true;
if self.notifications.desktop_notifications {
let conv_word = if conv_count == 1 {
"conversation"
} else {
"conversations"
};
let body = format!("{total} new messages in {conv_count} {conv_word}");
show_desktop_notification(
"siggy",
&body,
false,
None,
crate::domain::NotificationPreview::Full,
);
}
}
self.sync.suppressed_notifications.clear();
if let Some(conv_id) = self.active_conversation.clone() {
let read_from = self
.store
.last_read_index
.get(&conv_id)
.copied()
.unwrap_or(0);
self.queue_read_receipts_for_conv(&conv_id, read_from);
}
self.mark_read();
self.status_message = if self.connected {
"connected".to_string()
} else {
"disconnected".to_string()
};
}
fn queue_read_receipts_for_conv(&mut self, conv_id: &str, start_index: usize) {
if !self.send_read_receipts {
return;
}
let conv = match self.store.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));
}
}
}
pub(crate) 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
.store
.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_placements(&mut self) {
self.image.kitty_transmitted.clear();
self.image.kitty_pending_transmits.clear();
}
pub fn clear_kitty_state(&mut self) {
self.clear_kitty_placements();
if self.image.image_protocol != ImageProtocol::Sixel {
self.image.native_image_cache.clear();
self.image.iterm2_crop_cache.clear();
}
}
pub(crate) fn reset_typing_with_stop(&mut self) {
if self.typing.reset() {
self.pending.typing_stop = self.build_typing_request(true);
}
}
pub fn lock_now(&mut self) {
let has_hash = crate::domain::load_hash(&self.lock.hash_path)
.ok()
.flatten()
.filter(|s| !s.is_empty())
.is_some();
self.lock.phase = if has_hash {
crate::domain::LockPhase::LockEntry
} else {
crate::domain::LockPhase::SetPassphrase
};
self.lock.input_buffer.clear();
self.lock.error = None;
self.lock.old_passphrase_verified = false;
}
pub fn handle_lock_key(&mut self, code: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
if matches!(code, KeyCode::Char(_) | KeyCode::Backspace) {
self.lock.error = None;
}
match code {
KeyCode::Char(c) => {
self.lock.input_buffer.push(c);
true
}
KeyCode::Backspace => {
self.lock.input_buffer.pop();
true
}
KeyCode::Esc => {
self.lock.input_buffer.clear();
true
}
KeyCode::Enter => {
self.submit_lock_input();
true
}
_ => true, }
}
fn submit_lock_input(&mut self) {
use crate::domain::{LockPhase, hash_passphrase, load_hash, save_hash, verify_passphrase};
let entered = std::mem::take(&mut self.lock.input_buffer);
match self.lock.phase {
LockPhase::Unlocked => {} LockPhase::SetPassphrase => {
if entered.is_empty() {
self.lock.error = Some("Passphrase cannot be empty".to_string());
return;
}
match hash_passphrase(&entered) {
Ok(h) => {
if let Err(e) = save_hash(&self.lock.hash_path, &h) {
self.lock.error = Some(format!("Could not save hash: {e}"));
return;
}
self.lock.phase = LockPhase::Unlocked;
self.lock.error = None;
self.status_message = "Lock passphrase set".to_string();
}
Err(e) => {
self.lock.error = Some(format!("Hash failed: {e}"));
}
}
}
LockPhase::LockEntry => {
let stored = load_hash(&self.lock.hash_path)
.ok()
.flatten()
.unwrap_or_default();
if verify_passphrase(&entered, &stored) {
self.lock.phase = LockPhase::Unlocked;
self.lock.error = None;
} else {
self.lock.error = Some("Incorrect passphrase".to_string());
}
}
LockPhase::ChangePassphraseOld => {
let stored = load_hash(&self.lock.hash_path)
.ok()
.flatten()
.unwrap_or_default();
if verify_passphrase(&entered, &stored) {
self.lock.old_passphrase_verified = true;
self.lock.phase = LockPhase::ChangePassphraseNew;
self.lock.error = None;
} else {
self.lock.error = Some("Incorrect passphrase".to_string());
}
}
LockPhase::ChangePassphraseNew => {
if entered.is_empty() {
self.lock.error = Some("Passphrase cannot be empty".to_string());
return;
}
match hash_passphrase(&entered) {
Ok(h) => {
if let Err(e) = save_hash(&self.lock.hash_path, &h) {
self.lock.error = Some(format!("Could not save hash: {e}"));
return;
}
self.lock.phase = LockPhase::Unlocked;
self.lock.old_passphrase_verified = false;
self.status_message = "Lock passphrase changed".to_string();
}
Err(e) => {
self.lock.error = Some(format!("Hash failed: {e}"));
}
}
}
}
}
pub fn handle_global_key(&mut self, modifiers: KeyModifiers, code: KeyCode) -> bool {
if self.lock.is_locked() {
return self.handle_lock_key(code);
}
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.is_overlay(OverlayKind::Autocomplete) => {
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.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_add(5);
self.scroll.focused_index = None;
true
}
Some(KeyAction::PageScrollDown) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_sub(5);
self.scroll.focused_index = None;
true
}
Some(KeyAction::SidebarSearch) => {
self.sidebar_visible = true;
self.open_overlay(OverlayKind::SidebarFilter);
self.sidebar_filter.clear();
self.sidebar_filtered.clear();
true
}
Some(KeyAction::Lock) => {
self.lock_now();
true
}
_ => false,
}
}
pub fn active_overlay(&self) -> Option<OverlayKind> {
self.current_overlay
}
pub fn open_overlay(&mut self, kind: OverlayKind) {
self.current_overlay = Some(kind);
}
pub fn close_overlay(&mut self) {
self.current_overlay = None;
}
pub fn try_open_overlay(&mut self, kind: OverlayKind) {
if self.current_overlay.is_none() || self.current_overlay == Some(kind) {
self.open_overlay(kind);
}
}
pub fn is_overlay(&self, kind: OverlayKind) -> bool {
self.current_overlay == Some(kind)
}
pub fn handle_overlay_key(&mut self, code: KeyCode) -> (bool, Option<SendRequest>) {
let Some(kind) = self.active_overlay() else {
return (false, None);
};
match kind {
OverlayKind::SidebarFilter => {
self.handle_sidebar_filter_key(code);
(true, None)
}
OverlayKind::PollVote => {
let send = self.handle_poll_vote_key(code);
(true, send)
}
OverlayKind::PinDuration => {
let send = self.handle_pin_duration_key(code);
(true, send)
}
OverlayKind::ActionMenu => {
let send = self.handle_action_menu_key(code);
(true, send)
}
OverlayKind::DeleteConfirm => {
let send = self.handle_delete_confirm_key(code);
(true, send)
}
OverlayKind::DeleteConversationConfirm => {
let send = self.handle_delete_conversation_confirm_key(code);
(true, send)
}
OverlayKind::FilePicker => {
self.handle_file_browser_key(code);
(true, None)
}
OverlayKind::EmojiPicker => match self.emoji_picker.handle_key(code) {
EmojiPickerAction::Select(emoji) => {
let source = self.emoji_picker.source;
self.emoji_picker.close();
match source {
EmojiPickerSource::Input => {
self.input.buffer.insert_str(self.input.cursor, &emoji);
self.input.cursor += emoji.len();
(true, None)
}
EmojiPickerSource::Reaction => {
let send = self.prepare_reaction_send_emoji(&emoji);
(true, send)
}
}
}
EmojiPickerAction::Close => {
let was_reaction = self.emoji_picker.source == EmojiPickerSource::Reaction;
self.emoji_picker.close();
if was_reaction {
self.open_overlay(OverlayKind::ReactionPicker);
}
(true, None)
}
EmojiPickerAction::None => (true, None),
},
OverlayKind::ReactionPicker => {
let send = self.handle_reaction_picker_key(code);
(true, send)
}
OverlayKind::MessageRequest => {
let send = self.handle_message_request_key(code);
(true, send)
}
OverlayKind::GroupMenu => {
let send = self.handle_group_menu_key(code);
(true, send)
}
OverlayKind::About => {
self.close_overlay();
(true, None)
}
OverlayKind::Profile => {
let send = self.handle_profile_key(code);
(true, send)
}
OverlayKind::Help => {
self.close_overlay();
(true, None)
}
OverlayKind::Verify => {
let send = self.handle_verify_key(code);
(true, send)
}
OverlayKind::Forward => {
let send = self.handle_forward_key(code);
(true, send)
}
OverlayKind::Contacts => {
self.handle_contacts_key(code);
(true, None)
}
OverlayKind::Search => {
self.handle_search_key(code);
(true, None)
}
OverlayKind::SettingsProfiles => {
self.handle_settings_profile_manager_key(code);
(true, None)
}
OverlayKind::ThemePicker => {
self.handle_theme_key(code);
(true, None)
}
OverlayKind::Keybindings => {
self.handle_keybindings_key(code);
(true, None)
}
OverlayKind::Customize => {
self.handle_customize_key(code);
(true, None)
}
OverlayKind::Settings => {
self.handle_settings_key(code);
(true, None)
}
OverlayKind::Autocomplete => {
let send = self.handle_autocomplete_key(code);
(true, send)
}
}
}
pub fn handle_normal_key(
&mut self,
modifiers: KeyModifiers,
code: KeyCode,
) -> Option<SendRequest> {
if let Some(prev) = self.pending_normal_key.take() {
match (prev, code) {
('g', KeyCode::Char('g')) => {
if let Some(ref id) = self.active_conversation
&& let Some(conv) = self.store.conversations.get(id)
{
self.scroll.offset = conv.messages.len();
}
self.scroll.focused_index = None;
return None;
}
('d', KeyCode::Char('d')) => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
self.open_overlay(OverlayKind::DeleteConfirm);
}
return None;
}
(_, KeyCode::Esc) => {
return None;
}
_ => {
}
}
}
match self
.keybindings
.resolve(modifiers, code, BindingMode::Normal)
{
Some(KeyAction::ScrollDown) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_sub(1);
self.scroll.focused_index = None;
None
}
Some(KeyAction::ScrollUp) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_add(1);
self.scroll.focused_index = None;
None
}
Some(KeyAction::FocusNextMessage) => {
self.sync.user_scrolled = true;
self.jump_to_adjacent_message(false);
None
}
Some(KeyAction::FocusPrevMessage) => {
self.sync.user_scrolled = true;
self.jump_to_adjacent_message(true);
None
}
Some(KeyAction::HalfPageDown) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_sub(10);
self.scroll.focused_index = None;
None
}
Some(KeyAction::HalfPageUp) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_add(10);
self.scroll.focused_index = None;
None
}
Some(KeyAction::ScrollToBottom) => {
self.sync.user_scrolled = true;
self.scroll.offset = 0;
self.scroll.focused_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) => {
let line_end = self.current_line_end();
self.input.cursor = line_end;
self.input.buffer.insert(self.input.cursor, '\n');
self.input.cursor += 1;
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.open_overlay(OverlayKind::SidebarFilter);
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.autocomplete.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.open_overlay(OverlayKind::ReactionPicker);
self.reactions.picker_index = 0;
}
None
}
Some(KeyAction::Quote) => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
let phone = msg.route_author(&self.account).to_string();
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;
self.reply_target = Some((phone, snippet, ts));
self.mode = InputMode::Insert;
}
None
}
Some(KeyAction::EditMessage) => {
if let Some(msg) = self.selected_message()
&& msg.is_outgoing()
&& !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()
&& !msg.is_system
&& !msg.is_deleted
{
self.forward.body = msg.body.clone();
self.open_forward_picker();
}
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.open_overlay(OverlayKind::ActionMenu);
self.action_menu.index = 0;
}
None
}
Some(KeyAction::PinMessage) => crate::handlers::keys::execute_pin_toggle(self),
Some(KeyAction::JumpToQuote) => {
self.jump_to_quote();
None
}
Some(KeyAction::JumpBack) => {
self.jump_back();
None
}
_ => {
if let KeyCode::Char(c @ ('g' | 'd')) = code
&& modifiers.is_empty()
{
self.pending_normal_key = Some(c);
}
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.pending_normal_key = None; self.close_overlay();
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.close_overlay();
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.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_sub(1);
self.scroll.focused_index = None;
None
}
Some(KeyAction::ScrollUp) => {
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_add(1);
self.scroll.focused_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.open_overlay(OverlayKind::ReactionPicker);
self.reactions.picker_index = 0;
}
None
}
Some(KeyAction::Quote) => {
if let Some(msg) = self.selected_message()
&& !msg.is_system
&& !msg.is_deleted
{
let phone = msg.route_author(&self.account).to_string();
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;
self.reply_target = Some((phone, snippet, ts));
}
None
}
Some(KeyAction::EditMessage) => {
if let Some(msg) = self.selected_message()
&& msg.is_outgoing()
&& !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()
&& !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()
&& !msg.is_system
&& !msg.is_deleted
{
self.open_overlay(OverlayKind::DeleteConfirm);
}
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.open_overlay(OverlayKind::ActionMenu);
self.action_menu.index = 0;
}
None
}
Some(KeyAction::PinMessage) => crate::handlers::keys::execute_pin_toggle(self),
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) {
crate::handlers::signal::handle_signal_event(self, event);
}
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.store.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)
&& n > 0
{
return true;
}
removed
}
pub fn active_mute(&self, conv_id: &str, now: DateTime<Utc>) -> Option<&MuteState> {
self.muted_conversations
.get(conv_id)
.filter(|s| s.is_active(now))
}
pub fn is_muted_at(&self, conv_id: &str, now: DateTime<Utc>) -> bool {
self.active_mute(conv_id, now).is_some()
}
pub(crate) fn conversation_name<'a>(&'a self, conv_id: &'a str) -> &'a str {
self.store
.conversations
.get(conv_id)
.map(|c| c.name.as_str())
.unwrap_or(conv_id)
}
pub(crate) fn apply_mute(&mut self, conv_id: &str, state: Option<MuteState>) {
match state {
None => {
self.muted_conversations.remove(conv_id);
}
Some(s) => {
self.muted_conversations.insert(conv_id.to_string(), s);
}
}
db_warn(self.db.set_mute(conv_id, state), "set_mute");
}
pub fn sweep_expired_mutes(&mut self) {
match self.db.clear_expired_mutes(Utc::now()) {
Ok(cleared_ids) => {
if cleared_ids.is_empty() {
return;
}
for id in &cleared_ids {
self.muted_conversations.remove(id);
}
let names: Vec<&str> = cleared_ids
.iter()
.map(|id| self.conversation_name(id))
.collect();
self.status_message = format!("unmuted {}", names.join(", "));
}
Err(e) => {
crate::debug_log::logf(format_args!("db clear_expired_mutes: {e}"));
}
}
}
pub fn handle_delete_confirm_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('y') => {
self.close_overlay();
let conv_id = self.active_conversation.clone()?;
let conv = self.store.conversations.get(&conv_id)?;
let is_group = conv.is_group;
let index = self
.scroll
.focused_index
.unwrap_or_else(|| conv.messages.len().saturating_sub(1));
let msg = conv.messages.get(index)?;
let is_outgoing = msg.is_outgoing();
let target_timestamp = msg.timestamp_ms;
let conv = self.store.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.close_overlay();
let conv_id = self.active_conversation.clone()?;
let conv = self.store.conversations.get(&conv_id)?;
let index = self
.scroll
.focused_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.store.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.close_overlay();
None
}
_ => None,
}
}
pub fn handle_delete_conversation_confirm_key(&mut self, code: KeyCode) -> Option<SendRequest> {
match code {
KeyCode::Char('y') => {
self.close_overlay();
self.delete_active_conversation()
}
KeyCode::Char('n') | KeyCode::Esc => {
self.close_overlay();
None
}
_ => None,
}
}
pub(crate) fn delete_active_conversation(&mut self) -> Option<SendRequest> {
let conv_id = match self.active_conversation.clone() {
Some(id) => id,
None => {
self.status_message = "No active conversation to delete".to_string();
return None;
}
};
let (name, is_group, accepted) = match self.store.conversations.get(&conv_id) {
Some(conv) => (conv.name.clone(), conv.is_group, conv.accepted),
None => {
self.status_message = "Active conversation no longer exists".to_string();
self.active_conversation = None;
return None;
}
};
self.store.conversations.remove(&conv_id);
self.store.conversation_order.retain(|id| id != &conv_id);
self.store.last_read_index.remove(&conv_id);
self.store.has_more_messages.remove(&conv_id);
self.store.groups.remove(&conv_id);
self.scroll.positions.remove(&conv_id);
self.muted_conversations.remove(&conv_id);
self.blocked_conversations.remove(&conv_id);
self.db_warn_visible(self.db.delete_conversation(&conv_id), "delete_conversation");
self.active_conversation = None;
self.scroll.offset = 0;
self.scroll.focused_index = None;
self.pending_attachment = None;
self.reply_target = None;
self.editing_message = None;
self.reset_typing_with_stop();
self.clear_kitty_placements();
if self.is_overlay(OverlayKind::SidebarFilter) {
self.refresh_sidebar_filter();
}
self.status_message = format!("Deleted conversation \"{name}\"");
if !accepted {
Some(SendRequest::MessageRequestResponse {
recipient: conv_id,
is_group,
response_type: "delete".to_string(),
})
} else {
None
}
}
pub fn handle_pin_duration_key(&mut self, code: KeyCode) -> Option<SendRequest> {
crate::handlers::keys::handle_pin_duration_key(self, code)
}
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.close_overlay();
return Some(SendRequest::UpdateProfile {
given_name,
family_name,
about,
about_emoji,
});
}
}
KeyCode::Esc => {
self.close_overlay();
}
_ => {}
}
None
}
pub fn handle_poll_vote_key(&mut self, code: KeyCode) -> Option<SendRequest> {
crate::handlers::keys::handle_poll_vote_key(self, code)
}
pub(crate) fn prepare_outgoing_mentions(&self, text: &str) -> (String, Vec<(usize, String)>) {
if self.autocomplete.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.autocomplete.pending_mentions {
let pattern = format!("@{name}");
if let Some(uuid) = uuid
&& let Some(pos) = wire.find(&pattern)
{
found.push((pos, pos + pattern.len(), uuid.clone()));
}
}
found.sort_by_key(|b| std::cmp::Reverse(b.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)
}
pub fn handle_input(&mut self) -> Option<SendRequest> {
crate::handlers::input::handle_input(self)
}
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.try_open_overlay(OverlayKind::Autocomplete);
self.autocomplete.mode = AutocompleteMode::Command;
self.autocomplete.command_candidates = candidates;
if self.autocomplete.index >= self.autocomplete.command_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.store.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.store.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.store.conversation_order {
if let Some(conv) = self.store.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_key(|a| a.0.to_lowercase());
if !candidates.is_empty() {
self.try_open_overlay(OverlayKind::Autocomplete);
self.autocomplete.mode = AutocompleteMode::Join;
self.autocomplete.join_candidates = candidates;
if self.autocomplete.index >= self.autocomplete.join_candidates.len() {
self.autocomplete.index = 0;
}
return;
}
}
if let Some(ref conv_id) = self.active_conversation
&& let Some(conv) = self.store.conversations.get(conv_id)
&& 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.store.groups.get(conv_id) {
for member_phone in &group.members {
let name = self
.store
.contact_names
.get(member_phone)
.cloned()
.unwrap_or_else(|| member_phone.clone());
let uuid = self.store.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
.store
.contact_names
.get(conv_id)
.cloned()
.unwrap_or_else(|| conv_id.clone());
let uuid = self.store.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_key(|a| a.1.to_lowercase());
if !candidates.is_empty() {
self.try_open_overlay(OverlayKind::Autocomplete);
self.autocomplete.mode = AutocompleteMode::Mention;
self.autocomplete.mention_candidates = candidates;
self.autocomplete.mention_trigger_pos = trigger_pos;
if self.autocomplete.index >= self.autocomplete.mention_candidates.len() {
self.autocomplete.index = 0;
}
return;
}
}
self.autocomplete.clear();
if self.is_overlay(OverlayKind::Autocomplete) {
self.close_overlay();
}
}
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.input.history_index {
None => {
self.input.history_draft = self.input.buffer.clone();
self.input.history_index = Some(self.input.history.len() - 1);
}
Some(idx) if idx > 0 => {
self.input.history_index = Some(idx - 1);
}
_ => return,
}
self.input.buffer = self.input.history[self.input.history_index.unwrap()].clone();
self.input.cursor = self.input.buffer.len();
}
pub fn history_down(&mut self) {
let idx = match self.input.history_index {
Some(idx) => idx,
None => return,
};
if idx < self.input.history.len() - 1 {
self.input.history_index = Some(idx + 1);
self.input.buffer = self.input.history[idx + 1].clone();
} else {
self.input.buffer = self.input.history_draft.clone();
self.input.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.lock.is_locked() {
return None;
}
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
}
pub(crate) fn handle_paste_command(&mut self) -> Option<SendRequest> {
if self.lock.is_locked() {
return None;
}
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
.command_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.close_overlay();
self.autocomplete.command_candidates.clear();
self.autocomplete.index = 0;
}
}
AutocompleteMode::Mention => {
if let Some((_phone, name, uuid)) = self
.autocomplete
.mention_candidates
.get(self.autocomplete.index)
.cloned()
{
let replacement = format!("@{name} ");
let before = &self.input.buffer[..self.autocomplete.mention_trigger_pos];
let after = &self.input.buffer[self.input.cursor..];
self.input.buffer = format!("{before}{replacement}{after}");
self.input.cursor = self.autocomplete.mention_trigger_pos + replacement.len();
self.autocomplete.pending_mentions.push((name, uuid));
self.close_overlay();
self.autocomplete.mention_candidates.clear();
self.autocomplete.index = 0;
}
}
AutocompleteMode::Join => {
if let Some((_display, value)) = self
.autocomplete
.join_candidates
.get(self.autocomplete.index)
.cloned()
{
self.input.buffer = format!("/join {value}");
self.input.cursor = self.input.buffer.len();
self.close_overlay();
self.autocomplete.join_candidates.clear();
self.autocomplete.index = 0;
}
}
}
}
pub(crate) fn save_scroll_position(&mut self) {
if let Some(ref id) = self.active_conversation {
self.scroll
.positions
.insert(id.clone(), (self.scroll.offset, self.scroll.focused_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.scroll.focused_index = focus;
} else {
self.scroll.offset = 0;
self.scroll.focused_index = None;
}
}
pub(crate) fn join_conversation(&mut self, target: &str) {
self.mark_read();
self.save_scroll_position();
self.pending_attachment = None;
self.reset_typing_with_stop();
self.input.reset_for_conv_switch();
self.sync.pin = None;
self.clear_kitty_placements();
if self.store.conversations.contains_key(target) {
let read_from = self.store.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.store.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
.store
.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.store.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.store.conversations.get_mut(&id) {
conv.unread = 0;
}
self.restore_scroll_position(&id);
self.update_status();
return;
}
if target.starts_with('+') {
self.store
.get_or_create_conversation(target, target, false, &self.db);
self.active_conversation = Some(target.to_string());
self.scroll.offset = 0;
self.scroll.focused_index = None;
self.update_status();
} else {
self.status_message = format!("Conversation not found: {target}");
}
}
pub(crate) fn maybe_capture_sync_pin(&mut self, conv_id: &str) {
if !self.sync.active || self.sync.user_scrolled || self.sync.pin.is_some() {
return;
}
if self.active_conversation.as_deref() != Some(conv_id) {
return;
}
if let Some(conv) = self.store.conversations.get(conv_id)
&& let Some(last) = conv.messages.last()
{
self.sync.pin = Some((last.timestamp, self.scroll.offset));
}
}
pub fn should_tick_spinner(&self) -> bool {
self.loading && !self.sync.active
}
pub fn next_conversation(&mut self) {
if self.store.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.input.reset_for_conv_switch();
self.sync.pin = None;
self.clear_kitty_placements();
let idx = self
.active_conversation
.as_ref()
.and_then(|id| self.store.conversation_order.iter().position(|x| x == id))
.map(|i| (i + 1) % self.store.conversation_order.len())
.unwrap_or(0);
let new_id = self.store.conversation_order[idx].clone();
let read_from = self
.store
.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.store.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.store.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.input.reset_for_conv_switch();
self.sync.pin = None;
self.clear_kitty_placements();
let len = self.store.conversation_order.len();
let idx = self
.active_conversation
.as_ref()
.and_then(|id| self.store.conversation_order.iter().position(|x| x == id))
.map(|i| if i == 0 { len - 1 } else { i - 1 })
.unwrap_or(0);
let new_id = self.store.conversation_order[idx].clone();
let read_from = self
.store
.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.store.conversations.get_mut(&new_id) {
conv.unread = 0;
}
self.restore_scroll_position(&new_id);
self.update_status();
}
pub(crate) fn update_status(&mut self) {
if let Some(ref id) = self.active_conversation {
if let Some(conv) = self.store.conversations.get(id) {
let prefix = if conv.is_group { "#" } else { "" };
self.status_message = format!("connected | {}{}", prefix, conv.name);
}
let should_show = self
.active_conversation
.as_ref()
.and_then(|id| self.store.conversations.get(id))
.is_some_and(|c| !c.accepted);
if should_show {
self.try_open_overlay(OverlayKind::MessageRequest);
} else if self.is_overlay(OverlayKind::MessageRequest) {
self.close_overlay();
}
} else {
self.status_message = "connected | no conversation selected".to_string();
if self.is_overlay(OverlayKind::MessageRequest) {
self.close_overlay();
}
}
}
pub fn set_connected(&mut self) {
self.connected = true;
self.status_message = "connected | no conversation selected".to_string();
}
pub fn selected_message(&self) -> Option<&DisplayMessage> {
let conv_id = self.active_conversation.as_ref()?;
let conv = self.store.conversations.get(conv_id)?;
let index = self
.scroll
.focused_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.store.conversations.get(&conv_id) {
Some(c) => c,
None => return,
};
let total = conv.messages.len();
if total == 0 {
return;
}
let current = match self.scroll.focused_index {
Some(i) => i,
None => {
let start = (0..total).rev().find(|&i| !conv.messages[i].is_system);
if let Some(s) = start {
self.scroll.focused_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.scroll.focused_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.notifications.clipboard_clear_seconds > 0 {
self.notifications.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.notifications.clipboard_set_at
&& set_at.elapsed().as_secs() >= self.notifications.clipboard_clear_seconds
{
self.notifications.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.active_overlay().is_some()
}
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.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_add(3);
self.scroll.focused_index = None;
}
MouseEventKind::ScrollDown
if is_in_rect(event.column, event.row, self.mouse.messages_area) =>
{
self.sync.user_scrolled = true;
self.scroll.offset = self.scroll.offset.saturating_sub(3);
self.scroll.focused_index = None;
}
_ => {}
}
None
}
fn handle_left_click(&mut self, col: u16, row: u16) {
for link in &self.image.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
&& is_in_rect(col, row, inner)
{
let index = (row - inner.y) as usize;
let sidebar_list = if self.is_overlay(OverlayKind::SidebarFilter)
&& !self.sidebar_filtered.is_empty()
{
&self.sidebar_filtered
} else {
&self.store.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 open_file(&mut self, uri: &str) {
let path = file_uri_to_path(uri);
if !std::path::Path::new(&path).exists() {
self.status_message = format!("File not found: {path}");
return;
}
let filename = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path);
match open::that(&path) {
Ok(()) => self.status_message = format!("Opened {filename}"),
Err(e) => self.status_message = format!("Failed to open: {e}"),
}
}
pub(crate) 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.store.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 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 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()
}
}
fn extract_file_uri(body: &str) -> Option<String> {
let pos = body.find("file:///")?;
let rest = &body[pos..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ')')
.unwrap_or(rest.len());
Some(rest[..end].to_string())
}
fn extract_http_url(body: &str) -> Option<String> {
let mut best: Option<(usize, &str)> = None;
for scheme in &["https://", "http://"] {
if let Some(pos) = body.find(scheme)
&& (best.is_none() || pos < best.unwrap().0)
{
best = Some((pos, scheme));
}
}
let (pos, _) = best?;
let rest = &body[pos..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ')')
.unwrap_or(rest.len());
Some(rest[..end].to_string())
}
impl App {
pub(crate) fn populate_demo_data(&mut self, base_date: chrono::NaiveDate) {
use crate::signal::types::{
Group, LinkPreview, MessageStatus, PollData, PollOption, PollVote, Reaction, StyleType,
};
use chrono::{Local, TimeZone};
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(),
body_raw: None,
mentions: 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.store.conversations.insert(id.clone(), conv);
if msg_count > 0 {
self.store
.last_read_index
.insert(id, msg_count.saturating_sub(unread));
}
}
self.store.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.store
.contact_names
.insert(phone.to_string(), name.to_string());
self.store
.uuid_to_name
.insert(uuid.to_string(), name.to_string());
self.store
.number_to_uuid
.insert(phone.to_string(), uuid.to_string());
}
self.store.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.store.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.store.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.store.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.store.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, IdentityInfo, Mention, PollData, PollOption, SignalEvent,
SignalMessage, StyleType, TextStyle, TrustLevel,
};
use crossterm::event::{KeyCode, KeyModifiers};
use rstest::{fixture, rstest};
#[fixture]
fn app() -> App {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
std::mem::forget(dir);
let db = Database::open_in_memory().unwrap();
let mut app = App::new("+10000000000".to_string(), db, &config_path);
app.set_connected();
app
}
#[rstest]
fn contact_list_does_not_create_conversations(mut app: App) {
assert!(app.store.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.store.conversations.is_empty());
assert!(app.store.conversation_order.is_empty());
assert_eq!(app.store.contact_names["+1"], "Alice");
assert_eq!(app.store.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.store.conversations.len(), 2);
assert_eq!(app.store.conversations["g1"].name, "Family");
assert_eq!(app.store.conversations["g2"].name, "Work");
assert!(app.store.conversations["g1"].is_group);
assert_eq!(app.store.contact_names["g1"], "Family");
}
#[rstest]
fn contact_name_updates_existing_conversation(mut app: App) {
let msg = SignalMessage {
source: "+15551234567".to_string(),
timestamp: chrono::Utc::now(),
body: Some("hey".to_string()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.store.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.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.store.conversations["+1"].name, "Alice");
app.handle_signal_event(SignalEvent::ContactList(vec![Contact {
number: "+1".to_string(),
name: None,
uuid: None,
}]));
assert_eq!(app.store.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.store.conversations.is_empty());
let msg = SignalMessage {
source: "+1".to_string(),
timestamp: chrono::Utc::now(),
body: Some("hello!".to_string()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.store.conversations.len(), 1);
assert_eq!(app.store.conversations["+1"].name, "Alice");
assert_eq!(app.store.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.store.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()),
group_id: Some("g1".to_string()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.store.conversations.len(), 1);
assert_eq!(app.store.conversations["g1"].name, "Family");
assert_eq!(app.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
}
assert_eq!(app.store.conversations.len(), 1);
assert_eq!(app.store.conversation_order.len(), 1);
assert_eq!(app.store.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.is_overlay(OverlayKind::Autocomplete),
expected_visible,
"visibility for {input:?}"
);
if let Some(count) = expected_count {
assert_eq!(
app.autocomplete.command_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.command_candidates.len();
app.autocomplete.index = len + 5; app.update_autocomplete(); assert!(app.autocomplete.index < app.autocomplete.command_candidates.len());
}
#[rstest]
fn join_autocomplete_shows_contacts(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.contact_names
.insert("+2".to_string(), "Bob".to_string());
app.input.buffer = "/join ".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.mode, AutocompleteMode::Join);
assert_eq!(app.autocomplete.join_candidates.len(), 2);
}
#[rstest]
fn join_autocomplete_shows_groups(mut app: App) {
app.store.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.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.mode, AutocompleteMode::Join);
assert_eq!(app.autocomplete.join_candidates.len(), 1);
assert!(app.autocomplete.join_candidates[0].0.starts_with('#'));
}
#[rstest]
fn join_autocomplete_filters_by_name(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.contact_names
.insert("+2".to_string(), "Bob".to_string());
app.input.buffer = "/join al".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.join_candidates.len(), 1);
assert!(app.autocomplete.join_candidates[0].0.contains("Alice"));
}
#[rstest]
fn join_autocomplete_filters_by_phone(mut app: App) {
app.store
.contact_names
.insert("+1234".to_string(), "Alice".to_string());
app.store
.contact_names
.insert("+5678".to_string(), "Bob".to_string());
app.input.buffer = "/join +123".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.join_candidates.len(), 1);
assert!(app.autocomplete.join_candidates[0].1 == "+1234");
}
#[rstest]
fn join_autocomplete_alias(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.input.buffer = "/j ".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.mode, AutocompleteMode::Join);
assert_eq!(app.autocomplete.join_candidates.len(), 1);
}
#[rstest]
fn join_autocomplete_no_match_hides(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.input.buffer = "/join zzz".to_string();
app.update_autocomplete();
assert!(!app.is_overlay(OverlayKind::Autocomplete));
}
#[rstest]
fn apply_join_autocomplete(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.input.buffer = "/join al".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
app.apply_autocomplete();
assert_eq!(app.input.buffer, "/join +1");
assert_eq!(app.input.cursor, 8);
assert!(!app.is_overlay(OverlayKind::Autocomplete));
}
#[rstest]
fn apply_join_autocomplete_group(mut app: App) {
app.store.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.is_overlay(OverlayKind::Autocomplete));
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.store
.get_or_create_conversation("+9999", "+9999", false, &app.db);
app.input.buffer = "/join +999".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.join_candidates.len(), 1);
}
#[rstest]
fn join_autocomplete_skips_group_ids_in_contacts(mut app: App) {
app.store
.contact_names
.insert("g1".to_string(), "Family".to_string());
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.input.buffer = "/join ".to_string();
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
let contact_entries: Vec<_> = app
.autocomplete
.join_candidates
.iter()
.filter(|(_, v)| v == "+1")
.collect();
assert_eq!(contact_entries.len(), 1);
}
#[rstest]
fn join_autocomplete_index_clamped(mut app: App) {
app.store
.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.autocomplete.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.input.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.input.history_index, None);
}
#[rstest]
fn history_up_recalls_last_entry(mut app: App) {
app.input.history = vec!["hello".to_string(), "goodbye".to_string()];
app.input.buffer = "draft".to_string();
app.input.cursor = 5;
app.history_up();
assert_eq!(app.input.buffer, "goodbye");
assert_eq!(app.input.history_index, Some(1));
assert_eq!(app.input.history_draft, "draft");
assert_eq!(app.input.cursor, 7); }
#[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.input.history_index, Some(2));
app.history_up(); assert_eq!(app.input.buffer, "second");
assert_eq!(app.input.history_index, Some(1));
app.history_up(); assert_eq!(app.input.buffer, "first");
assert_eq!(app.input.history_index, Some(0));
app.history_up();
assert_eq!(app.input.buffer, "first");
assert_eq!(app.input.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.input.history_index, Some(0));
app.history_down(); assert_eq!(app.input.buffer, "bbb");
assert_eq!(app.input.history_index, Some(1));
app.history_down();
assert_eq!(app.input.buffer, "my draft");
assert_eq!(app.input.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.input.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.input.history = vec!["old".to_string()];
app.input.history_index = Some(0);
app.input.buffer = "new".to_string();
app.input.cursor = 3;
app.handle_input();
assert_eq!(app.input.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.input.history_index = Some(0);
app.apply_input_edit(KeyCode::Down);
assert_eq!(app.input.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store.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.store.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.store.conversations[conv_id].messages.len(), 100);
assert!(app.store.has_more_messages.contains(conv_id));
assert_eq!(app.store.conversations[conv_id].messages[0].body, "msg50");
assert_eq!(app.store.conversations[conv_id].messages[99].body, "msg149");
app.store.last_read_index.insert(conv_id.to_string(), 90);
app.scroll.focused_index = Some(95);
app.load_more_messages();
assert_eq!(app.store.conversations[conv_id].messages.len(), 150);
assert_eq!(app.store.conversations[conv_id].messages[0].body, "msg0");
assert_eq!(
app.store.conversations[conv_id].messages[149].body,
"msg149"
);
assert_eq!(app.store.last_read_index[conv_id], 140);
assert_eq!(app.scroll.focused_index, Some(145));
assert!(!app.store.has_more_messages.contains(conv_id));
}
#[rstest]
fn receipt_upgrades_outgoing_message_status(mut app: App) {
let conv_id = "+1";
app.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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.store.conversations[conv_id].messages[0].status,
Some(MessageStatus::Read)
);
}
#[rstest]
fn receipt_does_not_downgrade_status(mut app: App) {
let conv_id = "+1";
app.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let local_ts = 1700000000000_i64;
let server_ts = 1700000000123_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let local_ts = 1700000000000_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert_eq!(app.store.conversations["+1"].messages[0].status, None);
}
#[rstest]
fn receipt_before_send_timestamp_is_buffered_and_replayed(mut app: App) {
let conv_id = "+1";
app.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let local_ts = 1700000000000_i64;
let server_ts = 1700000000123_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.store.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.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.store.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.store.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let ts_ms = app.store.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.store.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.store.conversations["+1"].messages[0].reactions.len(), 0);
}
#[rstest]
fn handle_reaction_on_own_message(mut app: App) {
let conv_id = "+1";
app.store
.get_or_create_conversation(conv_id, "Alice", false, &app.db);
let ts_ms = 1700000000000_i64;
if let Some(conv) = app.store.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(),
body_raw: None,
mentions: 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.store.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store.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.store
.get_or_create_conversation("+1", "+1", false, &app.db);
let conv = app.store.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(),
body_raw: None,
mentions: 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(),
body_raw: None,
mentions: 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(),
body_raw: None,
mentions: 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.store.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.store
.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.store.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_reresolves_when_contact_arrives_after_message(mut app: App) {
let msg = SignalMessage {
source: "+15550001111".to_string(),
source_name: None,
source_uuid: Some("aaaaaaaa-1111-1111-1111-111111111111".to_string()),
timestamp: chrono::Utc::now(),
body: Some("hi \u{FFFC} welcome".to_string()),
attachments: vec![],
group_id: None,
group_name: None,
is_outgoing: false,
destination: None,
mentions: vec![Mention {
start: 3,
length: 1,
uuid: "bbbbbbbb-2222-2222-2222-222222222222".to_string(),
}],
text_styles: vec![],
quote: None,
expires_in_seconds: 0,
previews: Vec::new(),
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let body = &app.store.conversations["+15550001111"].messages[0].body;
assert!(
body.contains("@bbbbbbbb"),
"expected truncated UUID fallback, got {body:?}"
);
app.handle_signal_event(SignalEvent::ContactList(vec![Contact {
number: "+15550002222".to_string(),
name: Some("Bob".to_string()),
uuid: Some("bbbbbbbb-2222-2222-2222-222222222222".to_string()),
}]));
let body = &app.store.conversations["+15550001111"].messages[0].body;
assert_eq!(body, "hi @Bob welcome");
}
#[rstest]
fn mention_autocomplete_in_direct_chat(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.store
.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.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.mode, AutocompleteMode::Mention);
assert_eq!(app.autocomplete.mention_candidates.len(), 1);
assert_eq!(app.autocomplete.mention_candidates[0].1, "Alice");
}
#[rstest]
fn mention_autocomplete_in_group(mut app: App) {
app.store.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.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.contact_names
.insert("+2".to_string(), "Bob".to_string());
app.store
.get_or_create_conversation("g1", "Test Group", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.input.buffer = "@Al".to_string();
app.input.cursor = 3;
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
assert_eq!(app.autocomplete.mode, AutocompleteMode::Mention);
assert_eq!(app.autocomplete.mention_candidates.len(), 1);
assert_eq!(app.autocomplete.mention_candidates[0].1, "Alice");
}
#[rstest]
fn apply_mention_autocomplete(mut app: App) {
app.store.groups.insert(
"g1".to_string(),
Group {
id: "g1".to_string(),
name: "Test Group".to_string(),
members: vec!["+1".to_string()],
member_uuids: vec![],
},
);
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.number_to_uuid
.insert("+1".to_string(), "uuid-alice".to_string());
app.store
.get_or_create_conversation("g1", "Test Group", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.input.buffer = "Hey @Al".to_string();
app.input.cursor = 7;
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
app.apply_autocomplete();
assert_eq!(app.input.buffer, "Hey @Alice ");
assert_eq!(app.autocomplete.pending_mentions.len(), 1);
assert_eq!(app.autocomplete.pending_mentions[0].0, "Alice");
assert_eq!(
app.autocomplete.pending_mentions[0].1.as_deref(),
Some("uuid-alice")
);
}
#[rstest]
fn prepare_outgoing_mentions(mut app: App) {
app.autocomplete.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.store.uuid_to_name.get("uuid-alice").unwrap(), "Alice");
assert_eq!(app.store.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.store.groups.contains_key("g1"));
assert_eq!(app.store.groups["g1"].members.len(), 2);
}
#[rstest]
fn incoming_message_resolves_mentions(mut app: App) {
app.store
.uuid_to_name
.insert("uuid-bob".to_string(), "Bob".to_string());
let msg = SignalMessage {
source: "+1".to_string(),
source_name: Some("Alice".to_string()),
source_uuid: None,
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.store.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.is_overlay(OverlayKind::FilePicker));
assert!(app.status_message.contains("No active conversation"));
}
#[rstest]
fn clears_attachment_on_next_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.pending_attachment = Some(std::path::PathBuf::from("/tmp/photo.jpg"));
app.store
.get_or_create_conversation("+2", "Bob", false, &app.db);
app.next_conversation();
assert!(app.pending_attachment.is_none());
}
#[rstest]
fn history_browse_state_does_not_bleed_across_conv_switch(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.store
.get_or_create_conversation("+2", "Bob", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.input.history = vec!["earlier".to_string()];
app.input.buffer = "in-progress draft".to_string();
app.input.cursor = "in-progress draft".len();
app.history_up();
assert_eq!(app.input.buffer, "earlier");
assert_eq!(app.input.history_index, Some(0));
assert_eq!(app.input.history_draft, "in-progress draft");
app.next_conversation();
assert_eq!(app.active_conversation.as_deref(), Some("+2"));
assert!(app.input.buffer.is_empty(), "buffer should be cleared");
assert_eq!(app.input.cursor, 0);
assert_eq!(app.input.history_index, None);
assert_eq!(app.input.history_draft, "");
assert_eq!(
app.input.history,
vec!["earlier".to_string()],
"session history must be preserved across switches"
);
}
#[rstest]
fn spinner_pauses_during_sync(app: App) {
assert!(app.loading);
assert!(app.sync.active);
assert!(
!app.should_tick_spinner(),
"spinner must not tick while sync is active"
);
}
#[rstest]
fn spinner_ticks_after_sync_ends_but_loading_continues(mut app: App) {
app.sync.active = false;
assert!(app.loading);
assert!(app.should_tick_spinner());
}
#[rstest]
fn spinner_does_not_tick_when_loading_is_false(mut app: App) {
app.loading = false;
app.sync.active = false;
assert!(!app.should_tick_spinner());
}
#[rstest]
fn clears_attachment_on_part_command(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.is_overlay(OverlayKind::Search));
assert_eq!(app.search.query, "hello");
assert!(!app.search.results.is_empty());
assert_eq!(app.search.results[0].body, "hello world");
}
#[rstest]
fn search_without_query_shows_error(mut app: App) {
app.input.buffer = "/search".to_string();
app.input.cursor = 7;
app.handle_input();
assert!(!app.is_overlay(OverlayKind::Search));
assert!(app.status_message.contains("requires"));
}
#[rstest]
fn search_overlay_esc_closes(mut app: App) {
app.open_overlay(OverlayKind::Search);
app.search.query = "test".to_string();
app.handle_search_key(KeyCode::Esc);
assert!(!app.is_overlay(OverlayKind::Search));
assert!(app.search.query.is_empty());
}
#[rstest]
fn search_overlay_typing_refines(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.open_overlay(OverlayKind::Search);
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.store.conversations.contains_key("+15551234567"));
let conv = &app.store.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) {
app.sync.active = false;
let msg1 = SignalMessage {
source: "+15551234567".to_string(),
source_name: Some("Alice".to_string()),
timestamp: chrono::Utc::now(),
body: Some("first".to_string()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg1));
assert_eq!(app.store.conversations["+15551234567"].messages.len(), 1);
let read_idx = app
.store
.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()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
let total = app.store.conversations["+15551234567"].messages.len();
let read_idx = app.store.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()),
..Default::default()
};
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.store.conversations["+15551234567"].unread, 3);
assert_eq!(
app.store
.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.store.last_read_index["+15551234567"], 2);
assert_eq!(app.store.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()),
..Default::default()
};
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.store.last_read_index["+15551234567"], 3);
assert_eq!(app.store.conversations["+15551234567"].unread, 0);
app.handle_signal_event(SignalEvent::ReadSyncReceived {
read_messages: vec![("+15551234567".to_string(), 1000)],
});
assert_eq!(app.store.last_read_index["+15551234567"], 3);
assert_eq!(app.store.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.store.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.store.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.store
.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
.store
.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.store.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.store
.get_or_create_conversation("g1", "Family", true, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("g1", "Family", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.store.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.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.contact_names
.insert("+2".to_string(), "Bob".to_string());
app.store
.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.store
.get_or_create_conversation("g1", "Family", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.store.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.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.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.store
.get_or_create_conversation("g1", "Family", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.store.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.store
.get_or_create_conversation("g1", "Family", true, &app.db);
app.active_conversation = Some("g1".to_string());
app.store.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.store
.get_or_create_conversation("g1", "Old Name", true, &app.db);
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,
source_uuid: 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.store.conversations["+1"].accepted);
}
#[rstest]
fn known_contact_creates_accepted_conversation(mut app: App) {
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(app.store.conversations["+1"].accepted);
}
#[test]
fn action_menu_hint_roundtrips_through_char() {
let all = [
ActionMenuHint::Reply,
ActionMenuHint::Edit,
ActionMenuHint::React,
ActionMenuHint::Forward,
ActionMenuHint::Copy,
ActionMenuHint::Delete,
ActionMenuHint::PinToggle,
ActionMenuHint::Vote,
ActionMenuHint::EndPoll,
ActionMenuHint::OpenAttachment,
ActionMenuHint::OpenLink,
];
for hint in all {
let c = hint.key_char();
assert_eq!(
ActionMenuHint::from_char(c),
Some(hint),
"round-trip failed for {hint:?} (char {c})"
);
}
let chars: Vec<char> = all.iter().map(|h| h.key_char()).collect();
let unique: std::collections::HashSet<&char> = chars.iter().collect();
assert_eq!(
unique.len(),
chars.len(),
"ActionMenuHint variants must map to distinct keys; got {chars:?}"
);
assert_eq!(ActionMenuHint::from_char('z'), None);
assert_eq!(ActionMenuHint::from_char(' '), None);
}
#[rstest]
fn unknown_sender_with_source_name_creates_accepted_conversation(mut app: App) {
let mut msg = msg_from("+1");
msg.source_name = Some("Alice".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(
app.store.conversations["+1"].accepted,
"sender announced their name in the envelope — conversation should not be a message request"
);
}
#[rstest]
fn outgoing_sync_creates_accepted_conversation(mut app: App) {
let msg = SignalMessage {
source: "+10000000000".to_string(),
timestamp: chrono::Utc::now(),
body: Some("hey".to_string()),
is_outgoing: true,
destination: Some("+1".to_string()),
..Default::default()
};
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.store.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.store.conversations["+1"].accepted);
app.handle_signal_event(SignalEvent::ContactList(vec![Contact {
number: "+1".to_string(),
name: Some("Alice".to_string()),
uuid: None,
}]));
assert!(app.store.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.open_overlay(OverlayKind::MessageRequest);
let req = app.handle_message_request_key(KeyCode::Char('a'));
assert!(app.store.conversations["+1"].accepted);
assert!(!app.is_overlay(OverlayKind::MessageRequest));
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.scroll.positions.insert("+1".to_string(), (3, Some(0)));
app.store.last_read_index.insert("+1".to_string(), 1);
app.store.has_more_messages.insert("+1".to_string());
app.muted_conversations
.insert("+1".to_string(), MuteState::Permanent);
app.blocked_conversations.insert("+1".to_string());
app.open_overlay(OverlayKind::MessageRequest);
let req = app.handle_message_request_key(KeyCode::Char('d'));
assert!(!app.store.conversations.contains_key("+1"));
assert!(!app.store.conversation_order.contains(&"+1".to_string()));
assert!(!app.scroll.positions.contains_key("+1"));
assert!(!app.store.last_read_index.contains_key("+1"));
assert!(!app.store.has_more_messages.contains("+1"));
assert!(!app.muted_conversations.contains_key("+1"));
assert!(!app.blocked_conversations.contains("+1"));
assert!(app.active_conversation.is_none());
assert!(!app.is_overlay(OverlayKind::MessageRequest));
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.open_overlay(OverlayKind::MessageRequest);
let req = app.handle_message_request_key(KeyCode::Esc);
assert!(req.is_none());
assert!(!app.is_overlay(OverlayKind::MessageRequest));
assert!(app.active_conversation.is_none());
}
#[rstest]
fn slash_delete_opens_confirmation_overlay(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.input.buffer = "/delete".to_string();
app.input.cursor = 7;
let req = app.handle_input();
assert!(req.is_none());
assert!(app.is_overlay(OverlayKind::DeleteConversationConfirm));
assert!(app.store.conversations.contains_key("+1"));
assert_eq!(app.active_conversation.as_deref(), Some("+1"));
}
#[rstest]
fn slash_delete_without_active_conversation_sets_error(mut app: App) {
app.input.buffer = "/delete".to_string();
app.input.cursor = 7;
let req = app.handle_input();
assert!(req.is_none());
assert!(!app.is_overlay(OverlayKind::DeleteConversationConfirm));
assert!(app.status_message.contains("No active conversation"));
}
#[rstest]
fn delete_confirm_yes_removes_accepted_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.scroll.positions.insert("+1".to_string(), (3, Some(0)));
app.store.last_read_index.insert("+1".to_string(), 1);
app.open_overlay(OverlayKind::DeleteConversationConfirm);
let (handled, req) = app.handle_overlay_key(KeyCode::Char('y'));
assert!(handled);
assert!(req.is_none());
assert!(!app.is_overlay(OverlayKind::DeleteConversationConfirm));
assert!(!app.store.conversations.contains_key("+1"));
assert!(!app.scroll.positions.contains_key("+1"));
assert!(!app.store.last_read_index.contains_key("+1"));
assert!(app.active_conversation.is_none());
}
#[rstest]
fn delete_confirm_yes_on_unaccepted_returns_remote_delete(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+15550007777")));
assert!(!app.store.conversations["+15550007777"].accepted);
app.active_conversation = Some("+15550007777".to_string());
app.open_overlay(OverlayKind::DeleteConversationConfirm);
let (_, req) = app.handle_overlay_key(KeyCode::Char('y'));
let Some(SendRequest::MessageRequestResponse {
recipient,
is_group,
response_type,
}) = req
else {
panic!("expected MessageRequestResponse(delete) for unaccepted conversation");
};
assert_eq!(recipient, "+15550007777");
assert!(!is_group);
assert_eq!(response_type, "delete");
assert!(!app.store.conversations.contains_key("+15550007777"));
assert!(app.active_conversation.is_none());
}
#[rstest]
fn delete_confirm_no_keeps_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.open_overlay(OverlayKind::DeleteConversationConfirm);
let (handled, req) = app.handle_overlay_key(KeyCode::Char('n'));
assert!(handled);
assert!(req.is_none());
assert!(!app.is_overlay(OverlayKind::DeleteConversationConfirm));
assert!(app.store.conversations.contains_key("+1"));
assert_eq!(app.active_conversation.as_deref(), Some("+1"));
}
#[rstest]
fn delete_confirm_esc_keeps_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.open_overlay(OverlayKind::DeleteConversationConfirm);
let (_, req) = app.handle_overlay_key(KeyCode::Esc);
assert!(req.is_none());
assert!(!app.is_overlay(OverlayKind::DeleteConversationConfirm));
assert!(app.store.conversations.contains_key("+1"));
}
#[rstest]
fn update_status_does_not_clobber_active_app_overlay(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+unaccepted")));
app.open_overlay(OverlayKind::Settings);
app.active_conversation = Some("+unaccepted".to_string());
app.update_status();
assert_eq!(
app.active_overlay(),
Some(OverlayKind::Settings),
"Settings overlay must not be clobbered by MessageRequest"
);
}
#[rstest]
fn autocomplete_esc_closes_overlay(mut app: App) {
app.mode = InputMode::Insert;
app.input.buffer = "/j".to_string();
app.input.cursor = 2;
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
app.handle_autocomplete_key(KeyCode::Esc);
assert!(
!app.is_overlay(OverlayKind::Autocomplete),
"Esc should close the autocomplete overlay"
);
}
#[rstest]
fn autocomplete_no_match_closes_overlay(mut app: App) {
app.mode = InputMode::Insert;
app.input.buffer = "/j".to_string();
app.input.cursor = 2;
app.update_autocomplete();
assert!(app.is_overlay(OverlayKind::Autocomplete));
app.input.buffer = "/zzznothingmatches".to_string();
app.input.cursor = app.input.buffer.len();
app.update_autocomplete();
assert!(
!app.is_overlay(OverlayKind::Autocomplete),
"no-match autocomplete refresh should close the overlay"
);
}
#[rstest]
fn bell_skipped_for_unaccepted_conversation(mut app: App) {
app.handle_signal_event(SignalEvent::MessageReceived(msg_from("+1")));
assert!(!app.notifications.pending_bell);
}
#[rstest]
fn bell_skipped_for_blocked_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
if let Some(conv) = app.store.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.notifications.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
if let Some(conv) = app.store.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.open_overlay(OverlayKind::Settings);
app.settings_overlay.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_overlay.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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.store
.get_or_create_conversation("+2", "Bob", false, &app.db);
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); }
fn toggle_overlay(app: &mut App, kind: OverlayKind, on: bool) {
if on {
app.open_overlay(kind);
} else if app.is_overlay(kind) {
app.close_overlay();
}
if matches!(kind, OverlayKind::GroupMenu) {
app.group_menu.state = if on { Some(GroupMenuState::Menu) } else { None };
}
}
const ALL_OVERLAYS: &[OverlayKind] = &[
OverlayKind::SidebarFilter,
OverlayKind::PollVote,
OverlayKind::PinDuration,
OverlayKind::ActionMenu,
OverlayKind::DeleteConfirm,
OverlayKind::FilePicker,
OverlayKind::EmojiPicker,
OverlayKind::ReactionPicker,
OverlayKind::MessageRequest,
OverlayKind::GroupMenu,
OverlayKind::About,
OverlayKind::Profile,
OverlayKind::Help,
OverlayKind::Verify,
OverlayKind::Forward,
OverlayKind::Contacts,
OverlayKind::Search,
OverlayKind::SettingsProfiles,
OverlayKind::ThemePicker,
OverlayKind::Keybindings,
OverlayKind::Customize,
OverlayKind::Settings,
OverlayKind::Autocomplete,
];
#[rstest]
fn active_overlay_covers_every_variant(mut app: App) {
assert_eq!(
ALL_OVERLAYS.len(),
23,
"ALL_OVERLAYS is out of sync with OverlayKind - update when adding or removing a variant"
);
assert_eq!(app.active_overlay(), None);
assert!(!app.has_overlay());
for &kind in ALL_OVERLAYS {
toggle_overlay(&mut app, kind, true);
assert_eq!(
app.active_overlay(),
Some(kind),
"active_overlay did not match after enabling {kind:?}"
);
assert!(app.has_overlay(), "has_overlay returned false for {kind:?}");
toggle_overlay(&mut app, kind, false);
}
assert_eq!(app.active_overlay(), None);
assert!(!app.has_overlay());
}
#[rstest]
fn gg_scrolls_to_top(mut app: App) {
for i in 0..20 {
let msg = make_msg("+1", Some(&format!("msg {i}")), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
}
app.active_conversation = Some("+1".to_string());
app.scroll.offset = 0;
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('g'));
assert_eq!(app.pending_normal_key, Some('g'));
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('g'));
assert_eq!(app.pending_normal_key, None);
assert_eq!(app.scroll.offset, 20);
}
#[rstest]
fn dd_shows_delete_confirm(mut app: App) {
let msg = make_msg("+1", Some("hello"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('d'));
assert_eq!(app.pending_normal_key, Some('d'));
assert!(!app.is_overlay(OverlayKind::DeleteConfirm));
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('d'));
assert_eq!(app.pending_normal_key, None);
assert!(app.is_overlay(OverlayKind::DeleteConfirm));
}
#[rstest]
fn pending_key_cancelled_by_esc(mut app: App) {
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('g'));
assert_eq!(app.pending_normal_key, Some('g'));
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Esc);
assert_eq!(app.pending_normal_key, None);
}
#[rstest]
fn pending_key_discarded_on_other_key(mut app: App) {
let msg = make_msg("+1", Some("hello"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('g'));
assert_eq!(app.pending_normal_key, Some('g'));
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('j'));
assert_eq!(app.pending_normal_key, None);
}
#[rstest]
fn o_preserves_composer_buffer(mut app: App) {
app.mode = InputMode::Normal;
app.input.buffer = "hello world".to_string();
app.input.cursor = 5;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('o'));
assert_eq!(app.mode, InputMode::Insert);
assert_eq!(app.input.buffer, "hello world\n");
assert_eq!(app.input.cursor, 12);
}
#[rstest]
fn jk_focus_messages(mut app: App) {
for i in 0..5 {
let msg = make_msg("+1", Some(&format!("msg {i}")), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
}
app.active_conversation = Some("+1".to_string());
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('k'));
assert!(app.scroll.focused_index.is_some());
}
#[rstest]
fn pending_key_cleared_on_mode_transition(mut app: App) {
app.mode = InputMode::Normal;
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('g'));
assert_eq!(app.pending_normal_key, Some('g'));
app.handle_normal_key(KeyModifiers::NONE, KeyCode::Char('i'));
assert_eq!(app.pending_normal_key, None);
assert_eq!(app.mode, InputMode::Insert);
}
#[rstest]
fn ctrl_e_scrolls_without_focus(mut app: App) {
for i in 0..20 {
let msg = make_msg("+1", Some(&format!("msg {i}")), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
}
app.active_conversation = Some("+1".to_string());
app.mode = InputMode::Normal;
app.scroll.offset = 5;
app.scroll.focused_index = Some(10);
app.handle_normal_key(KeyModifiers::CONTROL, KeyCode::Char('e'));
assert_eq!(app.scroll.offset, 4);
assert_eq!(app.scroll.focused_index, None);
}
fn make_msg(
source: &str,
body: Option<&str>,
group_id: Option<&str>,
is_outgoing: bool,
) -> SignalMessage {
SignalMessage {
source: source.to_string(),
source_name: None,
source_uuid: 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(),
}
}
fn make_msg_with_ts(
source: &str,
body: Option<&str>,
group_id: Option<&str>,
is_outgoing: bool,
ts_ms: i64,
) -> SignalMessage {
let mut m = make_msg(source, body, group_id, is_outgoing);
m.timestamp = chrono::DateTime::from_timestamp_millis(ts_ms).unwrap();
m
}
#[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.store.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.store.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.store.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.store.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.store.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.sync.active = false;
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.get_or_create_conversation("+other", "Other", false, &app.db);
app.active_conversation = Some("+other".to_string());
app.notifications.notify_direct = true;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.notifications.pending_bell);
}
#[rstest]
fn bell_not_set_for_active_conversation(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.notifications.notify_direct = true;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.notifications.pending_bell);
}
#[rstest]
fn bell_skipped_when_notify_disabled(mut app: App) {
app.store
.get_or_create_conversation("+other", "Other", false, &app.db);
app.active_conversation = Some("+other".to_string());
app.notifications.notify_direct = false;
let msg = make_msg("+1", Some("hey"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.notifications.pending_bell);
}
#[rstest]
fn bell_for_group_respects_setting(mut app: App) {
app.sync.active = false;
app.handle_signal_event(SignalEvent::GroupList(vec![Group {
id: "g1".to_string(),
name: "Team".to_string(),
members: vec![],
member_uuids: vec![],
}]));
app.store
.get_or_create_conversation("+other", "Other", false, &app.db);
app.active_conversation = Some("+other".to_string());
app.notifications.notify_group = true;
let msg = make_msg("+1", Some("hi team"), Some("g1"), false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.notifications.pending_bell);
app.notifications.pending_bell = false;
app.notifications.notify_group = false;
let msg2 = make_msg("+2", Some("again"), Some("g1"), false);
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
assert!(!app.notifications.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.store.conversations["+1"].unread, 1);
}
#[rstest]
fn unread_no_increment_for_active(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store.conversations["+1"].unread, 0);
}
#[rstest]
fn active_conv_queues_read_receipt(mut app: App) {
app.sync.active = false;
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
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.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
assert_eq!(app.store.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.store.conversations["+1"].expiration_timer, 3600);
}
#[rstest]
fn paste_text_inserts_into_composer(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!(app.typing.indicators["group-a"].contains_key("+1"));
}
#[rstest]
fn group_typing_does_not_bleed_into_other_group(mut app: App) {
app.store
.get_or_create_conversation("group-a", "Group A", true, &app.db);
app.store
.get_or_create_conversation("group-b", "Group B", true, &app.db);
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"
);
}
#[rstest]
fn concurrent_typers_in_group(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()),
});
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+2".to_string(),
sender_name: Some("Bob".to_string()),
is_typing: true,
group_id: Some("group-a".to_string()),
});
let senders = &app.typing.indicators["group-a"];
assert_eq!(senders.len(), 2);
assert!(senders.contains_key("+1"));
assert!(senders.contains_key("+2"));
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+1".to_string(),
sender_name: None,
is_typing: false,
group_id: Some("group-a".to_string()),
});
let senders = &app.typing.indicators["group-a"];
assert_eq!(senders.len(), 1);
assert!(senders.contains_key("+2"));
app.handle_signal_event(SignalEvent::TypingIndicator {
sender: "+2".to_string(),
sender_name: None,
is_typing: false,
group_id: Some("group-a".to_string()),
});
assert!(!app.typing.indicators.contains_key("group-a"));
}
#[test]
fn is_stale_filters_correctly() {
let empty_group = Conversation {
name: "abc123groupid".to_string(),
id: "abc123groupid".to_string(),
messages: vec![],
unread: 0,
is_group: true,
expiration_timer: 0,
accepted: true,
};
assert!(
empty_group.is_stale(),
"group with no messages and name==id is stale"
);
let named_group = Conversation {
name: "Book Club".to_string(),
id: "abc123groupid".to_string(),
messages: vec![],
unread: 0,
is_group: true,
expiration_timer: 0,
accepted: true,
};
assert!(
!named_group.is_stale(),
"group with a real name is not stale"
);
let phone_contact = Conversation {
name: "+15551234567".to_string(),
id: "+15551234567".to_string(),
messages: vec![],
unread: 0,
is_group: false,
expiration_timer: 0,
accepted: true,
};
assert!(
!phone_contact.is_stale(),
"contact with phone number is not stale"
);
let uuid_contact = Conversation {
name: "8eb3dbda-1234-5678".to_string(),
id: "8eb3dbda-1234-5678".to_string(),
messages: vec![],
unread: 0,
is_group: false,
expiration_timer: 0,
accepted: true,
};
assert!(
uuid_contact.is_stale(),
"contact with UUID-only name is stale"
);
}
#[test]
fn extract_file_uri_from_image_body() {
let body = "[image: photo.jpg](file:///home/user/photo.jpg)";
assert_eq!(
extract_file_uri(body),
Some("file:///home/user/photo.jpg".to_string())
);
}
#[test]
fn extract_file_uri_from_attachment_body() {
let body = "[attachment: doc.pdf](file:///home/user/doc.pdf)";
assert_eq!(
extract_file_uri(body),
Some("file:///home/user/doc.pdf".to_string())
);
}
#[test]
fn extract_file_uri_none_for_plain_text() {
assert_eq!(extract_file_uri("hello world"), None);
}
#[test]
fn extract_http_url_from_body() {
let body = "check this out https://example.com/page and more text";
assert_eq!(
extract_http_url(body),
Some("https://example.com/page".to_string())
);
}
#[test]
fn extract_http_url_skips_file_uri() {
let body = "[image: photo.jpg](file:///home/user/photo.jpg) see https://example.com";
assert_eq!(
extract_http_url(body),
Some("https://example.com".to_string())
);
}
#[test]
fn extract_http_url_none_for_plain_text() {
assert_eq!(extract_http_url("hello world"), None);
}
#[test]
fn extract_http_url_with_trailing_paren() {
let body = "link (https://example.com/path) here";
assert_eq!(
extract_http_url(body),
Some("https://example.com/path".to_string())
);
}
#[rstest]
fn action_menu_shows_open_attachment(mut app: App) {
let mut msg = make_msg("+1", None, None, false);
msg.attachments = vec![Attachment {
id: "123".to_string(),
content_type: "application/pdf".to_string(),
filename: Some("doc.pdf".to_string()),
local_path: Some("/tmp/doc.pdf".to_string()),
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
let items = app.action_menu_items();
assert!(
items.iter().any(|a| a.label == "Open attachment"),
"expected Open attachment in menu"
);
}
#[rstest]
fn action_menu_shows_open_link(mut app: App) {
let msg = make_msg("+1", Some("check https://example.com"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
let items = app.action_menu_items();
assert!(
items.iter().any(|a| a.label == "Open link"),
"expected Open link in menu"
);
}
#[rstest]
fn action_menu_shows_both_open_items(mut app: App) {
let mut msg = make_msg("+1", Some("see https://example.com"), None, false);
msg.attachments = vec![Attachment {
id: "456".to_string(),
content_type: "image/png".to_string(),
filename: Some("photo.png".to_string()),
local_path: Some("/tmp/photo.png".to_string()),
}];
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
app.scroll.focused_index = Some(0);
let items_body = app.action_menu_items();
assert!(
items_body.iter().any(|a| a.label == "Open link"),
"expected Open link for body message"
);
app.scroll.focused_index = Some(1);
let items_att = app.action_menu_items();
assert!(
items_att.iter().any(|a| a.label == "Open attachment"),
"expected Open attachment for attachment message"
);
}
#[rstest]
fn action_menu_no_open_for_plain_text(mut app: App) {
let msg = make_msg("+1", Some("just a regular message"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
app.active_conversation = Some("+1".to_string());
let items = app.action_menu_items();
assert!(
!items.iter().any(|a| a.label == "Open attachment"),
"should not have Open attachment"
);
assert!(
!items.iter().any(|a| a.label == "Open link"),
"should not have Open link"
);
}
#[rstest]
fn action_menu_respects_focused_index(mut app: App) {
let msg1 = make_msg("+1", Some("check https://example.com"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg1));
let msg2 = make_msg("+1", Some("just text"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
app.active_conversation = Some("+1".to_string());
let items = app.action_menu_items();
assert!(
!items.iter().any(|a| a.label == "Open link"),
"last msg has no URL"
);
app.scroll.focused_index = Some(0);
let items = app.action_menu_items();
assert!(
items.iter().any(|a| a.label == "Open link"),
"focused msg has URL, should show Open link"
);
}
#[rstest]
fn sync_starts_active(app: App) {
assert!(app.sync.active);
assert_eq!(app.sync.message_count, 0);
assert!(!app.sync.user_scrolled);
}
#[rstest]
fn sync_should_end_requires_quiet_and_min_elapsed(mut app: App) {
assert!(!app.sync.should_end());
app.sync.started_at = Instant::now() - std::time::Duration::from_secs(15);
assert!(app.sync.should_end());
app.sync.last_message_time = Some(Instant::now());
assert!(!app.sync.should_end());
app.sync.last_message_time = Some(Instant::now() - std::time::Duration::from_secs(5));
assert!(app.sync.should_end());
}
#[rstest]
fn sync_suppresses_notifications(mut app: App) {
assert!(app.sync.active);
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.get_or_create_conversation("+other", "Other", false, &app.db);
app.active_conversation = Some("+other".to_string());
app.notifications.notify_direct = true;
let msg = make_msg("+1", Some("hello"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(!app.notifications.pending_bell);
assert_eq!(
app.sync
.suppressed_notifications
.get("+1")
.copied()
.unwrap_or(0),
1
);
assert!(app.sync.message_count > 0);
}
#[rstest]
fn notifications_fire_after_sync_ends(mut app: App) {
app.sync.active = false;
app.store
.contact_names
.insert("+1".to_string(), "Alice".to_string());
app.store
.get_or_create_conversation("+other", "Other", false, &app.db);
app.active_conversation = Some("+other".to_string());
app.notifications.notify_direct = true;
let msg = make_msg("+1", Some("hello"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.notifications.pending_bell);
}
#[rstest]
fn sync_captures_pin_on_first_message(mut app: App) {
assert!(app.sync.active);
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
let prior = make_msg_with_ts("+1", Some("prior message"), None, false, 1000);
app.handle_signal_event(SignalEvent::MessageReceived(prior));
let pinned_ts = app.store.conversations["+1"]
.messages
.last()
.unwrap()
.timestamp;
app.scroll.offset = 4;
let msg = make_msg_with_ts("+1", Some("first sync msg"), None, false, 2000);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let (pin_ts, pin_offset) = app
.sync
.pin
.expect("pin should be captured on first sync msg");
assert_eq!(pin_ts, pinned_ts);
assert_eq!(pin_offset, 4);
}
#[rstest]
fn sync_pin_only_captured_once(mut app: App) {
assert!(app.sync.active);
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
let prior = make_msg_with_ts("+1", Some("prior"), None, false, 1000);
app.handle_signal_event(SignalEvent::MessageReceived(prior));
let pinned_ts = app.store.conversations["+1"]
.messages
.last()
.unwrap()
.timestamp;
let msg1 = make_msg_with_ts("+1", Some("first"), None, false, 2000);
app.handle_signal_event(SignalEvent::MessageReceived(msg1));
let msg2 = make_msg_with_ts("+1", Some("second"), None, false, 3000);
app.handle_signal_event(SignalEvent::MessageReceived(msg2));
let (pin_ts, _) = app.sync.pin.expect("pin should still be set");
assert_eq!(
pin_ts, pinned_ts,
"pin must still anchor to the original message"
);
}
#[rstest]
fn sync_does_not_capture_pin_after_user_scroll(mut app: App) {
assert!(app.sync.active);
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
let prior = make_msg_with_ts("+1", Some("prior"), None, false, 1000);
app.handle_signal_event(SignalEvent::MessageReceived(prior));
app.sync.user_scrolled = true;
let msg = make_msg_with_ts("+1", Some("sync"), None, false, 2000);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(app.sync.pin.is_none());
assert_eq!(app.scroll.offset, 0);
}
#[rstest]
fn sync_pin_skips_non_active_conversation(mut app: App) {
assert!(app.sync.active);
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.store
.get_or_create_conversation("+2", "Bob", false, &app.db);
app.active_conversation = Some("+1".to_string());
let prior_other = make_msg_with_ts("+2", Some("prior"), None, false, 1000);
app.handle_signal_event(SignalEvent::MessageReceived(prior_other));
let msg = make_msg_with_ts("+2", Some("from non-active"), None, false, 2000);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
assert!(
app.sync.pin.is_none(),
"sync messages for the non-active conversation must not capture pin"
);
}
#[rstest]
fn sync_pin_cleared_on_end_sync(mut app: App) {
app.sync.pin = Some((Utc::now(), 3));
app.end_sync();
assert!(app.sync.pin.is_none());
}
#[rstest]
fn sync_pin_cleared_on_conv_switch(mut app: App) {
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.store
.get_or_create_conversation("+2", "Bob", false, &app.db);
app.active_conversation = Some("+1".to_string());
app.sync.pin = Some((Utc::now(), 2));
app.next_conversation();
assert!(
app.sync.pin.is_none(),
"switching conversations must clear the pin so it doesn't apply to the new conv"
);
}
#[rstest]
fn sync_does_not_advance_read_index_for_active_conv(mut app: App) {
assert!(app.sync.active);
app.store
.get_or_create_conversation("+1", "Alice", false, &app.db);
app.active_conversation = Some("+1".to_string());
let initial_read = app.store.last_read_index.get("+1").copied().unwrap_or(0);
let msg = make_msg("+1", Some("hello"), None, false);
app.handle_signal_event(SignalEvent::MessageReceived(msg));
let after_read = app.store.last_read_index.get("+1").copied().unwrap_or(0);
assert_eq!(
initial_read, after_read,
"read index should not advance during sync"
);
}
#[rstest]
fn end_sync_snaps_to_bottom_and_fires_bell(mut app: App) {
app.sync.active = true;
app.sync.message_count = 50;
app.scroll.offset = 30;
app.sync
.suppressed_notifications
.insert("+1".to_string(), 10);
app.sync
.suppressed_notifications
.insert("+2".to_string(), 5);
app.end_sync();
assert!(!app.sync.active);
assert_eq!(app.scroll.offset, 0);
assert!(app.notifications.pending_bell);
assert!(app.sync.suppressed_notifications.is_empty());
}
#[rstest]
fn end_sync_respects_user_scroll(mut app: App) {
app.sync.active = true;
app.scroll.offset = 15;
app.sync.user_scrolled = true;
app.end_sync();
assert!(!app.sync.active);
assert_eq!(app.scroll.offset, 15);
}
#[rstest]
fn end_sync_no_bell_when_no_suppressed(mut app: App) {
app.sync.active = true;
app.sync.message_count = 5;
app.end_sync();
assert!(!app.sync.active);
assert!(!app.notifications.pending_bell);
}
fn seed_conv_with_msg(app: &mut App, source: &str, body: &str, ts_ms: i64) -> String {
let mut m = make_msg_with_ts(source, Some(body), None, false, ts_ms);
m.source_name = Some("Alice".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(m));
source.to_string()
}
#[rstest]
fn edit_received_updates_body_and_marks_edited(mut app: App) {
let ts = 1_700_000_000_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "first", ts);
app.handle_signal_event(SignalEvent::EditReceived {
conv_id: conv_id.clone(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_timestamp: ts,
new_body: "edited body".to_string(),
new_timestamp: ts + 1,
is_outgoing: false,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
assert_eq!(conv.messages[idx].body, "edited body");
assert!(conv.messages[idx].is_edited);
}
#[rstest]
fn remote_delete_received_clears_body_and_reactions(mut app: App) {
let ts = 1_700_000_001_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "first", ts);
app.handle_signal_event(SignalEvent::ReactionReceived {
conv_id: conv_id.clone(),
emoji: "👍".to_string(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts,
is_remove: false,
});
app.handle_signal_event(SignalEvent::RemoteDeleteReceived {
conv_id: conv_id.clone(),
sender: "+1".to_string(),
target_timestamp: ts,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
assert!(conv.messages[idx].is_deleted);
assert_eq!(conv.messages[idx].body, "[deleted]");
assert!(conv.messages[idx].reactions.is_empty());
}
#[rstest]
fn pin_received_sets_pinned_and_inserts_system_message(mut app: App) {
let ts = 1_700_000_002_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "first", ts);
let before = app.store.conversations[&conv_id].messages.len();
app.handle_signal_event(SignalEvent::PinReceived {
conv_id: conv_id.clone(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
assert!(conv.messages[idx].is_pinned);
assert_eq!(conv.messages.len(), before + 1, "system message inserted");
let last = conv.messages.last().unwrap();
assert!(last.is_system);
assert!(last.body.contains("pinned"));
}
#[rstest]
fn unpin_received_clears_pinned(mut app: App) {
let ts = 1_700_000_003_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "first", ts);
app.handle_signal_event(SignalEvent::PinReceived {
conv_id: conv_id.clone(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts,
});
assert!(
app.store.conversations[&conv_id]
.find_msg_idx(ts)
.map(|i| app.store.conversations[&conv_id].messages[i].is_pinned)
.unwrap_or(false)
);
app.handle_signal_event(SignalEvent::UnpinReceived {
conv_id: conv_id.clone(),
sender: "+1".to_string(),
sender_name: Some("Alice".to_string()),
target_author: "+1".to_string(),
target_timestamp: ts,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
assert!(!conv.messages[idx].is_pinned);
let last = conv.messages.last().unwrap();
assert!(last.is_system);
assert!(last.body.contains("unpinned"));
}
fn sample_poll() -> PollData {
PollData {
question: "tabs or spaces?".to_string(),
options: vec![
PollOption {
id: 0,
text: "tabs".to_string(),
},
PollOption {
id: 1,
text: "spaces".to_string(),
},
],
allow_multiple: false,
closed: false,
}
}
#[rstest]
fn poll_created_attaches_to_existing_message(mut app: App) {
let ts = 1_700_000_010_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "vote!", ts);
app.handle_signal_event(SignalEvent::PollCreated {
conv_id: conv_id.clone(),
timestamp: ts,
poll_data: sample_poll(),
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
let poll = conv.messages[idx]
.poll_data
.as_ref()
.expect("poll attached");
assert_eq!(poll.question, "tabs or spaces?");
}
#[rstest]
fn poll_created_before_message_buffers_then_attaches_on_arrival(mut app: App) {
let ts = 1_700_000_011_000;
seed_conv_with_msg(&mut app, "+1", "ignore", ts - 1000);
app.handle_signal_event(SignalEvent::PollCreated {
conv_id: "+1".to_string(),
timestamp: ts,
poll_data: sample_poll(),
});
assert!(
app.poll_vote
.pending_polls
.contains_key(&("+1".to_string(), ts))
);
let m = make_msg_with_ts("+1", Some("vote!"), None, false, ts);
app.handle_signal_event(SignalEvent::MessageReceived(m));
let conv = &app.store.conversations["+1"];
let idx = conv.find_msg_idx(ts).expect("message present");
let poll = conv.messages[idx]
.poll_data
.as_ref()
.expect("poll attached on arrival");
assert_eq!(poll.question, "tabs or spaces?");
assert!(
!app.poll_vote
.pending_polls
.contains_key(&("+1".to_string(), ts))
);
}
#[rstest]
fn poll_vote_received_upserts_vote(mut app: App) {
let ts = 1_700_000_012_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "vote!", ts);
app.handle_signal_event(SignalEvent::PollCreated {
conv_id: conv_id.clone(),
timestamp: ts,
poll_data: sample_poll(),
});
app.handle_signal_event(SignalEvent::PollVoteReceived {
conv_id: conv_id.clone(),
target_timestamp: ts,
voter: "+2".to_string(),
voter_name: Some("Bob".to_string()),
option_indexes: vec![0],
vote_count: 1,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
let votes = &conv.messages[idx].poll_votes;
assert_eq!(votes.len(), 1);
assert_eq!(votes[0].voter, "+2");
assert_eq!(votes[0].option_indexes, vec![0]);
app.handle_signal_event(SignalEvent::PollVoteReceived {
conv_id: conv_id.clone(),
target_timestamp: ts,
voter: "+2".to_string(),
voter_name: Some("Bob".to_string()),
option_indexes: vec![1],
vote_count: 2,
});
let conv = &app.store.conversations[&conv_id];
let votes = &conv.messages[conv.find_msg_idx(ts).unwrap()].poll_votes;
assert_eq!(votes.len(), 1, "vote upserted, not duplicated");
assert_eq!(votes[0].option_indexes, vec![1]);
}
#[rstest]
fn poll_terminated_marks_closed(mut app: App) {
let ts = 1_700_000_013_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "vote!", ts);
app.handle_signal_event(SignalEvent::PollCreated {
conv_id: conv_id.clone(),
timestamp: ts,
poll_data: sample_poll(),
});
app.handle_signal_event(SignalEvent::PollTerminated {
conv_id: conv_id.clone(),
target_timestamp: ts,
});
let conv = &app.store.conversations[&conv_id];
let idx = conv.find_msg_idx(ts).expect("message present");
let poll = conv.messages[idx]
.poll_data
.as_ref()
.expect("poll attached");
assert!(poll.closed);
}
#[rstest]
fn identity_list_populates_trust_map(mut app: App) {
app.handle_signal_event(SignalEvent::IdentityList(vec![
IdentityInfo {
number: Some("+1".to_string()),
uuid: None,
fingerprint: "fp1".to_string(),
safety_number: "sn1".to_string(),
trust_level: TrustLevel::TrustedVerified,
added_timestamp: 0,
},
IdentityInfo {
number: Some("+2".to_string()),
uuid: None,
fingerprint: "fp2".to_string(),
safety_number: "sn2".to_string(),
trust_level: TrustLevel::Untrusted,
added_timestamp: 0,
},
]));
assert_eq!(
app.identity_trust.get("+1").copied(),
Some(TrustLevel::TrustedVerified)
);
assert_eq!(
app.identity_trust.get("+2").copied(),
Some(TrustLevel::Untrusted)
);
}
#[rstest]
fn expiration_timer_changed_updates_conv_and_inserts_system_message(mut app: App) {
let ts = 1_700_000_020_000;
let conv_id = seed_conv_with_msg(&mut app, "+1", "first", ts);
let before = app.store.conversations[&conv_id].messages.len();
let later = chrono::DateTime::from_timestamp_millis(ts + 1000).unwrap();
app.handle_signal_event(SignalEvent::ExpirationTimerChanged {
conv_id: conv_id.clone(),
seconds: 300,
body: "Alice set the timer to 5 minutes".to_string(),
timestamp: later,
timestamp_ms: ts + 1000,
});
let conv = &app.store.conversations[&conv_id];
assert_eq!(conv.expiration_timer, 300);
assert_eq!(conv.messages.len(), before + 1, "system message inserted");
let last = conv.messages.last().unwrap();
assert!(last.is_system);
assert!(last.body.contains("5 minutes"));
}
#[rstest]
fn receipt_viewed_upgrades_outgoing_status(mut app: App) {
let ts = 1_700_000_030_000;
let mut m = make_msg_with_ts("+10000000000", Some("hi"), None, true, ts);
m.destination = Some("+1".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(m));
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: "+1".to_string(),
receipt_type: "VIEWED".to_string(),
timestamps: vec![ts],
});
let conv = &app.store.conversations["+1"];
let idx = conv.find_msg_idx(ts).expect("outgoing message present");
assert_eq!(conv.messages[idx].status, Some(MessageStatus::Viewed));
}
#[rstest]
fn receipt_falls_back_to_group_scan_when_sender_is_not_conv_id(mut app: App) {
let ts = 1_700_000_031_000;
let mut m = make_msg_with_ts("+10000000000", Some("hey"), Some("group_a"), true, ts);
m.destination = Some("group_a".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(m));
app.handle_signal_event(SignalEvent::ReceiptReceived {
sender: "+2".to_string(),
receipt_type: "READ".to_string(),
timestamps: vec![ts],
});
let conv = &app.store.conversations["group_a"];
let idx = conv.find_msg_idx(ts).expect("group message present");
assert_eq!(conv.messages[idx].status, Some(MessageStatus::Read));
}
#[rstest]
fn wire_quote_attached_to_body_row_not_attachment_row(mut app: App) {
let ts = 1_700_000_040_000;
let quote_ts = ts - 1000;
let mut m = make_msg_with_ts("+1", Some("see this"), None, false, ts);
m.source_name = Some("Alice".to_string());
m.quote = Some((quote_ts, "+2".to_string(), "original".to_string()));
m.attachments = vec![Attachment {
id: "att1".to_string(),
content_type: "image/png".to_string(),
filename: Some("pic.png".to_string()),
local_path: None,
}];
app.handle_signal_event(SignalEvent::MessageReceived(m));
let rows = app
.db
.load_messages_page("+1", 100, 0)
.expect("DB query succeeds");
assert_eq!(rows.len(), 2, "body + one attachment row");
let body_row = rows
.iter()
.find(|r| !r.body.starts_with('['))
.expect("body row present");
let q = body_row.quote.as_ref().expect("body row has quote");
assert_eq!(q.body, "original");
assert_eq!(q.timestamp_ms, quote_ts);
let attachment_row = rows
.iter()
.find(|r| r.body.starts_with("[image:"))
.expect("attachment row present");
assert!(
attachment_row.quote.is_none(),
"attachment row should not carry wire-quote columns; got {:?}",
attachment_row.quote
);
}
#[rstest]
fn locked_session_suppresses_pending_bell(mut app: App) {
app.lock.phase = crate::domain::LockPhase::LockEntry;
app.notifications.notify_direct = true;
app.notifications.notify_group = true;
let ts = 1_700_000_100_000;
let mut m = make_msg_with_ts("+1", Some("hi"), None, false, ts);
m.source_name = Some("Alice".to_string());
app.handle_signal_event(SignalEvent::MessageReceived(m));
assert!(
!app.notifications.pending_bell,
"pending_bell should be false when locked"
);
}
#[rstest]
fn lock_flow_set_then_unlock(mut app: App) {
use crossterm::event::KeyCode;
app.lock_now();
assert_eq!(app.lock.phase, crate::domain::LockPhase::SetPassphrase);
for c in "secret".chars() {
app.handle_lock_key(KeyCode::Char(c));
}
app.handle_lock_key(KeyCode::Enter);
assert_eq!(app.lock.phase, crate::domain::LockPhase::Unlocked);
app.lock_now();
assert_eq!(app.lock.phase, crate::domain::LockPhase::LockEntry);
for c in "wrong".chars() {
app.handle_lock_key(KeyCode::Char(c));
}
app.handle_lock_key(KeyCode::Enter);
assert_eq!(app.lock.phase, crate::domain::LockPhase::LockEntry);
assert!(app.lock.error.is_some());
for c in "secret".chars() {
app.handle_lock_key(KeyCode::Char(c));
}
app.handle_lock_key(KeyCode::Enter);
assert_eq!(app.lock.phase, crate::domain::LockPhase::Unlocked);
assert!(app.lock.error.is_none());
}
}