Skip to main content

mxr_tui/ui/
message_view.rs

1use crate::app::{ActivePane, AttachmentSummary, BodyViewState};
2use crate::theme::Theme;
3use mxr_core::types::Envelope;
4use ratatui::prelude::*;
5use ratatui::widgets::*;
6
7#[derive(Debug, Clone)]
8pub struct ThreadMessageBlock {
9    pub envelope: Envelope,
10    pub body_state: BodyViewState,
11    pub labels: Vec<String>,
12    pub attachments: Vec<AttachmentSummary>,
13    pub selected: bool,
14    pub has_unsubscribe: bool,
15    pub signature_expanded: bool,
16}
17
18pub fn draw(
19    frame: &mut Frame,
20    area: Rect,
21    messages: &[ThreadMessageBlock],
22    scroll_offset: u16,
23    active_pane: &ActivePane,
24    theme: &Theme,
25) {
26    let is_focused = *active_pane == ActivePane::MessageView;
27    let border_style = theme.border_style(is_focused);
28
29    let title = if messages.len() > 1 {
30        " Thread "
31    } else {
32        " Message "
33    };
34    let block = Block::bordered()
35        .title(title)
36        .border_type(BorderType::Rounded)
37        .border_style(border_style);
38
39    let inner = block.inner(area);
40    frame.render_widget(block, area);
41
42    let mut lines: Vec<Line> = Vec::new();
43
44    for (index, message) in messages.iter().enumerate() {
45        if index > 0 {
46            lines.push(Line::from(""));
47            lines.push(Line::from(Span::styled(
48                "────────────────────────────────────────",
49                Style::default().fg(theme.text_muted),
50            )));
51            lines.push(Line::from(""));
52        }
53
54        let env = &message.envelope;
55        let from = env.from.name.as_deref().unwrap_or(&env.from.email);
56        let label_style = if message.selected {
57            Style::default().fg(theme.accent).bold()
58        } else {
59            Style::default().fg(theme.text_muted)
60        };
61        let value_style = Style::default().fg(theme.text_primary);
62
63        // Aligned headers with consistent label width
64        let label_width = 10; // "Subject: " padded
65        lines.push(Line::from(vec![
66            Span::styled(format!("{:<label_width$}", "From:"), label_style),
67            Span::styled(format!("{} <{}>", from, env.from.email), value_style),
68        ]));
69        if !env.to.is_empty() {
70            let to_str = env
71                .to
72                .iter()
73                .map(|a| {
74                    a.name
75                        .as_ref()
76                        .map(|n| format!("{} <{}>", n, a.email))
77                        .unwrap_or_else(|| a.email.clone())
78                })
79                .collect::<Vec<_>>()
80                .join(", ");
81            lines.push(Line::from(vec![
82                Span::styled(format!("{:<label_width$}", "To:"), label_style),
83                Span::styled(to_str, value_style),
84            ]));
85        }
86        lines.push(Line::from(vec![
87            Span::styled(format!("{:<label_width$}", "Date:"), label_style),
88            Span::styled(env.date.format("%Y-%m-%d %H:%M").to_string(), value_style),
89        ]));
90        lines.push(Line::from(vec![
91            Span::styled(format!("{:<label_width$}", "Subject:"), label_style),
92            Span::styled(env.subject.clone(), value_style),
93        ]));
94
95        // Label chips with colored backgrounds
96        if !message.labels.is_empty() {
97            let mut chips: Vec<Span> = Vec::new();
98            for label in &message.labels {
99                chips.push(Span::styled(
100                    format!(" {} ", label),
101                    Style::default()
102                        .bg(Theme::label_color(label))
103                        .fg(Color::Black),
104                ));
105                chips.push(Span::raw(" "));
106            }
107            lines.push(Line::from(chips));
108        }
109
110        if message.has_unsubscribe {
111            lines.push(Line::from(vec![
112                Span::styled(format!("{:<label_width$}", "List:"), label_style),
113                Span::styled(
114                    " unsubscribe ",
115                    Style::default().bg(theme.warning).fg(Color::Black).bold(),
116                ),
117            ]));
118        }
119
120        // Attachments
121        if !message.attachments.is_empty() {
122            lines.push(Line::from(vec![Span::styled(
123                format!("{:<label_width$}", "Attach:"),
124                label_style,
125            )]));
126            for attachment in &message.attachments {
127                lines.push(Line::from(vec![
128                    Span::raw(" ".repeat(label_width)),
129                    Span::styled(
130                        &attachment.filename,
131                        Style::default().fg(theme.success).bold(),
132                    ),
133                    Span::styled(
134                        format!(" ({})", human_size(attachment.size_bytes)),
135                        Style::default().fg(theme.text_muted),
136                    ),
137                ]));
138            }
139        }
140        lines.push(Line::from(""));
141
142        match &message.body_state {
143            BodyViewState::Ready { rendered, .. } => {
144                lines.extend(process_body_lines(
145                    rendered,
146                    theme,
147                    message.signature_expanded,
148                ));
149            }
150            BodyViewState::Loading { preview } => {
151                if let Some(preview) = preview {
152                    lines.extend(process_body_lines(
153                        preview,
154                        theme,
155                        message.signature_expanded,
156                    ));
157                    lines.push(Line::from(""));
158                }
159                lines.push(Line::from(Span::styled(
160                    "Loading...",
161                    Style::default().fg(theme.text_muted),
162                )));
163            }
164            BodyViewState::Empty { preview } => {
165                if let Some(preview) = preview {
166                    lines.extend(process_body_lines(
167                        preview,
168                        theme,
169                        message.signature_expanded,
170                    ));
171                    lines.push(Line::from(""));
172                }
173                lines.push(Line::from(Span::styled(
174                    "(no body available)",
175                    Style::default().fg(theme.text_muted),
176                )));
177            }
178            BodyViewState::Error {
179                message: err_msg,
180                preview,
181            } => {
182                if let Some(preview) = preview {
183                    lines.extend(process_body_lines(
184                        preview,
185                        theme,
186                        message.signature_expanded,
187                    ));
188                    lines.push(Line::from(""));
189                }
190                lines.push(Line::from(Span::styled(
191                    format!("Error: {err_msg}"),
192                    Style::default().fg(theme.error),
193                )));
194            }
195        }
196    }
197
198    if messages.is_empty() {
199        lines.push(Line::from(Span::styled(
200            "No message selected",
201            Style::default().fg(theme.text_muted),
202        )));
203    }
204
205    let paragraph = Paragraph::new(lines)
206        .wrap(Wrap { trim: false })
207        .scroll((scroll_offset, 0));
208
209    frame.render_widget(paragraph, inner);
210}
211
212fn process_body_lines(raw: &str, theme: &Theme, signature_expanded: bool) -> Vec<Line<'static>> {
213    let mut lines: Vec<Line<'static>> = Vec::new();
214    let mut quote_buffer: Vec<String> = Vec::new();
215    let mut in_signature = false;
216    let mut signature_lines: Vec<String> = Vec::new();
217    let mut consecutive_blanks: u32 = 0;
218
219    for line in raw.lines() {
220        // Signature detection
221        if line == "-- " || line == "--" {
222            flush_quotes(&mut quote_buffer, &mut lines, theme);
223            in_signature = true;
224            continue;
225        }
226
227        // Blank line collapsing
228        if line.trim().is_empty() {
229            if in_signature {
230                signature_lines.push(String::new());
231                continue;
232            }
233            flush_quotes(&mut quote_buffer, &mut lines, theme);
234            consecutive_blanks += 1;
235            if consecutive_blanks <= 2 {
236                lines.push(Line::from(""));
237            }
238            continue;
239        }
240        consecutive_blanks = 0;
241
242        if in_signature {
243            signature_lines.push(line.to_string());
244            continue;
245        }
246
247        // Quote detection
248        if line.starts_with('>') {
249            quote_buffer.push(line.to_string());
250            continue;
251        }
252
253        // Regular line — flush any pending quotes first
254        flush_quotes(&mut quote_buffer, &mut lines, theme);
255        lines.push(style_line_with_links(line, theme));
256    }
257
258    // Flush remaining
259    flush_quotes(&mut quote_buffer, &mut lines, theme);
260
261    if !signature_lines.is_empty() {
262        if signature_expanded {
263            lines.push(Line::from(""));
264            lines.push(Line::from(Span::styled(
265                "-- signature --",
266                Style::default()
267                    .fg(theme.signature_fg)
268                    .add_modifier(Modifier::ITALIC),
269            )));
270            for line in signature_lines {
271                lines.push(Line::from(Span::styled(
272                    line,
273                    Style::default().fg(theme.signature_fg),
274                )));
275            }
276        } else {
277            let count = signature_lines.len();
278            lines.push(Line::from(Span::styled(
279                format!("-- signature ({} lines, press S to expand) --", count),
280                Style::default()
281                    .fg(theme.text_muted)
282                    .add_modifier(Modifier::ITALIC),
283            )));
284        }
285    }
286
287    lines
288}
289
290fn human_size(size_bytes: u64) -> String {
291    const KB: u64 = 1024;
292    const MB: u64 = 1024 * 1024;
293
294    if size_bytes >= MB {
295        format!("{:.1} MB", size_bytes as f64 / MB as f64)
296    } else if size_bytes >= KB {
297        format!("{:.1} KB", size_bytes as f64 / KB as f64)
298    } else {
299        format!("{size_bytes} B")
300    }
301}
302
303fn flush_quotes(buffer: &mut Vec<String>, lines: &mut Vec<Line<'static>>, theme: &Theme) {
304    if buffer.is_empty() {
305        return;
306    }
307
308    let quote_style = Style::default().fg(theme.quote_fg);
309
310    if buffer.len() <= 3 {
311        for line in buffer.drain(..) {
312            let cleaned = line
313                .trim_start_matches('>')
314                .trim_start_matches(' ')
315                .to_string();
316            lines.push(Line::from(vec![
317                Span::styled("│ ", Style::default().fg(theme.accent_dim)),
318                Span::styled(cleaned, quote_style),
319            ]));
320        }
321    } else {
322        for line in &buffer[..2] {
323            let cleaned = line
324                .trim_start_matches('>')
325                .trim_start_matches(' ')
326                .to_string();
327            lines.push(Line::from(vec![
328                Span::styled("│ ", Style::default().fg(theme.accent_dim)),
329                Span::styled(cleaned, quote_style),
330            ]));
331        }
332        let hidden = buffer.len() - 2;
333        lines.push(Line::from(Span::styled(
334            format!("  ┆ ... {hidden} more quoted lines ..."),
335            Style::default()
336                .fg(theme.quote_fg)
337                .add_modifier(Modifier::ITALIC),
338        )));
339        buffer.clear();
340    }
341}
342
343/// Split a line into spans, highlighting URLs in link_fg with underline.
344fn style_line_with_links(line: &str, theme: &Theme) -> Line<'static> {
345    let link_style = Style::default()
346        .fg(theme.link_fg)
347        .add_modifier(Modifier::UNDERLINED);
348
349    let mut spans: Vec<Span<'static>> = Vec::new();
350    let mut rest = line;
351
352    while let Some(start) = rest.find("http://").or_else(|| rest.find("https://")) {
353        // Text before the URL
354        if start > 0 {
355            spans.push(Span::raw(rest[..start].to_string()));
356        }
357
358        // Find end of URL (whitespace, angle bracket, or end of string)
359        let url_rest = &rest[start..];
360        let end = url_rest
361            .find(|c: char| c.is_whitespace() || c == '>' || c == ')' || c == ']' || c == '"')
362            .unwrap_or(url_rest.len());
363
364        let url = &url_rest[..end];
365        // Strip trailing punctuation that's probably not part of the URL
366        let url_trimmed = url.trim_end_matches(['.', ',', ';', ':', '!', '?']);
367        let trimmed_len = url_trimmed.len();
368
369        spans.push(Span::styled(url_trimmed.to_string(), link_style));
370
371        // Any trailing punctuation goes back as plain text
372        if trimmed_len < end {
373            spans.push(Span::raw(url_rest[trimmed_len..end].to_string()));
374        }
375
376        rest = &rest[start + end..];
377    }
378
379    // Remaining text after last URL
380    if !rest.is_empty() {
381        spans.push(Span::raw(rest.to_string()));
382    }
383
384    if spans.is_empty() {
385        Line::from(line.to_string())
386    } else {
387        Line::from(spans)
388    }
389}