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 let label_width = 10; 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 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 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 if line == "-- " || line == "--" {
222 flush_quotes(&mut quote_buffer, &mut lines, theme);
223 in_signature = true;
224 continue;
225 }
226
227 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 if line.starts_with('>') {
249 quote_buffer.push(line.to_string());
250 continue;
251 }
252
253 flush_quotes(&mut quote_buffer, &mut lines, theme);
255 lines.push(style_line_with_links(line, theme));
256 }
257
258 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
343fn 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 if start > 0 {
355 spans.push(Span::raw(rest[..start].to_string()));
356 }
357
358 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 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 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 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}