p2p_chat/ui/runner/actions/commands/
history.rs

1//! This module contains the command handler for displaying chat history.
2use anyhow::Result;
3use chrono::{DateTime, Local, Utc};
4use libp2p::PeerId;
5
6use crate::types::Message;
7
8use super::super::context::CommandContext;
9use super::super::resolver::resolve_peer_id;
10
11const DEFAULT_HISTORY_LIMIT: usize = 20;
12const MAX_HISTORY_LIMIT: usize = 1000;
13
14/// Displays the message history for a given peer.
15///
16/// Usage: `history <peer_id_or_nickname> [message_count]`
17///
18/// # Arguments
19///
20/// * `parts` - A slice of strings representing the command arguments.
21/// * `context` - The `CommandContext` providing access to the application's state and node.
22///
23/// # Errors
24///
25/// This function returns an error if retrieving the message history fails.
26pub async fn show_history(parts: &[&str], context: &CommandContext) -> Result<()> {
27    if parts.len() < 2 || parts.len() > 3 {
28        context.emit_chat("Usage: history <peer_id_or_nickname> [message_count]");
29        return Ok(());
30    }
31
32    let peer_id = match resolve_peer_id(parts[1], context).await {
33        Ok(id) => id,
34        Err(e) => {
35            context.emit_chat(format!("❌ {}", e));
36            return Ok(());
37        }
38    };
39
40    let limit = match parse_limit(parts.get(2)) {
41        Ok(limit) => limit,
42        Err(msg) => {
43            context.emit_chat(msg);
44            return Ok(());
45        }
46    };
47
48    match context
49        .node()
50        .history
51        .get_history(&context.node().identity.peer_id, &peer_id, limit)
52        .await
53    {
54        Ok(messages) => {
55            if messages.is_empty() {
56                context.emit_chat(format!("No message history with {}", peer_id));
57                return Ok(());
58            }
59
60            let mut output = format!(
61                "Message history with {} (last {} messages):",
62                peer_id,
63                messages.len()
64            );
65
66            for msg in messages {
67                output.push_str(&format!(
68                    "\n  [{}] {}",
69                    format_timestamp(msg.timestamp),
70                    format_direction(&msg, context).await
71                ));
72
73                let content = decrypt_content(&msg, context).await;
74                output.push(' ');
75                output.push_str(&content);
76            }
77
78            context.emit_history(output);
79        }
80        Err(e) => {
81            context.emit_chat(format!("❌ Failed to get message history: {}", e));
82        }
83    }
84
85    Ok(())
86}
87
88/// Parses the message limit argument.
89///
90/// # Arguments
91///
92/// * `raw` - An `Option` containing the raw string value of the limit.
93///
94/// # Returns
95///
96/// A `Result` containing the parsed `usize` limit or an error message.
97fn parse_limit(raw: Option<&&str>) -> Result<usize, String> {
98    match raw {
99        None => Ok(DEFAULT_HISTORY_LIMIT),
100        Some(value) => match value.parse::<usize>() {
101            Ok(count) if (1..=MAX_HISTORY_LIMIT).contains(&count) => Ok(count),
102            _ => Err("❌ Message count must be between 1 and 1000".to_string()),
103        },
104    }
105}
106
107/// Formats a Unix timestamp into a human-readable string.
108///
109/// # Arguments
110///
111/// * `timestamp_ms` - The Unix timestamp in milliseconds.
112///
113/// # Returns
114///
115/// A formatted string representing the timestamp.
116fn format_timestamp(timestamp_ms: i64) -> String {
117    DateTime::<Utc>::from_timestamp_millis(timestamp_ms)
118        .map(|dt| {
119            dt.with_timezone(&Local)
120                .format("%Y-%m-%d %H:%M:%S")
121                .to_string()
122        })
123        .unwrap_or_else(|| "Invalid timestamp".to_string())
124}
125
126/// Formats the direction of a message (sent or received).
127///
128/// # Arguments
129///
130/// * `msg` - The `Message` to format.
131/// * `context` - The `CommandContext` for looking up peer labels.
132///
133/// # Returns
134///
135/// A formatted string indicating the message direction and peer label.
136async fn format_direction(msg: &Message, context: &CommandContext) -> String {
137    if msg.sender == context.node().identity.peer_id {
138        let label = lookup_peer_label(msg.recipient, context).await;
139        format!("\x1b[94mYou -> {}\x1b[0m", label)
140    } else {
141        let label = lookup_peer_label(msg.sender, context).await;
142        format!("\x1b[92m{} -> You\x1b[0m", label)
143    }
144}
145
146/// Looks up a peer's label (nickname or short Peer ID).
147///
148/// # Arguments
149///
150/// * `peer_id` - The `PeerId` to look up.
151/// * `context` - The `CommandContext` for accessing friend information.
152///
153/// # Returns
154///
155/// A string representing the peer's label.
156async fn lookup_peer_label(peer_id: PeerId, context: &CommandContext) -> String {
157    match context
158        .node()
159        .friends
160        .get_friend(&peer_id)
161        .await
162        .ok()
163        .flatten()
164    {
165        Some(friend) => friend.nickname.unwrap_or_else(|| short_peer(peer_id)),
166        None => short_peer(peer_id),
167    }
168}
169
170/// Returns a shortened string representation of a `PeerId`.
171///
172/// # Arguments
173///
174/// * `peer_id` - The `PeerId` to shorten.
175///
176/// # Returns
177///
178/// A shortened string of the `PeerId`.
179fn short_peer(peer_id: PeerId) -> String {
180    let peer_str = peer_id.to_string();
181    if peer_str.len() > 8 {
182        format!("{}...", &peer_str[..8])
183    } else {
184        peer_str
185    }
186}
187
188/// Decrypts the content of a message.
189///
190/// # Arguments
191///
192/// * `msg` - The `Message` whose content to decrypt.
193/// * `context` - The `CommandContext` for accessing identity and friend information.
194///
195/// # Returns
196///
197/// A string containing the decrypted message content, or an error message if decryption fails.
198async fn decrypt_content(msg: &Message, context: &CommandContext) -> String {
199    let other_pubkey = if msg.sender == context.node().identity.peer_id {
200        context
201            .node()
202            .friends
203            .get_friend(&msg.recipient)
204            .await
205            .ok()
206            .flatten()
207            .map(|f| f.e2e_public_key)
208    } else {
209        context
210            .node()
211            .friends
212            .get_friend(&msg.sender)
213            .await
214            .ok()
215            .flatten()
216            .map(|f| f.e2e_public_key)
217    };
218
219    match other_pubkey {
220        Some(pub_key) => match context.node().identity.decrypt_from(&pub_key, &msg.content) {
221            Ok(plaintext) => String::from_utf8_lossy(&plaintext).to_string(),
222            Err(_) => "[Decryption Failed]".to_string(),
223        },
224        None => "[Cannot decrypt - unknown peer]".to_string(),
225    }
226}