cryptic_tui/
app.rs

1//! Application state and data structures.
2//!
3//! This module contains the core data structures for managing the application state,
4//! including user data, messages, message history, UI state, and connection status.
5//!
6//! # Key Types
7//!
8//! - [`App`] - Main application state
9//! - [`ConversationView`] - Per-peer message history with lazy loading
10//! - [`Tab`] - UI tab navigation
11//! - [`User`] - User information
12//! - [`Message`] - Chat message data
13//! - [`Secret`] - Secure string wrapper with automatic zeroization
14//!
15//! # Message History
16//!
17//! The module implements efficient message history management through [`ConversationView`],
18//! which uses a [`VecDeque`] for O(1) prepending when loading older messages. This enables
19//! infinite scrolling with lazy loading from the persistent SQLite storage.
20
21use crate::erlang::ConnectionStatus;
22use std::collections::{HashMap, VecDeque};
23use std::time::{Duration, Instant};
24use zeroize::Zeroize;
25
26/// A system message with display timing information.
27#[derive(Debug, Clone)]
28pub struct SystemMessage {
29    /// The message text
30    pub text: String,
31    /// Optional system code (e.g., "server_connection_down")
32    #[allow(dead_code)]
33    pub sys_code: Option<String>,
34    /// When this message was added to the queue
35    pub timestamp: Instant,
36    /// Minimum duration to display this message (default 3 seconds)
37    pub min_display_duration: Duration,
38}
39
40impl SystemMessage {
41    /// Create a new system message with default 3-second display duration.
42    pub fn new(text: String) -> Self {
43        Self {
44            text,
45            sys_code: None,
46            timestamp: Instant::now(),
47            min_display_duration: Duration::from_secs(3),
48        }
49    }
50
51    /// Create a new system message with a system code.
52    pub fn with_code(text: String, sys_code: String) -> Self {
53        Self {
54            text,
55            sys_code: Some(sys_code),
56            timestamp: Instant::now(),
57            min_display_duration: Duration::from_secs(3),
58        }
59    }
60
61    /// Check if the minimum display duration has elapsed.
62    pub fn can_advance(&self) -> bool {
63        self.timestamp.elapsed() >= self.min_display_duration
64    }
65
66    /// Reset the timestamp to now (called when message starts displaying).
67    pub fn reset_timestamp(&mut self) {
68        self.timestamp = Instant::now();
69    }
70}
71
72/// UI tab navigation.
73///
74/// Represents the different tabs available in the main application screen.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub enum Tab {
77    /// Chat view with user list and message history
78    Chat,
79    /// Admin panel for user management
80    Admin,
81    /// Connection and system status
82    Status,
83    /// Help and keyboard shortcuts
84    Help,
85}
86
87impl Tab {
88    /// Move to the next tab in the sequence.
89    ///
90    /// Wraps around from Help back to Chat.
91    pub fn next(&self) -> Self {
92        match self {
93            Tab::Chat => Tab::Admin,
94            Tab::Admin => Tab::Status,
95            Tab::Status => Tab::Help,
96            Tab::Help => Tab::Chat,
97        }
98    }
99
100    /// Move to the previous tab in the sequence.
101    ///
102    /// Wraps around from Chat back to Help.
103    pub fn previous(&self) -> Self {
104        match self {
105            Tab::Chat => Tab::Help,
106            Tab::Admin => Tab::Chat,
107            Tab::Status => Tab::Admin,
108            Tab::Help => Tab::Status,
109        }
110    }
111}
112
113/// Represents a user in the messaging system.
114#[derive(Debug, Clone)]
115pub struct User {
116    /// Username identifier
117    pub name: String,
118    /// Whether the user is currently online
119    pub online: bool,
120}
121
122/// Represents a chat message.
123#[derive(Debug, Clone)]
124pub struct Message {
125    /// Username of the sender
126    pub from: String,
127    /// Message content (decrypted plaintext)
128    pub content: String,
129    /// Whether this message was sent by the current user
130    pub is_sent: bool,
131    /// Unix timestamp of the message
132    #[allow(dead_code)]
133    pub timestamp: u64,
134}
135
136impl Message {
137    pub fn received(from: &str, content: &str) -> Self {
138        Self {
139            from: from.to_string(),
140            content: content.to_string(),
141            is_sent: false,
142            timestamp: 0,
143        }
144    }
145
146    pub fn sent(from: &str, content: &str) -> Self {
147        Self {
148            from: from.to_string(),
149            content: content.to_string(),
150            is_sent: true,
151            timestamp: 0,
152        }
153    }
154}
155
156/// Engine status information from cryptic_engine.
157#[derive(Debug, Clone, Default)]
158pub struct EngineStatus {
159    pub username: String,
160    pub active_sessions: u32,
161    pub message_count: u32,
162    pub error_count: u32,
163    pub uptime_ms: u64,
164    pub session_details: Vec<SessionDetail>,
165    pub cert_expires_at: Option<i64>,
166    pub cert_renewal_in_progress: bool,
167    pub cert_time_until_expiry: Option<i64>,
168}
169
170/// Details about a specific Double Ratchet session.
171#[derive(Debug, Clone)]
172#[allow(dead_code)]
173pub struct SessionDetail {
174    pub peer_username: String,
175    pub message_count: u32,
176    pub error_count: u32,
177    pub dh_ratchet_step: u32,
178    pub send_msg_number: u32,
179    pub recv_msg_number: u32,
180    pub prev_recv_chain_length: u32,
181    pub skipped_keys_count: u32,
182    pub current_state: String,
183    pub transition_count: u32,
184    pub created_at: u64,
185    pub last_updated: u64,
186    pub receiving_chain_active: bool,
187    pub sending_chain_active: bool,
188    pub has_remote_dh: bool,
189}
190
191/// Detailed user information from admin user list.
192#[derive(Debug, Clone)]
193#[allow(dead_code)]
194pub struct DetailedUser {
195    pub username: String,
196    pub gpg_fp: String,
197    pub online: bool,
198    pub status: String,
199    pub registered_at: u64,
200    pub last_seen: u64,
201    pub registered_by: String,
202    pub metadata: Option<UserMetadata>,
203}
204
205/// Optional metadata for a user.
206/// Contains arbitrary key-value pairs like team, name, note, etc.
207#[derive(Debug, Clone)]
208pub struct UserMetadata {
209    pub fields: std::collections::HashMap<String, String>,
210}
211
212/// Certificate information for a user.
213#[derive(Debug, Clone)]
214pub struct Certificate {
215    pub serial: String,
216    pub issued_at: i64,
217    pub expires_at: i64,
218    pub status: String,
219    pub revoked_at: Option<i64>,
220    pub revoked_by: Option<String>,
221    pub revoked_reason: Option<String>,
222}
223
224/// Manages a conversation with message history.
225///
226/// This struct handles lazy loading of message history with infinite scrolling.
227/// Messages are stored in a VecDeque for efficient prepending when loading older messages.
228#[derive(Debug, Clone)]
229#[allow(dead_code)]
230pub struct ConversationView {
231    /// Username of the peer
232    pub peer: String,
233
234    /// Message cache (ordered by timestamp, oldest first)
235    pub messages: VecDeque<Message>,
236
237    /// Current scroll position (0 = bottom/newest)
238    pub scroll_offset: usize,
239
240    /// Total messages loaded from storage
241    pub loaded_count: usize,
242
243    /// Total messages in storage (if known)
244    pub total_count: Option<usize>,
245
246    /// Whether we're currently loading history
247    pub is_loading: bool,
248
249    /// Whether more history exists to load
250    pub has_more_history: bool,
251
252    /// Timestamp of oldest loaded message
253    pub oldest_timestamp: Option<u64>,
254
255    /// Timestamp of newest loaded message
256    pub newest_timestamp: Option<u64>,
257}
258
259impl ConversationView {
260    /// Create a new conversation view for a peer.
261    pub fn new(peer: String) -> Self {
262        Self {
263            peer,
264            messages: VecDeque::with_capacity(100),
265            scroll_offset: 0,
266            loaded_count: 0,
267            total_count: None,
268            is_loading: false,
269            has_more_history: true,
270            oldest_timestamp: None,
271            newest_timestamp: None,
272        }
273    }
274
275    /// Add messages to the front (older messages).
276    ///
277    /// Used when loading history backwards in time.
278    pub fn prepend_messages(&mut self, messages: Vec<Message>) {
279        if let Some(first) = messages.first() {
280            self.oldest_timestamp = Some(first.timestamp);
281        }
282
283        for msg in messages.into_iter().rev() {
284            self.messages.push_front(msg);
285        }
286
287        self.loaded_count = self.messages.len();
288    }
289
290    /// Add messages to the back (newer messages).
291    ///
292    /// Used when initially loading recent messages or when receiving new messages.
293    pub fn append_messages(&mut self, messages: Vec<Message>) {
294        if let Some(last) = messages.last() {
295            self.newest_timestamp = Some(last.timestamp);
296        }
297
298        self.messages.extend(messages);
299        self.loaded_count = self.messages.len();
300    }
301
302    /// Add a single new message (for real-time incoming messages).
303    pub fn add_message(&mut self, message: Message) {
304        self.newest_timestamp = Some(message.timestamp);
305        self.messages.push_back(message);
306        self.loaded_count = self.messages.len();
307    }
308
309    /// Get oldest timestamp for pagination.
310    pub fn get_oldest_timestamp(&self) -> Option<u64> {
311        self.messages.front().map(|m| m.timestamp)
312    }
313
314    /// Check if we're scrolled to the top.
315    pub fn is_at_top(&self) -> bool {
316        self.scroll_offset >= self.messages.len().saturating_sub(1)
317    }
318
319    /// Check if we're scrolled to the bottom.
320    #[allow(dead_code)]
321    pub fn is_at_bottom(&self) -> bool {
322        self.scroll_offset == 0
323    }
324}
325
326/// Main application state.
327///
328/// Contains all the data needed to render the UI and manage the application.
329/// This includes user lists, messages, input state, connection status, and more.
330///
331/// # Examples
332///
333/// ```no_run
334/// use cryptic_tui::app::App;
335///
336/// // Create app with mock data for testing
337/// let app = App::with_mock_data();
338///
339/// // Create empty app
340/// let app = App::new();
341/// ```
342pub struct App {
343    /// Currently active tab
344    pub active_tab: Tab,
345    /// List of all users
346    pub users: Vec<User>,
347    /// Messages grouped by peer username
348    pub messages: HashMap<String, Vec<Message>>,
349    /// Currently selected peer for chat
350    pub current_peer: Option<String>,
351    /// Input buffer for message composition
352    pub input_buffer: String,
353    /// Cursor position in input buffer (byte index, not char index)
354    pub input_cursor: usize,
355    /// Currently selected user index in the user list
356    pub selected_user: usize,
357    /// Whether running with mock data or real Erlang connection
358    pub is_mock_mode: bool,
359    /// Current screen (Splash or Main)
360    pub screen: AppScreen,
361    /// Encrypted passphrase (zeroized on drop)
362    pub passphrase: Option<Secret>,
363    /// Passphrase input buffer (cleared after validation)
364    pub passphrase_input: String,
365    /// Error message to display on splash screen
366    pub splash_error: Option<String>,
367    /// Flag to trigger application exit
368    pub should_exit: bool,
369    /// Current Erlang connection status
370    pub connection_status: ConnectionStatus,
371    /// Name of the Erlang node to connect to
372    pub erlang_node_name: Option<String>,
373    /// Cryptographic engine status
374    pub engine_status: Option<EngineStatus>,
375    /// Detailed user list from admin API
376    pub detailed_users: Option<Vec<DetailedUser>>,
377    /// Active conversation views (keyed by peer username)
378    pub conversations: HashMap<String, ConversationView>,
379    /// Flag to trigger history loading
380    pub should_load_history: bool,
381    /// Status message to display to user (e.g., errors, notifications)
382    pub status_message: Option<String>,
383    /// Pending admin form inputs
384    pub admin_form: AdminForm,
385    /// Current admin view mode
386    pub admin_mode: AdminMode,
387    /// Selected user index in admin user list
388    pub admin_selected_user: usize,
389    /// Certificates for currently viewed user
390    pub certificates: Option<Vec<Certificate>>,
391    /// User whose certificates are being viewed
392    pub certificates_for_user: Option<String>,
393    /// Admin view scroll offset (for scrolling content)
394    pub admin_scroll_offset: u16,
395    /// Status view scroll offset (for scrolling content)
396    pub status_scroll_offset: u16,
397    /// Help view scroll offset (for scrolling content)
398    pub help_scroll_offset: u16,
399    /// Queue of system messages to display
400    pub system_message_queue: VecDeque<SystemMessage>,
401    /// Currently displayed system message (if any)
402    pub current_system_message: Option<SystemMessage>,
403    /// Server connection state (tracked via sys_code from system_message events)
404    pub server_connection_up: bool,
405}
406
407/// Form state for Admin actions.
408#[derive(Debug, Clone, Default)]
409#[allow(dead_code)]
410pub struct AdminForm {
411    /// GPG fingerprint input field
412    pub gpg_fp: String,
413    /// Path to GPG public key file
414    pub key_path: String,
415    /// Optional metadata string (e.g., "--name John Doe --team Engineering")
416    pub metadata: String,
417    /// Which input is currently active (for focus switching)
418    pub active_field: AdminField,
419    /// Whether the form is visible
420    pub visible: bool,
421}
422
423/// Admin view mode
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub enum AdminMode {
426    Menu,
427    RegisterUser,
428    SuspendUser,
429    ReactivateUser,
430    RevokeUser,
431    ListCertificates,
432}
433
434/// Identifies active admin form field.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum AdminField {
437    GpgFingerprint,
438    KeyPath,
439    Metadata,
440}
441
442impl Default for AdminField {
443    fn default() -> Self {
444        AdminField::GpgFingerprint
445    }
446}
447
448impl App {
449    /// Create a new empty App without mock data.
450    ///
451    /// Used when connecting to a real Erlang node - users will be populated
452    /// from the server's user list.
453    pub fn new() -> Self {
454        Self {
455            active_tab: Tab::Chat,
456            users: Vec::new(), // Start with empty user list
457            messages: HashMap::new(),
458            current_peer: None,
459            input_buffer: String::new(),
460            input_cursor: 0,
461            selected_user: 0,
462            is_mock_mode: false,
463            screen: AppScreen::Splash,
464            passphrase: None,
465            passphrase_input: String::new(),
466            splash_error: None,
467            should_exit: false,
468            connection_status: ConnectionStatus::Disconnected,
469            erlang_node_name: None,
470            engine_status: None,
471            detailed_users: None,
472            conversations: HashMap::new(),
473            should_load_history: false,
474            status_message: None,
475            admin_form: AdminForm::default(),
476            admin_mode: AdminMode::Menu,
477            admin_selected_user: 0,
478            certificates: None,
479            certificates_for_user: None,
480            admin_scroll_offset: 0,
481            status_scroll_offset: 0,
482            help_scroll_offset: 0,
483            system_message_queue: VecDeque::new(),
484            current_system_message: None,
485            server_connection_up: false,
486        }
487    }
488
489    /// Update the users list with online status from the server.
490    ///
491    /// # Arguments
492    ///
493    /// * `online_users` - List of usernames currently online
494    pub fn update_online_users(&mut self, online_users: Vec<String>) {
495        // Get current username to exclude from list (as owned String to avoid borrow issues)
496        let current_username = self.get_current_username().map(|s| s.to_string());
497
498        // Mark all users as offline first
499        for user in &mut self.users {
500            user.online = false;
501        }
502
503        // Mark online users (excluding ourselves)
504        for username in &online_users {
505            // Skip our own username
506            if current_username.as_deref() == Some(username.as_str()) {
507                continue;
508            }
509
510            if let Some(user) = self.users.iter_mut().find(|u| &u.name == username) {
511                user.online = true;
512            } else {
513                // Add new user if not in list
514                self.users.push(User {
515                    name: username.clone(),
516                    online: true,
517                });
518            }
519        }
520
521        // Remove current user from the list if they somehow got added
522        if let Some(current_user) = current_username {
523            self.users.retain(|u| u.name != current_user);
524        }
525
526        // Sort users: online first, then alphabetically
527        self.users.sort_by(|a, b| match (a.online, b.online) {
528            (true, false) => std::cmp::Ordering::Less,
529            (false, true) => std::cmp::Ordering::Greater,
530            _ => a.name.cmp(&b.name),
531        });
532    }
533
534    /// Get the current username from the Erlang node name.
535    ///
536    /// Extracts the username portion before '@' in node names like "alice@localhost".
537    /// Returns None if no node name is set.
538    pub fn get_current_username(&self) -> Option<&str> {
539        self.erlang_node_name
540            .as_ref()
541            .map(|name| name.split('@').next().unwrap_or(name.as_str()))
542    }
543
544    /// Create app with mock data for Phase 0 development.
545    ///
546    /// Populates the app with sample users and messages for UI testing
547    /// without requiring an Erlang connection.
548    pub fn with_mock_data() -> Self {
549        let users = vec![
550            User {
551                name: "alice".to_string(),
552                online: true,
553            },
554            User {
555                name: "bob".to_string(),
556                online: false,
557            },
558            User {
559                name: "charlie".to_string(),
560                online: true,
561            },
562            User {
563                name: "dave".to_string(),
564                online: false,
565            },
566            User {
567                name: "eve".to_string(),
568                online: true,
569            },
570        ];
571
572        let mut messages = HashMap::new();
573
574        // Mock conversation with alice
575        messages.insert(
576            "alice".to_string(),
577            vec![
578                Message::received("alice", "Hey there! How's the TUI coming along?"),
579                Message::sent("me", "Pretty good! Just got the layout working."),
580                Message::received("alice", "That's awesome! Can't wait to try it."),
581                Message::sent("me", "Still need to hook up the Erlang backend though."),
582                Message::received("alice", "One step at a time! 🚀"),
583            ],
584        );
585
586        // Mock conversation with charlie
587        messages.insert(
588            "charlie".to_string(),
589            vec![
590                Message::received("charlie", "Did you see the new design?"),
591                Message::sent("me", "Yes! Looks great with the tabs."),
592                Message::received("charlie", "Thanks! Let me know if you need help."),
593            ],
594        );
595
596        Self {
597            active_tab: Tab::Chat,
598            users,
599            messages,
600            current_peer: Some("alice".to_string()),
601            input_buffer: String::new(),
602            input_cursor: 0,
603            selected_user: 0,
604            is_mock_mode: true,
605            screen: AppScreen::Splash,
606            passphrase: None,
607            passphrase_input: String::new(),
608            splash_error: None,
609            should_exit: false,
610            connection_status: ConnectionStatus::Disconnected,
611            erlang_node_name: None,
612            engine_status: None,
613            detailed_users: None,
614            conversations: HashMap::new(),
615            should_load_history: false,
616            status_message: None,
617            admin_form: AdminForm::default(),
618            admin_mode: AdminMode::Menu,
619            admin_selected_user: 0,
620            certificates: None,
621            certificates_for_user: None,
622            admin_scroll_offset: 0,
623            status_scroll_offset: 0,
624            help_scroll_offset: 0,
625            system_message_queue: VecDeque::new(),
626            current_system_message: None,
627            server_connection_up: false,
628        }
629    }
630
631    /// Switch to next tab
632    pub fn next_tab(&mut self) {
633        self.active_tab = self.active_tab.next();
634        // Reset scroll offsets when switching tabs
635        self.admin_reset_scroll();
636        self.status_reset_scroll();
637        self.help_reset_scroll();
638    }
639
640    /// Switch to previous tab
641    pub fn previous_tab(&mut self) {
642        self.active_tab = self.active_tab.previous();
643        // Reset scroll offsets when switching tabs
644        self.admin_reset_scroll();
645        self.status_reset_scroll();
646        self.help_reset_scroll();
647    }
648
649    /// Select next user in the list
650    pub fn next_user(&mut self) {
651        if self.users.is_empty() {
652            return;
653        }
654        self.selected_user = (self.selected_user + 1) % self.users.len();
655        
656        // Auto-open chat when navigating to show message history
657        self.open_selected_chat();
658    }
659
660    /// Select previous user in the list
661    pub fn previous_user(&mut self) {
662        if self.users.is_empty() {
663            return;
664        }
665        if self.selected_user == 0 {
666            self.selected_user = self.users.len() - 1;
667        } else {
668            self.selected_user -= 1;
669        }
670        
671        // Auto-open chat when navigating to show message history
672        self.open_selected_chat();
673    }
674
675    /// Open chat with selected user
676    pub fn open_selected_chat(&mut self) {
677        let idx = self.selected_user;
678        if idx < self.users.len() {
679            let peer = self.users[idx].name.clone();
680            self.current_peer = Some(peer.clone());
681
682            // Trigger initial history load if this conversation has no messages
683            let needs_load = {
684                let conv = self.get_conversation(&peer);
685                conv.map(|c| c.messages.is_empty()).unwrap_or(true)
686            };
687
688            if needs_load {
689                self.should_load_history = true;
690            }
691        }
692    }
693
694    /// Send message from input buffer
695    pub fn send_message(&mut self) {
696        if self.input_buffer.is_empty() {
697            return;
698        }
699
700        if let Some(peer) = self.current_peer.clone() {
701            let content = self.input_buffer.clone();
702            let timestamp = std::time::SystemTime::now()
703                .duration_since(std::time::UNIX_EPOCH)
704                .unwrap()
705                .as_secs();
706
707            let message = Message {
708                from: "me".to_string(),
709                content: content.clone(),
710                is_sent: true,
711                timestamp,
712            };
713
714            // Add to old messages structure (for backward compatibility)
715            self.messages
716                .entry(peer.clone())
717                .or_insert_with(Vec::new)
718                .push(message.clone());
719
720            // Add to conversation view (new structure that UI uses)
721            let conv = self.get_conversation_mut(&peer);
722            conv.add_message(message);
723            conv.scroll_offset = 0; // Reset scroll to show sent message
724
725            // Clear input
726            self.input_buffer.clear();
727            self.input_cursor = 0;
728
729            // TODO: Phase 2+ - Send via RPC to Erlang
730            // chat_rpc:send_message(peer, message)
731        }
732    }
733
734    /// Add character to input buffer at cursor position
735    pub fn input_char(&mut self, c: char) {
736        let byte_index = self.char_index_to_byte_index(self.input_cursor);
737        self.input_buffer.insert(byte_index, c);
738        self.input_cursor += 1;
739    }
740
741    /// Remove character before cursor (backspace)
742    pub fn input_backspace(&mut self) {
743        if self.input_cursor > 0 {
744            self.input_cursor -= 1;
745            let byte_index = self.char_index_to_byte_index(self.input_cursor);
746            self.input_buffer.remove(byte_index);
747        }
748    }
749
750    /// Clear input buffer
751    pub fn clear_input(&mut self) {
752        self.input_buffer.clear();
753        self.input_cursor = 0;
754    }
755
756    /// Move cursor to beginning of line (Ctrl-A)
757    pub fn input_move_home(&mut self) {
758        self.input_cursor = 0;
759    }
760
761    /// Move cursor to end of line (Ctrl-E)
762    pub fn input_move_end(&mut self) {
763        self.input_cursor = self.input_buffer.chars().count();
764    }
765
766    /// Move cursor left one character (Ctrl-B)
767    pub fn input_move_left(&mut self) {
768        if self.input_cursor > 0 {
769            self.input_cursor -= 1;
770        }
771    }
772
773    /// Move cursor right one character (Ctrl-F)
774    pub fn input_move_right(&mut self) {
775        let len = self.input_buffer.chars().count();
776        if self.input_cursor < len {
777            self.input_cursor += 1;
778        }
779    }
780
781    /// Delete character at cursor (Delete key / Ctrl-D)
782    pub fn input_delete(&mut self) {
783        let len = self.input_buffer.chars().count();
784        if self.input_cursor < len {
785            let byte_index = self.char_index_to_byte_index(self.input_cursor);
786            self.input_buffer.remove(byte_index);
787        }
788    }
789
790    /// Convert character index to byte index (for UTF-8 safety)
791    fn char_index_to_byte_index(&self, char_index: usize) -> usize {
792        self.input_buffer
793            .char_indices()
794            .nth(char_index)
795            .map(|(byte_idx, _)| byte_idx)
796            .unwrap_or(self.input_buffer.len())
797    }
798
799    /// Simulate receiving a message (mock event bus)
800    #[allow(dead_code)]
801    pub fn receive_message(&mut self, from: &str, content: &str) {
802        let timestamp = std::time::SystemTime::now()
803            .duration_since(std::time::UNIX_EPOCH)
804            .unwrap()
805            .as_secs();
806
807        let message = Message {
808            from: from.to_string(),
809            content: content.to_string(),
810            is_sent: false,
811            timestamp,
812        };
813
814        // Add to old messages structure (for backward compatibility)
815        self.messages
816            .entry(from.to_string())
817            .or_insert_with(Vec::new)
818            .push(message.clone());
819
820        // Add to conversation view (new structure that UI uses)
821        self.get_conversation_mut(from).add_message(message);
822    }
823
824    /// Update engine status with new data
825    pub fn update_engine_status(&mut self, status: EngineStatus) {
826        self.engine_status = Some(status);
827    }
828
829    /// Update detailed user list from admin API
830    pub fn update_detailed_users(&mut self, users: Vec<DetailedUser>) {
831        self.detailed_users = Some(users);
832    }
833
834    /// Set a status message to display to the user.
835    ///
836    /// # Arguments
837    ///
838    /// * `message` - The status message to display
839    pub fn set_status_message(&mut self, message: String) {
840        self.status_message = Some(message);
841    }
842
843    /// Clear the current status message.
844    pub fn clear_status_message(&mut self) {
845        self.status_message = None;
846    }
847
848    /// Switch active admin form field.
849    pub fn admin_focus_next(&mut self) {
850        self.admin_form.active_field = match self.admin_form.active_field {
851            AdminField::GpgFingerprint => AdminField::KeyPath,
852            AdminField::KeyPath => AdminField::Metadata,
853            AdminField::Metadata => AdminField::GpgFingerprint,
854        };
855    }
856
857    /// Switch active admin form field in reverse order.
858    pub fn admin_focus_previous(&mut self) {
859        self.admin_form.active_field = match self.admin_form.active_field {
860            AdminField::GpgFingerprint => AdminField::Metadata,
861            AdminField::KeyPath => AdminField::GpgFingerprint,
862            AdminField::Metadata => AdminField::KeyPath,
863        };
864    }
865
866    /// Insert character into active admin field.
867    pub fn admin_input_char(&mut self, ch: char) {
868        if ch.is_control() {
869            return;
870        }
871
872        match self.admin_form.active_field {
873            AdminField::GpgFingerprint => self.admin_form.gpg_fp.push(ch),
874            AdminField::KeyPath => self.admin_form.key_path.push(ch),
875            AdminField::Metadata => self.admin_form.metadata.push(ch),
876        }
877    }
878
879    /// Handle backspace in active admin field.
880    pub fn admin_backspace(&mut self) {
881        match self.admin_form.active_field {
882            AdminField::GpgFingerprint => {
883                self.admin_form.gpg_fp.pop();
884            }
885            AdminField::KeyPath => {
886                self.admin_form.key_path.pop();
887            }
888            AdminField::Metadata => {
889                self.admin_form.metadata.pop();
890            }
891        }
892    }
893
894    /// Clear both admin input fields.
895    pub fn admin_clear_inputs(&mut self) {
896        self.admin_form.gpg_fp.clear();
897        self.admin_form.key_path.clear();
898        self.admin_form.metadata.clear();
899        self.admin_form.active_field = AdminField::GpgFingerprint;
900    }
901
902    /// Determine if admin form is ready to submit.
903    pub fn admin_form_complete(&self) -> bool {
904        !self.admin_form.gpg_fp.trim().is_empty() && !self.admin_form.key_path.trim().is_empty()
905    }
906
907    /// Move to next user in admin user list.
908    pub fn admin_next_user(&mut self) {
909        if let Some(ref users) = self.detailed_users {
910            if !users.is_empty() {
911                self.admin_selected_user = (self.admin_selected_user + 1) % users.len();
912            }
913        }
914    }
915
916    /// Move to previous user in admin user list.
917    pub fn admin_previous_user(&mut self) {
918        if let Some(ref users) = self.detailed_users {
919            if !users.is_empty() {
920                if self.admin_selected_user == 0 {
921                    self.admin_selected_user = users.len() - 1;
922                } else {
923                    self.admin_selected_user -= 1;
924                }
925            }
926        }
927    }
928
929    /// Get currently selected user in admin list.
930    pub fn admin_get_selected_user(&self) -> Option<&DetailedUser> {
931        self.detailed_users
932            .as_ref()
933            .and_then(|users| users.get(self.admin_selected_user))
934    }
935
936    /// Scroll admin view up
937    pub fn admin_scroll_up(&mut self) {
938        self.admin_scroll_offset = self.admin_scroll_offset.saturating_sub(1);
939    }
940
941    /// Scroll admin view down
942    pub fn admin_scroll_down(&mut self) {
943        self.admin_scroll_offset = self.admin_scroll_offset.saturating_add(1);
944    }
945
946    /// Reset admin scroll to top
947    pub fn admin_reset_scroll(&mut self) {
948        self.admin_scroll_offset = 0;
949    }
950
951    /// Scroll status view up
952    pub fn status_scroll_up(&mut self) {
953        self.status_scroll_offset = self.status_scroll_offset.saturating_add(1);
954    }
955
956    /// Scroll status view down
957    pub fn status_scroll_down(&mut self) {
958        self.status_scroll_offset = self.status_scroll_offset.saturating_sub(1);
959    }
960
961    /// Reset status scroll to top
962    pub fn status_reset_scroll(&mut self) {
963        self.status_scroll_offset = 0;
964    }
965
966    /// Scroll help view up
967    pub fn help_scroll_up(&mut self) {
968        self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
969    }
970
971    /// Scroll help view down
972    pub fn help_scroll_down(&mut self) {
973        self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
974    }
975
976    /// Reset help scroll to top
977    pub fn help_reset_scroll(&mut self) {
978        self.help_scroll_offset = 0;
979    }
980
981    /// Get or create conversation view for a peer.
982    ///
983    /// If a conversation doesn't exist, creates a new one.
984    pub fn get_conversation_mut(&mut self, peer: &str) -> &mut ConversationView {
985        self.conversations
986            .entry(peer.to_string())
987            .or_insert_with(|| ConversationView::new(peer.to_string()))
988    }
989
990    /// Get conversation view (read-only).
991    ///
992    /// Returns None if conversation doesn't exist yet.
993    pub fn get_conversation(&self, peer: &str) -> Option<&ConversationView> {
994        self.conversations.get(peer)
995    }
996
997    /// Add a system message to the queue.
998    ///
999    /// If no message is currently being displayed, immediately shows the new message.
1000    /// Otherwise, adds it to the queue.
1001    ///
1002    /// # Arguments
1003    ///
1004    /// * `text` - The message text to display
1005    /// * `sys_code` - Optional system code (Erlang atom) such as:
1006    ///   - `"server_connection_up"` - Server is available
1007    ///   - `"server_connection_down"` - Server is unavailable
1008    ///
1009    /// When `sys_code` is "server_connection_down", all users are marked as offline.
1010    pub fn add_system_message(&mut self, text: String, sys_code: Option<String>) {
1011        // Update server connection state based on sys_code
1012        if let Some(ref code) = sys_code {
1013            match code.as_str() {
1014                "server_connection_up" => self.server_connection_up = true,
1015                "server_connection_down" => {
1016                    self.server_connection_up = false;
1017                    // Mark all users as offline when server goes down
1018                    for user in &mut self.users {
1019                        user.online = false;
1020                    }
1021                }
1022                _ => {}
1023            }
1024        }
1025
1026        let message = if let Some(code) = sys_code {
1027            SystemMessage::with_code(text, code)
1028        } else {
1029            SystemMessage::new(text)
1030        };
1031        
1032        if self.current_system_message.is_none() {
1033            // No message currently displayed, show immediately
1034            self.current_system_message = Some(message);
1035        } else {
1036            // Queue the message
1037            self.system_message_queue.push_back(message);
1038        }
1039    }
1040
1041    /// Update the system message display, advancing to the next message if the
1042    /// minimum display time has elapsed.
1043    ///
1044    /// Should be called periodically (e.g., from the event loop).
1045    pub fn update_system_message(&mut self) {
1046        if let Some(ref current) = self.current_system_message {
1047            // Check if we can advance to the next message
1048            if current.can_advance() {
1049                // Try to get the next message from the queue
1050                if let Some(mut next_message) = self.system_message_queue.pop_front() {
1051                    // Reset timestamp so it displays for the full duration
1052                    next_message.reset_timestamp();
1053                    self.current_system_message = Some(next_message);
1054                } else {
1055                    // No more messages, clear current
1056                    self.current_system_message = None;
1057                }
1058            }
1059        }
1060    }
1061
1062    /// Get the current system message text for display.
1063    pub fn get_current_system_message(&self) -> Option<&str> {
1064        self.current_system_message.as_ref().map(|m| m.text.as_str())
1065    }
1066
1067    /// Clear all system messages (current and queued).
1068    #[allow(dead_code)]
1069    pub fn clear_system_messages(&mut self) {
1070        self.current_system_message = None;
1071        self.system_message_queue.clear();
1072    }
1073}
1074
1075/// Application screen state.
1076///
1077/// Determines which top-level screen is currently displayed.
1078#[derive(Debug, Clone, Copy, PartialEq)]
1079pub enum AppScreen {
1080    /// Initial splash screen with passphrase prompt
1081    Splash,
1082    /// Main application screen with tabs
1083    Main,
1084}
1085
1086/// Secure string wrapper with automatic memory zeroization.
1087///
1088/// Wraps a `String` and ensures the memory is zeroized when dropped,
1089/// preventing sensitive data (like passphrases) from remaining in memory.
1090///
1091/// # Security
1092///
1093/// When this struct is dropped, the underlying string bytes are overwritten
1094/// with zeros before being deallocated.
1095///
1096/// # Examples
1097///
1098/// ```
1099/// # use cryptic_tui::app::Secret;
1100/// let secret = Secret::new("my_passphrase".to_string());
1101/// // Memory will be zeroized when secret goes out of scope
1102/// ```
1103#[derive(Debug, Clone)]
1104pub struct Secret(String);
1105
1106impl Secret {
1107    /// Create a new secret from a string.
1108    ///
1109    /// # Arguments
1110    ///
1111    /// * `v` - The secret value to wrap
1112    ///
1113    /// # Examples
1114    ///
1115    /// ```
1116    /// # use cryptic_tui::app::Secret;
1117    /// let secret = Secret::new("password123".to_string());
1118    /// ```
1119    pub fn new(v: String) -> Self {
1120        Self(v)
1121    }
1122
1123    /// Get a string slice of the secret value.
1124    ///
1125    /// # Returns
1126    ///
1127    /// A reference to the inner string.
1128    ///
1129    /// # Security Warning
1130    ///
1131    /// The returned reference does not have the same zeroization guarantees
1132    /// as the `Secret` struct itself. Avoid storing this reference or
1133    /// creating copies of the data.
1134    #[allow(dead_code)]
1135    pub fn as_str(&self) -> &str {
1136        &self.0
1137    }
1138}
1139
1140/// Implement `Drop` to ensure memory is zeroized when the secret is dropped.
1141impl Drop for Secret {
1142    fn drop(&mut self) {
1143        self.0.zeroize();
1144    }
1145}