Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 0e333af4e5508097b27a9e23f3e8c0215050683a
Timestamp: Thu, 27 Apr 2023 18:18:09 +0000 (1 year ago)

+416 -37 +/-10 browse
web: add thread replies to post view
1diff --git a/web/build.rs b/web/build.rs
2index 0e5f64d..bd29123 100644
3--- a/web/build.rs
4+++ b/web/build.rs
5 @@ -79,6 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
6 }
7 }
8
9+ compressed.write_all(b"&[")?;
10 for (name, template_path) in templates {
11 let mut templ = OpenOptions::new()
12 .write(false)
13 @@ -98,6 +99,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
14 compressed.write_all(format!("{:?}", compressed_bytes).as_bytes())?;
15 compressed.write_all(b"),")?;
16 }
17+ compressed.write_all(b"]")?;
18
19 commit_sha();
20 Ok(())
21 diff --git a/web/src/lists.rs b/web/src/lists.rs
22index 3c4c9ea..3fe4b6a 100644
23--- a/web/src/lists.rs
24+++ b/web/src/lists.rs
25 @@ -47,13 +47,31 @@ pub async fn list(
26 .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
27
28 let posts = db.list_posts(list.pk, None)?;
29+ let post_map = posts
30+ .iter()
31+ .map(|p| (p.message_id.as_str(), p))
32+ .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
33 let mut hist = months
34 .iter()
35 .map(|m| (m.to_string(), [0usize; 31]))
36 .collect::<HashMap<String, [usize; 31]>>();
37- let posts_ctx = posts
38- .iter()
39- .map(|post| {
40+ let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> =
41+ Default::default();
42+ let mut env_lock = envelopes.write().unwrap();
43+
44+ for post in &posts {
45+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
46+ .expect("Could not parse mail");
47+ env_lock.insert(envelope.hash(), envelope);
48+ }
49+ let mut threads: melib::Threads = melib::Threads::new(posts.len());
50+ drop(env_lock);
51+ threads.amend(&envelopes);
52+ let roots = thread_roots(&envelopes, &mut threads);
53+ let posts_ctx = roots
54+ .into_iter()
55+ .map(|(thread, length, _timestamp)| {
56+ let post = &post_map[&thread.message_id.as_str()];
57 //2019-07-14T14:21:02
58 if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
59 hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
60 @@ -70,7 +88,7 @@ pub async fn list(
61 {
62 subject_ref = subject_ref[2 + list.id.len()..].trim();
63 }
64- minijinja::context! {
65+ let ret = minijinja::context! {
66 pk => post.pk,
67 list => post.list,
68 subject => subject_ref,
69 @@ -79,8 +97,10 @@ pub async fn list(
70 message => post.message,
71 timestamp => post.timestamp,
72 datetime => post.datetime,
73- root_url_prefix => &state.root_url_prefix,
74- }
75+ replies => length.saturating_sub(1),
76+ last_active => thread.datetime,
77+ };
78+ ret
79 })
80 .collect::<Vec<_>>();
81 let crumbs = vec![
82 @@ -123,7 +143,7 @@ pub async fn list_post(
83 auth: AuthContext,
84 State(state): State<Arc<AppState>>,
85 ) -> Result<Html<String>, ResponseError> {
86- let db = Connection::open_db(state.conf.clone())?;
87+ let db = Connection::open_db(state.conf.clone())?.trusted();
88 let Some(list) = (match id {
89 ListPathIdentifier::Pk(id) => db.list(id)?,
90 ListPathIdentifier::Id(id) => db.list_by_id(id)?,
91 @@ -146,6 +166,7 @@ pub async fn list_post(
92 StatusCode::NOT_FOUND,
93 ));
94 };
95+ let thread = super::utils::thread_db(&db, list.pk, &post.message_id);
96 let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
97 .with_status(StatusCode::BAD_REQUEST)?;
98 let body = envelope.body_bytes(post.message.as_slice());
99 @@ -190,6 +211,7 @@ pub async fn list_post(
100 message => post.message,
101 timestamp => post.timestamp,
102 datetime => post.datetime,
103+ thread => thread,
104 root_url_prefix => &state.root_url_prefix,
105 current_user => auth.current_user,
106 user_context => user_context,
107 @@ -485,3 +507,67 @@ pub enum SubscriptionPolicySettings {
108 Request,
109 Custom,
110 }
111+
112+ /// Raw post page.
113+ pub async fn post_raw(
114+ ListPostRawPath(id, msg_id): ListPostRawPath,
115+ State(state): State<Arc<AppState>>,
116+ ) -> Result<String, ResponseError> {
117+ let db = Connection::open_db(state.conf.clone())?.trusted();
118+ let Some(list) = (match id {
119+ ListPathIdentifier::Pk(id) => db.list(id)?,
120+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
121+ }) else {
122+ return Err(ResponseError::new(
123+ "List not found".to_string(),
124+ StatusCode::NOT_FOUND,
125+ ));
126+ };
127+
128+ let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
129+ post
130+ } else {
131+ return Err(ResponseError::new(
132+ format!("Post with Message-ID {} not found", msg_id),
133+ StatusCode::NOT_FOUND,
134+ ));
135+ };
136+ Ok(String::from_utf8_lossy(&post.message).to_string())
137+ }
138+
139+ /// .eml post page.
140+ pub async fn post_eml(
141+ ListPostEmlPath(id, msg_id): ListPostEmlPath,
142+ State(state): State<Arc<AppState>>,
143+ ) -> Result<impl IntoResponse, ResponseError> {
144+ let db = Connection::open_db(state.conf.clone())?.trusted();
145+ let Some(list) = (match id {
146+ ListPathIdentifier::Pk(id) => db.list(id)?,
147+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
148+ }) else {
149+ return Err(ResponseError::new(
150+ "List not found".to_string(),
151+ StatusCode::NOT_FOUND,
152+ ));
153+ };
154+
155+ let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
156+ post
157+ } else {
158+ return Err(ResponseError::new(
159+ format!("Post with Message-ID {} not found", msg_id),
160+ StatusCode::NOT_FOUND,
161+ ));
162+ };
163+ let mut response = post.into_inner().message.into_response();
164+ response.headers_mut().insert(
165+ http::header::CONTENT_TYPE,
166+ http::HeaderValue::from_static("application/octet-stream"),
167+ );
168+ response.headers_mut().insert(
169+ http::header::CONTENT_DISPOSITION,
170+ http::HeaderValue::try_from(format!("attachment; filename=\"{}.eml\"", msg_id)).unwrap(),
171+ );
172+
173+ Ok(response)
174+ }
175 diff --git a/web/src/main.rs b/web/src/main.rs
176index c2bc13f..1fabdcc 100644
177--- a/web/src/main.rs
178+++ b/web/src/main.rs
179 @@ -62,6 +62,8 @@ async fn main() {
180 .route("/", get(root))
181 .typed_get(list)
182 .typed_get(list_post)
183+ .typed_get(post_raw)
184+ .typed_get(post_eml)
185 .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
186 Role::User..,
187 Arc::clone(&login_url),
188 diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs
189index 220880d..7a6d36a 100644
190--- a/web/src/minijinja_utils.rs
191+++ b/web/src/minijinja_utils.rs
192 @@ -37,6 +37,7 @@ lazy_static::lazy_static! {
193 }
194 }
195 add!(function calendarize,
196+ strip_carets,
197 login_path,
198 logout_path,
199 settings_path,
200 @@ -44,7 +45,9 @@ lazy_static::lazy_static! {
201 list_path,
202 list_settings_path,
203 list_edit_path,
204- list_post_path
205+ list_post_path,
206+ post_raw_path,
207+ post_eml_path
208 );
209 add!(filter pluralize);
210 #[cfg(not(feature = "zstd"))]
211 @@ -381,3 +384,16 @@ pub fn pluralize(
212 (true, _, Some(suffix)) => suffix.into(),
213 })
214 }
215+
216+ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
217+ Ok(Value::from(
218+ arg.as_str()
219+ .ok_or_else(|| {
220+ minijinja::Error::new(
221+ minijinja::ErrorKind::InvalidOperation,
222+ format!("argument to strip_carets() is of type {}", arg.kind()),
223+ )
224+ })?
225+ .strip_carets(),
226+ ))
227+ }
228 diff --git a/web/src/minijinja_utils/compressed.rs b/web/src/minijinja_utils/compressed.rs
229index afd8501..8965d02 100644
230--- a/web/src/minijinja_utils/compressed.rs
231+++ b/web/src/minijinja_utils/compressed.rs
232 @@ -17,4 +17,4 @@
233 * along with this program. If not, see <https://www.gnu.org/licenses/>.
234 */
235
236- pub const COMPRESSED: &[(&str, &[u8])] = &[include!("compressed.data")];
237+ pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");
238 diff --git a/web/src/templates/css.html b/web/src/templates/css.html
239index 534d1a1..06560d8 100644
240--- a/web/src/templates/css.html
241+++ b/web/src/templates/css.html
242 @@ -49,7 +49,7 @@
243 text-rendering:optimizeLegibility;
244 -webkit-font-smoothing:antialiased;
245 -moz-osx-font-smoothing:grayscale;
246- font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
247+ font-family:var(--sans-serif-system-stack);
248 font-size:100%;
249 }
250
251 @@ -149,9 +149,12 @@
252 }
253
254 :root {
255+ --emoji-system-stack: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
256 --monospace-system-stack: /* apple */ ui-monospace, SFMono-Regular, Menlo, Monaco,
257 /* windows */ "Cascadia Mono", "Segoe UI Mono", Consolas,
258- /* free unixes */ "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
259+ /* free unixes */ "DejaVu Sans Mono", "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, var(--emoji-system-stack);
260+ --sans-serif-system-stack:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif, var(--emoji-system-stack);
261+ --grotesque-system-stack: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif, var(--emoji-system-stack);
262 --text-primary: CanvasText;
263 --text-faded: GrayText;
264 --horizontal-rule: #88929d;
265 @@ -167,7 +170,7 @@
266 --text-link: #0069c2;
267 --text-invert: #fff;
268 --background-primary: #fff;
269- --background-secondary: #f9f9fb;
270+ --background-secondary: #ebebeb;
271 --background-tertiary: #fff;
272 --background-toc-active: #ebeaea;
273 --background-mark-yellow: #c7b70066;
274 @@ -344,6 +347,7 @@
275 }
276 main.layout>.rightside { grid-area: rightside; }
277 main.layout>footer {
278+ font-family: var(--grotesque-system-stack);
279 grid-area: footer;
280 border-top: 2px inset;
281 margin-block-start: 1rem;
282 @@ -370,6 +374,8 @@
283
284 main.layout>div.header>h1 {
285 margin: 1rem;
286+ font-family: var(--grotesque-system-stack);
287+ font-size: xx-large;
288 }
289
290 main.layout>div.header>div.page-header {
291 @@ -416,6 +422,7 @@
292
293 main.layout>div.header h2.page-title {
294 margin: 1rem 0px;
295+ font-family: var(--grotesque-system-stack);
296 }
297
298 nav.breadcrumbs {
299 @@ -649,6 +656,10 @@
300 margin-inline-end: 1rem;
301 }
302
303+ table.headers {
304+ margin-left: -3vw;
305+ }
306+
307 table.headers tr>th {
308 text-align: right;
309 color: var(--text-faded);
310 @@ -657,29 +668,44 @@
311 table.headers th[scope="row"] {
312 padding-right: .5rem;
313 vertical-align: top;
314+ font-family: var(--grotesque-system-stack);
315 }
316
317 table.headers tr>td {
318 overflow-wrap: break-word;
319 hyphens: auto;
320+ word-wrap: anywhere;
321+ word-break: break-all;
322+ width: 50ch;
323 }
324
325 div.post-body {
326- margin: 1rem;
327+ margin: 1rem 0px;
328 }
329
330 div.post-body>pre {
331- max-width: 69vw;
332 overflow-wrap: break-word;
333 white-space: pre-line;
334- margin-left: min(5rem, 14vw);
335 hyphens: auto;
336+ background-color: var(--background-secondary);
337+ outline: 1rem solid var(--background-secondary);
338+ margin: 2rem 0;
339+ line-height: 1.333;
340+ }
341+
342+ div.post-body:not(:last-child) {
343+ padding-bottom: .5rem;
344+ border-bottom: 1px solid var(--horizontal-rule);
345 }
346
347 td.message-id,
348 span.message-id{
349 color: var(--text-faded);
350 }
351+ .message-id>a {
352+ overflow-wrap: break-word;
353+ hyphens: auto;
354+ }
355 td.message-id:before,
356 span.message-id:before{
357 content: '<';
358 @@ -705,6 +731,13 @@
359 span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
360 color: revert;
361 }
362+ details.reply-details button {
363+ padding: 0.3rem;
364+ font-size: medium;
365+ min-width: 0;
366+ margin-block-start: 0.2rem;
367+ display: inline-block;
368+ }
369
370 ul.lists {
371 padding: 1rem 2rem;
372 diff --git a/web/src/templates/lists/list.html b/web/src/templates/lists/list.html
373index 113b326..4767525 100644
374--- a/web/src/templates/lists/list.html
375+++ b/web/src/templates/lists/list.html
376 @@ -94,6 +94,7 @@
377 <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
378 <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
379 <span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
380+ {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
381 <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
382 </div>
383 {% endfor %}
384 diff --git a/web/src/templates/lists/post.html b/web/src/templates/lists/post.html
385index 27d0c68..3b9324f 100644
386--- a/web/src/templates/lists/post.html
387+++ b/web/src/templates/lists/post.html
388 @@ -38,5 +38,29 @@
389 <div class="post-body">
390 <pre title="E-mail text content">{{ body }}</pre>
391 </div>
392+ {% for (depth, post, body, date) in thread %}
393+ <table class="headers" title="E-mail headers">
394+ <caption class="screen-reader-only">E-mail headers</caption>
395+ <tr>
396+ <th scope="row">From:</th>
397+ <td>{{ post.address }}</td>
398+ </tr>
399+ <tr>
400+ <th scope="row">Date:</th>
401+ <td class="faded">{{ date }}</td>
402+ </tr>
403+ <tr>
404+ <th scope="row">Message-ID:</th>
405+ <td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
406+ </tr>
407+ <tr>
408+ <td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ root_url_prefix }}{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ root_url_prefix }}{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
409+ </tr>
410+ </table>
411+ <div class="post-body">
412+ <pre title="E-mail text content">{{ body }}</pre>
413+ </div>
414+
415+ {% endfor %}
416 </div>
417 {% include "footer.html" %}
418 diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs
419index 5ebd35c..4db1660 100644
420--- a/web/src/typed_paths.rs
421+++ b/web/src/typed_paths.rs
422 @@ -91,6 +91,14 @@ impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
423 pub struct ListPostPath(pub ListPathIdentifier, pub String);
424
425 #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
426+ #[typed_path("/list/:id/posts/:msgid/raw/")]
427+ pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
428+
429+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
430+ #[typed_path("/list/:id/posts/:msgid/eml/")]
431+ pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
432+
433+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
434 #[typed_path("/list/:id/edit/")]
435 pub struct ListEditPath(pub ListPathIdentifier);
436
437 @@ -159,31 +167,39 @@ list_id_impl!(list_edit_path, ListEditPath);
438 list_id_impl!(list_subscribers_path, ListEditSubscribersPath);
439 list_id_impl!(list_candidates_path, ListEditCandidatesPath);
440
441- pub fn list_post_path(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
442- let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
443- format!("<{s}>")
444- }) else {
445- return Err(Error::new(
446- minijinja::ErrorKind::UnknownMethod,
447- "Second argument of list_post_path must be a string."
448- ));
449- };
450+ macro_rules! list_post_impl {
451+ ($ident:ident, $ty:tt) => {
452+ pub fn $ident(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
453+ let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
454+ format!("<{s}>")
455+ }) else {
456+ return Err(Error::new(
457+ minijinja::ErrorKind::UnknownMethod,
458+ "Second argument of list_post_path must be a string."
459+ ));
460+ };
461
462- if let Some(id) = id.as_str() {
463- return Ok(Value::from_safe_string(
464- ListPostPath(ListPathIdentifier::Id(id.to_string()), msg_id)
465- .to_crumb()
466- .to_string(),
467- ));
468- }
469- let pk = id.try_into()?;
470- Ok(Value::from_safe_string(
471- ListPostPath(ListPathIdentifier::Pk(pk), msg_id)
472- .to_crumb()
473- .to_string(),
474- ))
475+ if let Some(id) = id.as_str() {
476+ return Ok(Value::from_safe_string(
477+ $ty(ListPathIdentifier::Id(id.to_string()), msg_id)
478+ .to_crumb()
479+ .to_string(),
480+ ));
481+ }
482+ let pk = id.try_into()?;
483+ Ok(Value::from_safe_string(
484+ $ty(ListPathIdentifier::Pk(pk), msg_id)
485+ .to_crumb()
486+ .to_string(),
487+ ))
488+ }
489+ };
490 }
491
492+ list_post_impl!(list_post_path, ListPostPath);
493+ list_post_impl!(post_raw_path, ListPostRawPath);
494+ list_post_impl!(post_eml_path, ListPostEmlPath);
495+
496 pub mod tsr {
497 use std::{borrow::Cow, convert::Infallible};
498
499 diff --git a/web/src/utils.rs b/web/src/utils.rs
500index 5a2b724..35daa40 100644
501--- a/web/src/utils.rs
502+++ b/web/src/utils.rs
503 @@ -212,3 +212,202 @@ where
504 .map(|s| Value::from_safe_string(s.to_string()))
505 .serialize(ser)
506 }
507+
508+ pub struct ThreadEntry {
509+ pub hash: melib::EnvelopeHash,
510+ pub depth: usize,
511+ pub thread_node: melib::ThreadNodeHash,
512+ pub thread: melib::ThreadHash,
513+ pub from: String,
514+ pub message_id: String,
515+ pub timestamp: u64,
516+ pub datetime: String,
517+ }
518+
519+ pub fn thread_db(
520+ db: &mailpot::Connection,
521+ list: i64,
522+ root: &str,
523+ ) -> Vec<(i64, DbVal<mailpot::models::Post>, String, String)> {
524+ let mut stmt = db
525+ .connection
526+ .prepare(
527+ "WITH RECURSIVE cte_replies AS MATERIALIZED
528+ (
529+ SELECT
530+ pk,
531+ message_id,
532+ REPLACE(
533+ TRIM(
534+ SUBSTR(
535+ CAST(message AS TEXT),
536+ INSTR(
537+ CAST(message AS TEXT),
538+ 'In-Reply-To: '
539+ )
540+ +
541+ LENGTH('in-reply-to: '),
542+ INSTR(
543+ SUBSTR(
544+ CAST(message AS TEXT),
545+ INSTR(
546+ CAST(message AS TEXT),
547+ 'In-Reply-To: ')
548+ +
549+ LENGTH('in-reply-to: ')
550+ ),
551+ '>'
552+ )
553+ )
554+ ),
555+ ' ',
556+ ''
557+ ) AS in_reply_to,
558+ INSTR(
559+ CAST(message AS TEXT),
560+ 'In-Reply-To: '
561+ ) AS offset
562+ FROM post
563+ WHERE
564+ offset > 0
565+ UNION
566+ SELECT
567+ pk,
568+ message_id,
569+ NULL AS in_reply_to,
570+ INSTR(
571+ CAST(message AS TEXT),
572+ 'In-Reply-To: '
573+ ) AS offset
574+ FROM post
575+ WHERE
576+ offset = 0
577+ ),
578+ cte_thread(parent, root, depth) AS (
579+ SELECT DISTINCT
580+ message_id AS parent,
581+ message_id AS root,
582+ 0 AS depth
583+ FROM cte_replies
584+ WHERE
585+ in_reply_to IS NULL
586+ UNION ALL
587+ SELECT
588+ t.message_id AS parent,
589+ cte_thread.root AS root,
590+ (cte_thread.depth + 1) AS depth
591+ FROM cte_replies
592+ AS t
593+ JOIN
594+ cte_thread
595+ ON cte_thread.parent = t.in_reply_to
596+ WHERE t.in_reply_to IS NOT NULL
597+ )
598+ SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
599+ )
600+ .unwrap();
601+ let iter = stmt
602+ .query_map(rusqlite::params![root], |row| {
603+ let parent: String = row.get("parent")?;
604+ let root: String = row.get("root")?;
605+ let depth: i64 = row.get("depth")?;
606+ Ok((parent, root, depth))
607+ })
608+ .unwrap();
609+ let mut ret = vec![];
610+ for post in iter {
611+ let post = post.unwrap();
612+ ret.push(post);
613+ }
614+ let posts = db.list_posts(list, None).unwrap();
615+ ret.into_iter()
616+ .filter_map(|(m, _, depth)| {
617+ posts
618+ .iter()
619+ .find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets())
620+ .map(|p| {
621+ let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap();
622+ let body = envelope.body_bytes(p.message.as_slice());
623+ let body_text = body.text();
624+ let date = envelope.date_as_str().to_string();
625+ (depth, p.clone(), body_text, date)
626+ })
627+ })
628+ .skip(1)
629+ .collect()
630+ }
631+
632+ pub fn thread(
633+ envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
634+ threads: &melib::Threads,
635+ root_env_hash: melib::EnvelopeHash,
636+ ) -> Vec<ThreadEntry> {
637+ let env_lock = envelopes.read().unwrap();
638+ let thread = threads.envelope_to_thread[&root_env_hash];
639+ let mut ret = vec![];
640+ for (depth, t) in threads.thread_group_iter(thread) {
641+ let hash = threads.thread_nodes[&t].message.unwrap();
642+ ret.push(ThreadEntry {
643+ hash,
644+ depth,
645+ thread_node: t,
646+ thread,
647+ message_id: env_lock[&hash].message_id().to_string(),
648+ from: env_lock[&hash].field_from_to_string(),
649+ datetime: env_lock[&hash].date_as_str().to_string(),
650+ timestamp: env_lock[&hash].timestamp,
651+ });
652+ }
653+ ret
654+ }
655+
656+ pub fn thread_roots(
657+ envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
658+ threads: &mut melib::Threads,
659+ ) -> Vec<(ThreadEntry, usize, u64)> {
660+ let items = threads.roots();
661+ let env_lock = envelopes.read().unwrap();
662+ let mut ret = vec![];
663+ 'items_for_loop: for thread in items {
664+ let mut iter_ptr = threads.thread_ref(thread).root();
665+ let thread_node = &threads.thread_nodes()[&iter_ptr];
666+ let root_env_hash = if let Some(h) = thread_node.message().or_else(|| {
667+ if thread_node.children().is_empty() {
668+ return None;
669+ }
670+ iter_ptr = thread_node.children()[0];
671+ while threads.thread_nodes()[&iter_ptr].message().is_none() {
672+ if threads.thread_nodes()[&iter_ptr].children().is_empty() {
673+ return None;
674+ }
675+ iter_ptr = threads.thread_nodes()[&iter_ptr].children()[0];
676+ }
677+ threads.thread_nodes()[&iter_ptr].message()
678+ }) {
679+ h
680+ } else {
681+ continue 'items_for_loop;
682+ };
683+ if !env_lock.contains_key(&root_env_hash) {
684+ panic!("key = {}", root_env_hash);
685+ }
686+ let envelope: &melib::Envelope = &env_lock[&root_env_hash];
687+ let tref = threads.thread_ref(thread);
688+ ret.push((
689+ ThreadEntry {
690+ hash: root_env_hash,
691+ depth: 0,
692+ thread_node: iter_ptr,
693+ thread,
694+ message_id: envelope.message_id().to_string(),
695+ from: envelope.field_from_to_string(),
696+ datetime: envelope.date_as_str().to_string(),
697+ timestamp: envelope.timestamp,
698+ },
699+ tref.len,
700+ tref.date,
701+ ));
702+ }
703+ ret.sort_by_key(|(_, _, key)| std::cmp::Reverse(*key));
704+ ret
705+ }