mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
feat: add a setting to turn conversation view off
This commit is contained in:
1 parent
ee66aad68c
commit
2cdc2a24ba
55 files changed
+9591
-7326
No files matched your search
@@ -152,7 +152,8 @@ for Work and orange for Support.
|
||||
### 🧵 Conversation threads
|
||||
|
||||
Replies stay grouped in chronological order, with the active message expanded
|
||||
inside the reading pane.
|
||||
inside the reading pane. To list, open and act on every message on its own, turn
|
||||
off **Conversation view** in Settings → General.
|
||||
|
||||
Use **Up/Down** to navigate messages, **Home/End** to jump to the first or last
|
||||
loaded message, and **Page Up/Page Down** to move a page at a time. **Enter**
|
||||
|
||||
@@ -121,7 +121,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let list_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let after_list = sample(&core).await?;
|
||||
let started = Instant::now();
|
||||
let badges = core.mailbox_badge_counts().await?;
|
||||
let badges = core
|
||||
.mailbox_badge_counts(flectar_mail_core::models::MailGrouping::Conversations)
|
||||
.await?;
|
||||
let badges_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let after_badges = sample(&core).await?;
|
||||
let deep_search = if deep_search_after_badges {
|
||||
@@ -270,7 +272,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
let list_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let started = Instant::now();
|
||||
let warm_badges = core.mailbox_badge_counts().await?;
|
||||
let warm_badges = core
|
||||
.mailbox_badge_counts(flectar_mail_core::models::MailGrouping::Conversations)
|
||||
.await?;
|
||||
let badges_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(
|
||||
warm_page
|
||||
@@ -300,7 +304,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
let first_list_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let started = Instant::now();
|
||||
let reopened_badges = core.mailbox_badge_counts().await?;
|
||||
let reopened_badges = core
|
||||
.mailbox_badge_counts(flectar_mail_core::models::MailGrouping::Conversations)
|
||||
.await?;
|
||||
let first_badges_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(
|
||||
reopened_page
|
||||
@@ -319,7 +325,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
let repeat_list_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let started = Instant::now();
|
||||
let repeated_badges = core.mailbox_badge_counts().await?;
|
||||
let repeated_badges = core
|
||||
.mailbox_badge_counts(flectar_mail_core::models::MailGrouping::Conversations)
|
||||
.await?;
|
||||
let repeat_badges_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(
|
||||
repeated_page
|
||||
|
||||
@@ -84,7 +84,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
assert_eq!(initial.threads.last().unwrap().id, old_anchor.thread_id);
|
||||
assert_eq!(old_second.threads[0].id, old_anchor.thread_id - 1);
|
||||
let initial_badges = db
|
||||
.read(|conn| repo::counts::mailbox_badge_counts(conn))
|
||||
.read(|conn| {
|
||||
repo::counts::mailbox_badge_counts(
|
||||
conn,
|
||||
flectar_mail_core::models::MailGrouping::Conversations,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(initial_badges.len(), 1);
|
||||
assert_eq!(initial_badges[0].inbox, 50_000);
|
||||
@@ -175,7 +180,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
.await?;
|
||||
|
||||
let after_badges = db
|
||||
.read(|conn| repo::counts::mailbox_badge_counts(conn))
|
||||
.read(|conn| {
|
||||
repo::counts::mailbox_badge_counts(
|
||||
conn,
|
||||
flectar_mail_core::models::MailGrouping::Conversations,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(after_badges.len(), 2);
|
||||
assert_eq!(after_badges[0].account_id, 1);
|
||||
|
||||
@@ -412,7 +412,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
let started = Instant::now();
|
||||
let badges = db
|
||||
.read(|conn| repo::counts::mailbox_badge_counts(conn))
|
||||
.read(|conn| {
|
||||
repo::counts::mailbox_badge_counts(
|
||||
conn,
|
||||
flectar_mail_core::models::MailGrouping::Conversations,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
let badge_elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let process_after_badge = process_memory()?;
|
||||
|
||||
@@ -40,6 +40,7 @@ const MIGRATIONS: &[Migration] = &[
|
||||
Migration::Plain(include_str!("migrations/021_verification_codes.sql")),
|
||||
Migration::Plain(include_str!("migrations/022_search_leftovers.sql")),
|
||||
Migration::TableRebuild(include_str!("migrations/023_local_mail.sql")),
|
||||
Migration::Plain(include_str!("migrations/024_message_list_indexes.sql")),
|
||||
];
|
||||
pub const LATEST_VERSION: i64 = MIGRATIONS.len() as i64;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Mail lists without conversations page through messages newest first, like
|
||||
-- the conversation timeline on threads: within an account, and across all
|
||||
-- of them.
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_account_recent
|
||||
ON messages(account_id, date DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_recent
|
||||
ON messages(date DESC, id DESC);
|
||||
@@ -12,7 +12,8 @@ pub mod snapshot;
|
||||
use crate::{
|
||||
error::{CoreError, Result},
|
||||
models::{
|
||||
AppIcon, StartupPresentation, normalized_window_frame, normalized_workspace_list_pane_width,
|
||||
AppIcon, MailGrouping, StartupPresentation, normalized_window_frame,
|
||||
normalized_workspace_list_pane_width,
|
||||
},
|
||||
};
|
||||
use rusqlite::config::DbConfig;
|
||||
@@ -224,6 +225,13 @@ pub(crate) fn startup_presentation(path: &Path) -> StartupPresentation {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(AppIcon::parse)
|
||||
.unwrap_or_default(),
|
||||
grouping: match value
|
||||
.get("conversationView")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
{
|
||||
Some(false) => MailGrouping::Messages,
|
||||
_ => MailGrouping::Conversations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +812,26 @@ mod startup_preference_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_grouping_follows_the_conversation_view_setting() {
|
||||
use crate::models::MailGrouping;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("mail.db");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
INSERT INTO app_settings (key, value)
|
||||
VALUES ('settings', '{\"conversationView\": false}');",
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
assert_eq!(startup_presentation(&path).grouping, MailGrouping::Messages);
|
||||
assert_eq!(
|
||||
startup_presentation(&temp.path().join("missing.db")).grouping,
|
||||
MailGrouping::Conversations
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_width_is_bounded_and_missing_state_is_non_fatal() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! threads.rs so the two evolve independently.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Label, MailboxBadgeCounts, SplitRule, UnreadCounts, roles};
|
||||
use crate::models::{Label, MailGrouping, MailboxBadgeCounts, SplitRule, UnreadCounts, roles};
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -25,6 +25,25 @@ const INBOX_BADGE_SQL: &str = "SELECT t.account_id, COUNT(*)
|
||||
))
|
||||
GROUP BY t.account_id";
|
||||
|
||||
/// Unread messages in an inbox outside snoozed conversations, for lists that
|
||||
/// show each message on its own. Mirrors the message-level inbox filter of
|
||||
/// `threads::list_messages`.
|
||||
const INBOX_MESSAGE_BADGE_SQL: &str = "SELECT m.account_id, COUNT(*)
|
||||
FROM messages m
|
||||
JOIN accounts a ON a.id = m.account_id
|
||||
LEFT JOIN snoozes s ON s.thread_id = m.thread_id
|
||||
WHERE m.is_read = 0
|
||||
AND m.is_draft = 0
|
||||
AND m.thread_id IS NOT NULL
|
||||
AND s.thread_id IS NULL
|
||||
AND (m.folder_id IN (SELECT id FROM folders WHERE role = 'inbox')
|
||||
OR (a.provider = 'gmail' AND EXISTS (
|
||||
SELECT 1 FROM message_folders mf
|
||||
JOIN folders f ON f.id = mf.folder_id
|
||||
WHERE mf.message_id = m.id AND f.role = 'inbox'
|
||||
)))
|
||||
GROUP BY m.account_id";
|
||||
|
||||
/// Query-plan details for the inbox badge path used by `mailbox_badge_counts`.
|
||||
pub fn inbox_badge_query_plan(conn: &Connection) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {INBOX_BADGE_SQL}"))?;
|
||||
@@ -232,7 +251,12 @@ pub fn unread_counts(
|
||||
/// This deliberately avoids calling `unread_counts` once per account: the
|
||||
/// Slint sidebar does not render split/label badges, and repeating those
|
||||
/// scalar queries would make a live refresh proportional to account count.
|
||||
pub fn mailbox_badge_counts(conn: &Connection) -> Result<Vec<MailboxBadgeCounts>> {
|
||||
/// Badges count what the mail list shows: conversations, or messages.
|
||||
pub fn mailbox_badge_counts(
|
||||
conn: &Connection,
|
||||
grouping: MailGrouping,
|
||||
) -> Result<Vec<MailboxBadgeCounts>> {
|
||||
let messages = grouping == MailGrouping::Messages;
|
||||
let mut counts = conn
|
||||
.prepare("SELECT id FROM accounts ORDER BY sort_order, id")?
|
||||
.query_map([], |row| {
|
||||
@@ -252,7 +276,11 @@ pub fn mailbox_badge_counts(conn: &Connection) -> Result<Vec<MailboxBadgeCounts>
|
||||
|
||||
// The inbox predicate mirrors `Q::inbox`, but grouping by account returns
|
||||
// every native sidebar section without repeating the scan per account.
|
||||
let mut inbox = conn.prepare(INBOX_BADGE_SQL)?;
|
||||
let mut inbox = conn.prepare(if messages {
|
||||
INBOX_MESSAGE_BADGE_SQL
|
||||
} else {
|
||||
INBOX_BADGE_SQL
|
||||
})?;
|
||||
for row in inbox.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? {
|
||||
let (account_id, value) = row?;
|
||||
if let Some(index) = positions.get(&account_id) {
|
||||
@@ -260,11 +288,15 @@ pub fn mailbox_badge_counts(conn: &Connection) -> Result<Vec<MailboxBadgeCounts>
|
||||
}
|
||||
}
|
||||
|
||||
let mut starred = conn.prepare(
|
||||
let mut starred = conn.prepare(if messages {
|
||||
"SELECT account_id, COUNT(*) FROM messages
|
||||
WHERE is_read = 0 AND is_draft = 0 AND is_starred = 1 AND thread_id IS NOT NULL
|
||||
GROUP BY account_id"
|
||||
} else {
|
||||
"SELECT account_id, COUNT(*) FROM threads
|
||||
WHERE unread_count > 0 AND starred_count > 0
|
||||
GROUP BY account_id",
|
||||
)?;
|
||||
GROUP BY account_id"
|
||||
})?;
|
||||
for row in starred.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? {
|
||||
let (account_id, value) = row?;
|
||||
if let Some(index) = positions.get(&account_id) {
|
||||
@@ -274,23 +306,24 @@ pub fn mailbox_badge_counts(conn: &Connection) -> Result<Vec<MailboxBadgeCounts>
|
||||
|
||||
// Draft folders are normally tiny. Starting from those folders uses the
|
||||
// existing folder/message indexes and avoids inspecting every inbox row.
|
||||
let mut drafts = conn.prepare(
|
||||
"SELECT account_id, COUNT(DISTINCT thread_id)
|
||||
let counted = if messages { "message_id" } else { "thread_id" };
|
||||
let mut drafts = conn.prepare(&format!(
|
||||
"SELECT account_id, COUNT(DISTINCT {counted})
|
||||
FROM (
|
||||
SELECT m.account_id, m.thread_id
|
||||
SELECT m.account_id, m.thread_id, m.id AS message_id
|
||||
FROM folders f
|
||||
JOIN messages m ON m.folder_id = f.id
|
||||
WHERE f.role = 'drafts' AND m.is_draft = 1 AND m.thread_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT m.account_id, m.thread_id
|
||||
SELECT m.account_id, m.thread_id, m.id AS message_id
|
||||
FROM folders f
|
||||
JOIN message_folders mf ON mf.folder_id = f.id
|
||||
JOIN messages m ON m.id = mf.message_id
|
||||
JOIN accounts a ON a.id = m.account_id AND a.provider = 'gmail'
|
||||
WHERE f.role = 'drafts' AND m.is_draft = 1 AND m.thread_id IS NOT NULL
|
||||
)
|
||||
GROUP BY account_id",
|
||||
)?;
|
||||
GROUP BY account_id"
|
||||
))?;
|
||||
for row in drafts.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? {
|
||||
let (account_id, value) = row?;
|
||||
if let Some(index) = positions.get(&account_id) {
|
||||
@@ -416,7 +449,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let badges = mailbox_badge_counts(&conn).unwrap();
|
||||
let badges = mailbox_badge_counts(&conn, MailGrouping::Conversations).unwrap();
|
||||
assert_eq!(
|
||||
badges,
|
||||
vec![
|
||||
@@ -437,7 +470,7 @@ mod tests {
|
||||
|
||||
conn.execute("UPDATE threads SET unread_count = 0 WHERE id = 1", [])
|
||||
.unwrap();
|
||||
let badges = mailbox_badge_counts(&conn).unwrap();
|
||||
let badges = mailbox_badge_counts(&conn, MailGrouping::Conversations).unwrap();
|
||||
assert_eq!(badges[0].inbox, 0);
|
||||
assert_eq!(badges[0].starred, 0);
|
||||
assert_eq!(badges[0].drafts, 1);
|
||||
@@ -504,7 +537,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mailbox_badge_counts(&conn).unwrap(),
|
||||
mailbox_badge_counts(&conn, MailGrouping::Conversations).unwrap(),
|
||||
vec![
|
||||
MailboxBadgeCounts {
|
||||
account_id: 1,
|
||||
@@ -570,4 +603,27 @@ mod tests {
|
||||
assert_eq!(c.splits["7"], 1);
|
||||
assert_eq!(c.labels["5"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_badges_count_unread_messages() {
|
||||
let conn = crate::db::testutil::conn();
|
||||
crate::db::testutil::seed_account(&conn);
|
||||
let (thread_id, _) =
|
||||
crate::db::testutil::seed_message(&conn, "a@b.dev", "two unread", false);
|
||||
let (own_thread, reply) =
|
||||
crate::db::testutil::seed_message(&conn, "a@b.dev", "re: two unread", false);
|
||||
conn.execute(
|
||||
"UPDATE messages SET thread_id = ?1, is_starred = 1, uid = 10000 + id WHERE id = ?2",
|
||||
params![thread_id, reply],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", [own_thread])
|
||||
.unwrap();
|
||||
crate::db::repo::threads::recompute(&conn, thread_id).unwrap();
|
||||
|
||||
let conversations = mailbox_badge_counts(&conn, MailGrouping::Conversations).unwrap();
|
||||
assert_eq!((conversations[0].inbox, conversations[0].starred), (1, 1));
|
||||
let messages = mailbox_badge_counts(&conn, MailGrouping::Messages).unwrap();
|
||||
assert_eq!((messages[0].inbox, messages[0].starred), (2, 1));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::error::Result;
|
||||
use crate::models::{SearchCursor, SearchPage, ThreadSummary, roles};
|
||||
use crate::models::{
|
||||
MessageCursor, MessageSearchCursor, MessageSearchPage, SearchCursor, SearchPage, ThreadSummary,
|
||||
roles,
|
||||
};
|
||||
use crate::search::ParsedQuery;
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::HashMap;
|
||||
@@ -631,6 +634,106 @@ fn chronological_page_for_query(
|
||||
})
|
||||
}
|
||||
|
||||
/// One page of chronological matches as single messages, for mail lists that
|
||||
/// do not group conversations. Matching is the same as `chronological_page`,
|
||||
/// including falling back to any-term matching when every term matched none.
|
||||
pub fn chronological_message_page(
|
||||
conn: &Connection,
|
||||
q: &ParsedQuery,
|
||||
cursor: Option<MessageSearchCursor>,
|
||||
limit: i64,
|
||||
) -> Result<MessageSearchPage> {
|
||||
if limit <= 0 {
|
||||
return Ok(MessageSearchPage {
|
||||
messages: Vec::new(),
|
||||
next_cursor: None,
|
||||
});
|
||||
}
|
||||
let relaxed = cursor.is_some_and(|cursor| cursor.relaxed);
|
||||
let mut query = q.clone();
|
||||
if relaxed {
|
||||
query.fts = query.fts_or.clone();
|
||||
}
|
||||
let position = cursor.map(|cursor| cursor.position);
|
||||
let mut page = chronological_message_page_for_query(conn, &query, position, limit, relaxed)?;
|
||||
if page.messages.is_empty() && cursor.is_none() && !q.fts.is_empty() && q.fts_or != q.fts {
|
||||
query.fts = query.fts_or.clone();
|
||||
page = chronological_message_page_for_query(conn, &query, None, limit, true)?;
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// Messages page with a keyset cursor in SQL: unlike conversations, no match
|
||||
/// can hide behind an earlier one, so nothing has to be scanned twice.
|
||||
fn chronological_message_page_for_query(
|
||||
conn: &Connection,
|
||||
q: &ParsedQuery,
|
||||
cursor: Option<MessageCursor>,
|
||||
limit: i64,
|
||||
relaxed: bool,
|
||||
) -> Result<MessageSearchPage> {
|
||||
let mut where_clauses = vec!["m.thread_id IS NOT NULL".to_owned()];
|
||||
let mut bind: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let fts_join = if q.fts.is_empty() {
|
||||
""
|
||||
} else {
|
||||
bind.push(Box::new(q.fts.clone()));
|
||||
where_clauses.push(format!("messages_fts MATCH ?{}", bind.len()));
|
||||
"JOIN messages_fts ON messages_fts.rowid = m.id"
|
||||
};
|
||||
append_operator_clauses(q, &mut where_clauses, &mut bind);
|
||||
if where_clauses.len() == 1 {
|
||||
return Ok(MessageSearchPage {
|
||||
messages: Vec::new(),
|
||||
next_cursor: None,
|
||||
});
|
||||
}
|
||||
append_account_clauses(q, &mut where_clauses, &mut bind);
|
||||
if let Some(cursor) = cursor {
|
||||
bind.push(Box::new(cursor.date));
|
||||
let date_parameter = bind.len();
|
||||
bind.push(Box::new(cursor.message_id));
|
||||
where_clauses.push(format!(
|
||||
"(m.date, m.id) < (?{date_parameter}, ?{})",
|
||||
bind.len()
|
||||
));
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.date FROM messages m {fts_join}
|
||||
WHERE {} ORDER BY m.date DESC, m.id DESC LIMIT {}",
|
||||
where_clauses.join(" AND "),
|
||||
limit + 1,
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
|
||||
bind.iter().map(|value| value.as_ref()).collect();
|
||||
let mut matches = stmt
|
||||
.query_map(params_ref.as_slice(), |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let page_size = usize::try_from(limit).unwrap_or(usize::MAX);
|
||||
let next_cursor = if matches.len() > page_size {
|
||||
matches.truncate(page_size);
|
||||
matches
|
||||
.last()
|
||||
.map(|(message_id, date)| MessageSearchCursor {
|
||||
position: MessageCursor {
|
||||
date: *date,
|
||||
message_id: *message_id,
|
||||
},
|
||||
relaxed,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let message_ids = matches.into_iter().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
Ok(MessageSearchPage {
|
||||
messages: super::threads::get_message_summaries(conn, &message_ids)?,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hybrid search: fuse the lexical (bm25) and semantic (vector KNN) branches
|
||||
/// with RRF, preserving operator filters, and hydrate to thread summaries.
|
||||
/// `vec_hits` are (message_id, score) from the in-memory index, score-sorted;
|
||||
@@ -898,4 +1001,44 @@ mod tests {
|
||||
assert_eq!(matches("obsoleteuniqueterm"), 0);
|
||||
assert_eq!(matches("replacementuniqueterm"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_search_lists_every_matching_message_and_pages() {
|
||||
let c = testutil::conn();
|
||||
testutil::seed_account(&c);
|
||||
let (thread_id, first) = testutil::seed_message(&c, "a@b.dev", "rosterplan draft", false);
|
||||
let (own_thread, reply) = testutil::seed_message(&c, "a@b.dev", "re: rosterplan", false);
|
||||
c.execute(
|
||||
"UPDATE messages SET thread_id = ?1, date = 2000, uid = 10000 + id WHERE id = ?2",
|
||||
params![thread_id, reply],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute("DELETE FROM threads WHERE id = ?1", [own_thread])
|
||||
.unwrap();
|
||||
let (_, unrelated) = testutil::seed_message(&c, "a@b.dev", "lunch", false);
|
||||
for id in [first, reply, unrelated] {
|
||||
index_message(&c, id).unwrap();
|
||||
}
|
||||
let query = crate::search::parse("rosterplan");
|
||||
|
||||
// The conversation matches once; its messages match one by one.
|
||||
assert_eq!(
|
||||
chronological_page(&c, &query, None, 10)
|
||||
.unwrap()
|
||||
.threads
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let page = chronological_message_page(&c, &query, None, 1).unwrap();
|
||||
assert_eq!(
|
||||
page.messages.iter().map(|m| m.id).collect::<Vec<_>>(),
|
||||
[reply]
|
||||
);
|
||||
let rest = chronological_message_page(&c, &query, page.next_cursor, 1).unwrap();
|
||||
assert_eq!(
|
||||
rest.messages.iter().map(|m| m.id).collect::<Vec<_>>(),
|
||||
[first]
|
||||
);
|
||||
assert!(rest.next_cursor.is_none());
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ mod tests {
|
||||
assert!(d.show_avatars);
|
||||
assert!(d.group_mail_by_date);
|
||||
assert!(d.show_tabs);
|
||||
assert!(d.conversation_view);
|
||||
assert!(d.collect_outgoing_contacts);
|
||||
assert!(!d.collect_incoming_contacts);
|
||||
assert!(!d.contact_suggest_all_accounts);
|
||||
@@ -72,6 +73,7 @@ mod tests {
|
||||
s.show_avatars = false;
|
||||
s.group_mail_by_date = false;
|
||||
s.show_tabs = false;
|
||||
s.conversation_view = false;
|
||||
s.workspace_layout = "minimal".into();
|
||||
s.contact_suggest_all_accounts = true;
|
||||
s.theme_preset = "teal".into();
|
||||
@@ -123,6 +125,7 @@ mod tests {
|
||||
assert!(!back.show_avatars);
|
||||
assert!(!back.group_mail_by_date);
|
||||
assert!(!back.show_tabs);
|
||||
assert!(!back.conversation_view);
|
||||
assert!(back.contact_suggest_all_accounts);
|
||||
assert_eq!(back.workspace_layout, "minimal");
|
||||
assert_eq!(back.theme_preset, "teal");
|
||||
@@ -161,6 +164,7 @@ mod tests {
|
||||
assert!(s.show_avatars);
|
||||
assert!(s.group_mail_by_date);
|
||||
assert!(s.show_tabs);
|
||||
assert!(s.conversation_view);
|
||||
assert!(s.collect_outgoing_contacts);
|
||||
assert!(!s.collect_incoming_contacts);
|
||||
assert!(!s.contact_suggest_all_accounts);
|
||||
|
||||
@@ -299,15 +299,32 @@ const AUTO_BUCKET: &str = "(SELECT m.is_automated FROM messages m
|
||||
WHERE m.thread_id = t.id AND m.is_draft = 0 AND m.is_outgoing = 0
|
||||
ORDER BY m.date DESC, m.id DESC LIMIT 1) = 1";
|
||||
|
||||
/// The WHERE clause for one mail-list scope. Conversations match when any of
|
||||
/// their messages does; messages match on their own folders, flags and labels.
|
||||
/// Snooze and tab routing belong to the conversation in both groupings, so a
|
||||
/// message list joins each message's thread as `t`.
|
||||
fn build_filter(
|
||||
args: &ListArgs,
|
||||
grouping: MailGrouping,
|
||||
include_cursor: bool,
|
||||
) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut bind: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let messages = grouping == MailGrouping::Messages;
|
||||
|
||||
let role_exists = |role: &str, bind: &mut Vec<Box<dyn rusqlite::types::ToSql>>| {
|
||||
bind.push(Box::new(role.to_string()));
|
||||
if messages {
|
||||
return format!(
|
||||
"(EXISTS (SELECT 1 FROM folders f WHERE f.id = m.folder_id AND f.role = ?{0})
|
||||
OR (a.provider = 'gmail' AND EXISTS (
|
||||
SELECT 1 FROM message_folders mf
|
||||
JOIN folders f ON f.id = mf.folder_id
|
||||
WHERE mf.message_id = m.id AND f.role = ?{0}
|
||||
)))",
|
||||
bind.len(),
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"(EXISTS (SELECT 1 FROM messages m JOIN folders f ON f.id = m.folder_id
|
||||
WHERE m.thread_id = t.id AND f.role = ?{0})
|
||||
@@ -328,12 +345,17 @@ fn build_filter(
|
||||
where_clauses.push(c);
|
||||
where_clauses.push("s.thread_id IS NULL".into());
|
||||
}
|
||||
View::Starred if messages => where_clauses.push("m.is_starred = 1".into()),
|
||||
View::Starred => where_clauses.push("t.starred_count > 0".into()),
|
||||
View::Snoozed => where_clauses.push("s.thread_id IS NOT NULL".into()),
|
||||
View::Sent => {
|
||||
let c = role_exists(roles::SENT, &mut bind);
|
||||
where_clauses.push(c);
|
||||
}
|
||||
View::Drafts if messages => {
|
||||
let c = role_exists(roles::DRAFTS, &mut bind);
|
||||
where_clauses.push(format!("m.is_draft = 1 AND {c}"));
|
||||
}
|
||||
View::Drafts => {
|
||||
bind.push(Box::new(roles::DRAFTS.to_string()));
|
||||
where_clauses.push(format!(
|
||||
@@ -366,13 +388,18 @@ fn build_filter(
|
||||
View::All => {}
|
||||
}
|
||||
|
||||
let account_column = if messages {
|
||||
"m.account_id"
|
||||
} else {
|
||||
"t.account_id"
|
||||
};
|
||||
if let Some(acc) = args.account_id {
|
||||
bind.push(Box::new(acc));
|
||||
where_clauses.push(format!("t.account_id = ?{}", bind.len()));
|
||||
where_clauses.push(format!("{account_column} = ?{}", bind.len()));
|
||||
}
|
||||
if let Some(account_ids) = &args.account_ids {
|
||||
where_clauses.push(super::search::account_set_clause(
|
||||
"t.account_id",
|
||||
account_column,
|
||||
account_ids,
|
||||
&mut bind,
|
||||
));
|
||||
@@ -394,6 +421,14 @@ fn build_filter(
|
||||
bind.push(Box::new(format!("label:{id}")));
|
||||
where_clauses.push(format!("t.routed_tab = ?{}", bind.len()));
|
||||
}
|
||||
Some(TabFilter::ManualLabel(id)) if messages => {
|
||||
bind.push(Box::new(*id));
|
||||
where_clauses.push(format!(
|
||||
"EXISTS (SELECT 1 FROM message_labels ml
|
||||
WHERE ml.message_id = m.id AND ml.label_id = ?{})",
|
||||
bind.len()
|
||||
));
|
||||
}
|
||||
Some(TabFilter::ManualLabel(id)) => {
|
||||
bind.push(Box::new(*id));
|
||||
where_clauses.push(format!(
|
||||
@@ -404,7 +439,19 @@ fn build_filter(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(folder_id) = args.folder_id {
|
||||
if let Some(folder_id) = args.folder_id
|
||||
&& messages
|
||||
{
|
||||
bind.push(Box::new(folder_id));
|
||||
where_clauses.push(format!(
|
||||
"(m.folder_id = ?{0}
|
||||
OR (a.provider = 'gmail' AND EXISTS (
|
||||
SELECT 1 FROM message_folders mf
|
||||
WHERE mf.message_id = m.id AND mf.folder_id = ?{0}
|
||||
)))",
|
||||
bind.len()
|
||||
));
|
||||
} else if let Some(folder_id) = args.folder_id {
|
||||
bind.push(Box::new(folder_id));
|
||||
where_clauses.push(format!(
|
||||
"EXISTS (SELECT 1 FROM messages m
|
||||
@@ -420,7 +467,10 @@ fn build_filter(
|
||||
bind.len()
|
||||
));
|
||||
}
|
||||
if include_cursor && let Some(cur) = args.cursor {
|
||||
if include_cursor
|
||||
&& !messages
|
||||
&& let Some(cur) = args.cursor
|
||||
{
|
||||
bind.push(Box::new(cur.last_message_at));
|
||||
let timestamp_parameter = bind.len();
|
||||
bind.push(Box::new(cur.thread_id));
|
||||
@@ -463,7 +513,7 @@ pub fn list(conn: &Connection, args: &ListArgs) -> Result<ThreadPage> {
|
||||
}
|
||||
|
||||
fn list_sql(args: &ListArgs) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
|
||||
let (where_sql, bind) = build_filter(args, true);
|
||||
let (where_sql, bind) = build_filter(args, MailGrouping::Conversations, true);
|
||||
(
|
||||
format!(
|
||||
"{SUMMARY_SELECT} {where_sql}
|
||||
@@ -488,7 +538,7 @@ pub fn list_query_plan(conn: &Connection, args: &ListArgs) -> Result<Vec<String>
|
||||
/// thread summaries or walking cursor pages. Keeping this on the same filter
|
||||
/// builder as `list` prevents sidebar totals from drifting from list contents.
|
||||
pub fn count(conn: &Connection, args: &ListArgs) -> Result<usize> {
|
||||
let (where_sql, bind) = build_filter(args, false);
|
||||
let (where_sql, bind) = build_filter(args, MailGrouping::Conversations, false);
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)
|
||||
FROM threads t
|
||||
@@ -502,6 +552,186 @@ pub fn count(conn: &Connection, args: &ListArgs) -> Result<usize> {
|
||||
Ok(count.max(0) as usize)
|
||||
}
|
||||
|
||||
fn message_summary_from_row(row: &Row) -> rusqlite::Result<MessageSummary> {
|
||||
Ok(MessageSummary {
|
||||
id: row.get("id")?,
|
||||
thread_id: row.get("thread_id")?,
|
||||
account_id: row.get("account_id")?,
|
||||
account_email: row.get("account_email")?,
|
||||
account_addresses: parse_json_column(&row.get::<_, String>("account_addresses")?, 4)?,
|
||||
subject: row.get("subject")?,
|
||||
snippet: row.get("snippet")?,
|
||||
from: Address {
|
||||
name: row.get("from_name")?,
|
||||
email: row
|
||||
.get::<_, Option<String>>("from_addr")?
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
to: parse_addrs(&row.get::<_, String>("to_json")?, 9)?,
|
||||
date: row.get("date")?,
|
||||
is_read: row.get::<_, i64>("is_read")? != 0,
|
||||
is_starred: row.get::<_, i64>("is_starred")? != 0,
|
||||
is_draft: row.get::<_, i64>("is_draft")? != 0,
|
||||
is_outgoing: row.get::<_, i64>("is_outgoing")? != 0,
|
||||
has_attachments: row.get::<_, i64>("has_attachments")? != 0,
|
||||
has_replied: row.get::<_, i64>("has_replied")? != 0,
|
||||
snoozed_until: row.get("snoozed_until")?,
|
||||
labels: parse_id_list(
|
||||
&row.get::<_, Option<String>>("label_ids")?
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
verification_code: row.get("verification_code")?,
|
||||
first_attachment: row
|
||||
.get::<_, Option<String>>("first_attachment")?
|
||||
.map(|json| {
|
||||
parse_json_column::<(i64, Option<String>, Option<String>, Option<i64>)>(&json, 20)
|
||||
})
|
||||
.transpose()?
|
||||
.map(|(id, filename, mime_type, size)| AttachmentMeta {
|
||||
id,
|
||||
filename,
|
||||
mime_type,
|
||||
size,
|
||||
is_inline: false,
|
||||
}),
|
||||
attachment_total: row.get("attachment_total")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A message as a list row, with its conversation joined as `t` for the
|
||||
/// conversation-level filters `build_filter` shares between groupings.
|
||||
const MESSAGE_SUMMARY_SELECT: &str = "
|
||||
SELECT m.id, m.thread_id, m.account_id, a.email AS account_email,
|
||||
COALESCE((SELECT json_group_array(si.email)
|
||||
FROM sender_identities si
|
||||
WHERE si.account_id = m.account_id
|
||||
AND (si.is_primary = 1 OR si.verification_status = 'accepted')), '[]')
|
||||
AS account_addresses,
|
||||
COALESCE(m.local_subject_prefix, '') || m.subject AS subject,
|
||||
m.snippet, m.from_name, m.from_addr, m.to_json, m.date,
|
||||
m.is_read, m.is_starred, m.is_draft, m.is_outgoing, m.has_attachments,
|
||||
(m.message_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM message_refs mr
|
||||
JOIN messages reply ON reply.id = mr.message_id
|
||||
WHERE mr.ref_message_id = m.message_id
|
||||
AND reply.account_id = m.account_id
|
||||
AND reply.is_outgoing = 1
|
||||
AND reply.is_draft = 0
|
||||
)) AS has_replied,
|
||||
s.wake_at AS snoozed_until,
|
||||
(SELECT group_concat(ml.label_id) FROM message_labels ml
|
||||
WHERE ml.message_id = m.id) AS label_ids,
|
||||
m.verification_code,
|
||||
(SELECT json_array(f.id, f.filename, f.mime_type, f.size)
|
||||
FROM attachments f
|
||||
WHERE f.message_id = m.id AND f.is_inline = 0
|
||||
ORDER BY f.id LIMIT 1) AS first_attachment,
|
||||
(SELECT COUNT(*) FROM attachments f
|
||||
WHERE f.message_id = m.id AND f.is_inline = 0) AS attachment_total
|
||||
FROM messages m
|
||||
JOIN threads t ON t.id = m.thread_id
|
||||
JOIN accounts a ON a.id = m.account_id
|
||||
LEFT JOIN snoozes s ON s.thread_id = m.thread_id";
|
||||
|
||||
/// One page of the message timeline for the scope in `args`, newest first.
|
||||
/// `args.cursor` is ignored; messages page by `cursor`.
|
||||
pub fn list_messages(
|
||||
conn: &Connection,
|
||||
args: &ListArgs,
|
||||
cursor: Option<MessageCursor>,
|
||||
) -> Result<MessagePage> {
|
||||
let (sql, bind) = list_messages_sql(args, cursor);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
|
||||
let mut messages = stmt
|
||||
.query_map(params_ref.as_slice(), message_summary_from_row)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let next_cursor = if messages.len() as i64 > args.limit {
|
||||
messages.truncate(args.limit as usize);
|
||||
messages.last().map(|message| MessageCursor {
|
||||
date: message.date,
|
||||
message_id: message.id,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(MessagePage {
|
||||
messages,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
fn list_messages_sql(
|
||||
args: &ListArgs,
|
||||
cursor: Option<MessageCursor>,
|
||||
) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
|
||||
let (mut where_sql, mut bind) = build_filter(args, MailGrouping::Messages, false);
|
||||
if let Some(cursor) = cursor {
|
||||
bind.push(Box::new(cursor.date));
|
||||
let date_parameter = bind.len();
|
||||
bind.push(Box::new(cursor.message_id));
|
||||
let id_parameter = bind.len();
|
||||
let clause = format!("(m.date, m.id) < (?{date_parameter}, ?{id_parameter})");
|
||||
where_sql = if where_sql.is_empty() {
|
||||
format!("WHERE {clause}")
|
||||
} else {
|
||||
format!("{where_sql} AND {clause}")
|
||||
};
|
||||
}
|
||||
(
|
||||
format!(
|
||||
"{MESSAGE_SUMMARY_SELECT} {where_sql}
|
||||
ORDER BY m.date DESC, m.id DESC LIMIT {}",
|
||||
args.limit + 1
|
||||
),
|
||||
bind,
|
||||
)
|
||||
}
|
||||
|
||||
/// Query-plan details for the message list, including its cursor and filter.
|
||||
pub fn list_messages_query_plan(
|
||||
conn: &Connection,
|
||||
args: &ListArgs,
|
||||
cursor: Option<MessageCursor>,
|
||||
) -> Result<Vec<String>> {
|
||||
let (sql, bind) = list_messages_sql(args, cursor);
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
|
||||
Ok(stmt
|
||||
.query_map(params_ref.as_slice(), |row| row.get(3))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Summaries for many messages in one query, returned in `message_ids` order.
|
||||
/// Ids that no longer exist, or have no conversation yet, are skipped.
|
||||
pub fn get_message_summaries(
|
||||
conn: &Connection,
|
||||
message_ids: &[i64],
|
||||
) -> Result<Vec<MessageSummary>> {
|
||||
if message_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let placeholders = message_ids
|
||||
.iter()
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!("{MESSAGE_SUMMARY_SELECT} WHERE m.id IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params_from_iter(message_ids.iter()),
|
||||
message_summary_from_row,
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut by_id: std::collections::HashMap<i64, MessageSummary> =
|
||||
rows.into_iter().map(|m| (m.id, m)).collect();
|
||||
Ok(message_ids
|
||||
.iter()
|
||||
.filter_map(|id| by_id.remove(id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -804,4 +1034,178 @@ mod tests {
|
||||
"keyset cursor must seek by account and timestamp: {plan:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A conversation whose first message is in the inbox and whose reply was
|
||||
/// archived. Returns (thread, inbox message, archived reply).
|
||||
fn conversation_across_folders(conn: &Connection) -> (i64, i64, i64) {
|
||||
crate::db::testutil::seed_account(conn);
|
||||
conn.execute(
|
||||
"INSERT INTO folders (id, account_id, path, role) VALUES (2, 1, 'Archive', 'archive')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let (thread_id, inbox) =
|
||||
crate::db::testutil::seed_message(conn, "sender@example.com", "Plans", false);
|
||||
let (own_thread, reply) =
|
||||
crate::db::testutil::seed_message(conn, "sender@example.com", "Re: Plans", false);
|
||||
conn.execute(
|
||||
"UPDATE messages SET thread_id = ?1, folder_id = 2, date = 2000, is_starred = 1,
|
||||
uid = 10000 + id
|
||||
WHERE id = ?2",
|
||||
params![thread_id, reply],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", [own_thread])
|
||||
.unwrap();
|
||||
recompute(conn, thread_id).unwrap();
|
||||
(thread_id, inbox, reply)
|
||||
}
|
||||
|
||||
fn scope(view: View) -> ListArgs {
|
||||
ListArgs {
|
||||
view,
|
||||
tab: None,
|
||||
account_id: None,
|
||||
account_ids: None,
|
||||
folder_id: None,
|
||||
cursor: None,
|
||||
limit: 50,
|
||||
}
|
||||
}
|
||||
|
||||
fn message_ids(conn: &Connection, args: &ListArgs) -> Vec<i64> {
|
||||
list_messages(conn, args, None)
|
||||
.unwrap()
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_list_on_their_own_folders_and_flags() {
|
||||
let conn = crate::db::testutil::conn();
|
||||
let (thread_id, inbox, reply) = conversation_across_folders(&conn);
|
||||
|
||||
// The conversation is in the inbox and starred as a whole; each
|
||||
// message is only where it is itself.
|
||||
assert_eq!(list(&conn, &scope(View::Inbox)).unwrap().threads.len(), 1);
|
||||
assert_eq!(message_ids(&conn, &scope(View::Inbox)), [inbox]);
|
||||
assert_eq!(message_ids(&conn, &scope(View::Done)), [reply]);
|
||||
assert_eq!(message_ids(&conn, &scope(View::Starred)), [reply]);
|
||||
assert_eq!(message_ids(&conn, &scope(View::All)), [reply, inbox]);
|
||||
let archive = ListArgs {
|
||||
folder_id: Some(2),
|
||||
..scope(View::All)
|
||||
};
|
||||
assert_eq!(message_ids(&conn, &archive), [reply]);
|
||||
|
||||
let row = list_messages(&conn, &scope(View::Inbox), None)
|
||||
.unwrap()
|
||||
.messages
|
||||
.remove(0);
|
||||
assert_eq!(row.thread_id, thread_id);
|
||||
assert_eq!(row.subject, "Plans");
|
||||
assert_eq!(row.from.email, "sender@example.com");
|
||||
assert!(!row.is_read);
|
||||
assert!(!row.is_starred);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_follow_their_conversation_snooze_and_routing() {
|
||||
let conn = crate::db::testutil::conn();
|
||||
let (thread_id, inbox, _) = conversation_across_folders(&conn);
|
||||
conn.execute(
|
||||
"UPDATE threads SET routed_tab = 'other' WHERE id = ?1",
|
||||
[thread_id],
|
||||
)
|
||||
.unwrap();
|
||||
let other = ListArgs {
|
||||
tab: Some(TabFilter::Other),
|
||||
..scope(View::Inbox)
|
||||
};
|
||||
assert_eq!(message_ids(&conn, &other), [inbox]);
|
||||
let important = ListArgs {
|
||||
tab: Some(TabFilter::Important),
|
||||
..scope(View::Inbox)
|
||||
};
|
||||
assert!(message_ids(&conn, &important).is_empty());
|
||||
|
||||
crate::db::repo::snoozes::set(&conn, thread_id, 1, i64::MAX, Some(1)).unwrap();
|
||||
assert!(message_ids(&conn, &scope(View::Inbox)).is_empty());
|
||||
assert_eq!(message_ids(&conn, &scope(View::Snoozed)).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_labels_list_only_the_labelled_message() {
|
||||
let conn = crate::db::testutil::conn();
|
||||
let (_, inbox, _) = conversation_across_folders(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, keyword, position, is_auto, scope, owner_account_id, origin)
|
||||
VALUES (7, 'Work', '#000000', 'work', 0, 0, 'account', 1, 'user')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO message_labels (message_id, label_id) VALUES (?1, 7)",
|
||||
[inbox],
|
||||
)
|
||||
.unwrap();
|
||||
let labelled = ListArgs {
|
||||
tab: Some(TabFilter::ManualLabel(7)),
|
||||
..scope(View::All)
|
||||
};
|
||||
assert_eq!(message_ids(&conn, &labelled), [inbox]);
|
||||
assert_eq!(list(&conn, &labelled).unwrap().threads.len(), 1);
|
||||
let row = list_messages(&conn, &labelled, None)
|
||||
.unwrap()
|
||||
.messages
|
||||
.remove(0);
|
||||
assert_eq!(row.labels, [7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_pages_continue_after_their_cursor() {
|
||||
let conn = crate::db::testutil::conn();
|
||||
crate::db::testutil::seed_account(&conn);
|
||||
let mut ids = Vec::new();
|
||||
for (index, subject) in ["one", "two", "three"].into_iter().enumerate() {
|
||||
let (_, id) = crate::db::testutil::seed_message(&conn, "a@b.dev", subject, false);
|
||||
// Two messages share a date; the id breaks the tie.
|
||||
conn.execute(
|
||||
"UPDATE messages SET date = ?1 WHERE id = ?2",
|
||||
params![1000 + (index as i64).min(1), id],
|
||||
)
|
||||
.unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let args = ListArgs {
|
||||
limit: 2,
|
||||
..scope(View::Inbox)
|
||||
};
|
||||
let first = list_messages(&conn, &args, None).unwrap();
|
||||
let first_ids = first.messages.iter().map(|m| m.id).collect::<Vec<_>>();
|
||||
assert_eq!(first_ids, [ids[2], ids[1]]);
|
||||
let second = list_messages(&conn, &args, first.next_cursor).unwrap();
|
||||
assert_eq!(
|
||||
second.messages.iter().map(|m| m.id).collect::<Vec<_>>(),
|
||||
[ids[0]]
|
||||
);
|
||||
assert!(second.next_cursor.is_none());
|
||||
|
||||
let plan = list_messages_query_plan(
|
||||
&conn,
|
||||
&ListArgs {
|
||||
account_id: Some(1),
|
||||
..args
|
||||
},
|
||||
first.next_cursor,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.iter()
|
||||
.any(|line| line.contains("idx_messages_account_recent")),
|
||||
"account pages seek the message timeline index: {plan:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -634,6 +634,12 @@ impl From<(Option<ThreadCursor>, i64)> for ThreadPageRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MessagePageRequest {
|
||||
pub cursor: Option<MessageCursor>,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Core {
|
||||
/// Mail, contacts, folders, and mail-side durable actions.
|
||||
@@ -2995,12 +3001,107 @@ impl Core {
|
||||
folder_id: Option<i64>,
|
||||
page: ThreadPageRequest,
|
||||
) -> Result<ThreadPage> {
|
||||
use repo::threads::TabFilter;
|
||||
let ThreadPageRequest { cursor, limit } = page;
|
||||
// A category label filters to its single resolved tab; a manual label is
|
||||
// a cross-cutting filter. Split conventions: -1 = Important, -2 = Other,
|
||||
// positive ids = custom split tabs.
|
||||
let tab = if let Some(lid) = label_id {
|
||||
let tab = self.tab_filter(split_id, label_id).await?;
|
||||
self.db
|
||||
.read(move |conn| {
|
||||
repo::threads::list(
|
||||
conn,
|
||||
&repo::threads::ListArgs {
|
||||
view,
|
||||
tab,
|
||||
account_id,
|
||||
account_ids,
|
||||
folder_id,
|
||||
cursor,
|
||||
limit: limit.clamp(1, 200),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// One page of the message timeline: `list_threads` for mail lists that
|
||||
/// show each message on its own instead of grouping conversations.
|
||||
pub async fn list_messages(
|
||||
&self,
|
||||
view: View,
|
||||
split_id: Option<i64>,
|
||||
account_id: Option<i64>,
|
||||
label_id: Option<i64>,
|
||||
folder_id: Option<i64>,
|
||||
page: MessagePageRequest,
|
||||
) -> Result<MessagePage> {
|
||||
self.list_messages_where(view, split_id, account_id, None, label_id, folder_id, page)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `list_messages` across a set of accounts, such as a mail profile's. An
|
||||
/// empty set lists nothing.
|
||||
pub async fn list_messages_for_accounts(
|
||||
&self,
|
||||
view: View,
|
||||
split_id: Option<i64>,
|
||||
account_ids: Vec<i64>,
|
||||
label_id: Option<i64>,
|
||||
folder_id: Option<i64>,
|
||||
page: MessagePageRequest,
|
||||
) -> Result<MessagePage> {
|
||||
self.list_messages_where(
|
||||
view,
|
||||
split_id,
|
||||
None,
|
||||
Some(account_ids),
|
||||
label_id,
|
||||
folder_id,
|
||||
page,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_messages_where(
|
||||
&self,
|
||||
view: View,
|
||||
split_id: Option<i64>,
|
||||
account_id: Option<i64>,
|
||||
account_ids: Option<Vec<i64>>,
|
||||
label_id: Option<i64>,
|
||||
folder_id: Option<i64>,
|
||||
page: MessagePageRequest,
|
||||
) -> Result<MessagePage> {
|
||||
let MessagePageRequest { cursor, limit } = page;
|
||||
let tab = self.tab_filter(split_id, label_id).await?;
|
||||
self.db
|
||||
.read(move |conn| {
|
||||
repo::threads::list_messages(
|
||||
conn,
|
||||
&repo::threads::ListArgs {
|
||||
view,
|
||||
tab,
|
||||
account_id,
|
||||
account_ids,
|
||||
folder_id,
|
||||
cursor: None,
|
||||
limit: limit.clamp(1, 200),
|
||||
},
|
||||
cursor,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The inbox tab or label a list is narrowed to. A category label filters
|
||||
/// to its single resolved tab; a manual label is a cross-cutting filter.
|
||||
/// Split conventions: -1 = Important, -2 = Other, positive ids = custom
|
||||
/// split tabs.
|
||||
async fn tab_filter(
|
||||
&self,
|
||||
split_id: Option<i64>,
|
||||
label_id: Option<i64>,
|
||||
) -> Result<Option<repo::threads::TabFilter>> {
|
||||
use repo::threads::TabFilter;
|
||||
Ok(if let Some(lid) = label_id {
|
||||
let is_auto = self
|
||||
.db
|
||||
.read(move |conn| Ok(repo::labels::get(conn, lid)?.map(|l| l.is_auto)))
|
||||
@@ -3018,23 +3119,7 @@ impl Core {
|
||||
Some(id) if id > 0 => Some(TabFilter::Split(id)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
self.db
|
||||
.read(move |conn| {
|
||||
repo::threads::list(
|
||||
conn,
|
||||
&repo::threads::ListArgs {
|
||||
view,
|
||||
tab,
|
||||
account_id,
|
||||
account_ids,
|
||||
folder_id,
|
||||
cursor,
|
||||
limit: limit.clamp(1, 200),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact count for the standard mailbox/folder scopes used by native
|
||||
@@ -4143,12 +4228,29 @@ impl Core {
|
||||
let mut action_ids: Vec<i64> = Vec::new();
|
||||
let mut touched_accounts: Vec<i64> = Vec::new();
|
||||
|
||||
for thread_id in args.thread_ids.clone() {
|
||||
// Each conversation changes in its own transaction. Single messages
|
||||
// are grouped by their conversation, which keeps its aggregates.
|
||||
let scopes: Vec<(i64, Option<Vec<i64>>)> = match &args.target {
|
||||
ActionTarget::Threads(thread_ids) => thread_ids.iter().map(|id| (*id, None)).collect(),
|
||||
ActionTarget::Messages(message_ids) => {
|
||||
let message_ids = message_ids.clone();
|
||||
self.db
|
||||
.read(move |conn| messages_by_thread(conn, &message_ids))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(thread_id, messages)| (thread_id, Some(messages)))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
let thread_ids = scopes.iter().map(|(id, _)| *id).collect::<Vec<_>>();
|
||||
for (thread_id, messages) in scopes {
|
||||
let kind = args.kind;
|
||||
let params = args.params.clone();
|
||||
let ids = self
|
||||
.db
|
||||
.write(move |conn| apply_thread_action(conn, thread_id, kind, params.as_ref()))
|
||||
.write(move |conn| {
|
||||
apply_thread_action(conn, thread_id, messages.as_deref(), kind, params.as_ref())
|
||||
})
|
||||
.await?;
|
||||
for (aid, account_id) in ids {
|
||||
action_ids.push(aid);
|
||||
@@ -4158,9 +4260,7 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
self.bus.emit(CoreEvent::MailUpdated {
|
||||
thread_ids: args.thread_ids.clone(),
|
||||
});
|
||||
self.bus.emit(CoreEvent::MailUpdated { thread_ids });
|
||||
for acc in touched_accounts {
|
||||
self.nudge(Some(acc), || SyncCmd::RunActions).await;
|
||||
}
|
||||
@@ -7118,6 +7218,48 @@ impl Core {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Cursor-paginated chronological search as single messages, for mail
|
||||
/// lists that do not group conversations.
|
||||
pub async fn search_messages_page(
|
||||
&self,
|
||||
query: String,
|
||||
account_id: Option<i64>,
|
||||
cursor: Option<MessageSearchCursor>,
|
||||
limit: i64,
|
||||
) -> Result<MessageSearchPage> {
|
||||
let mut parsed = search::parse(&query);
|
||||
parsed.account_id = account_id;
|
||||
self.search_messages_parsed(parsed, cursor, limit).await
|
||||
}
|
||||
|
||||
/// `search_messages_page` across a set of accounts, such as a mail
|
||||
/// profile's. An empty set matches nothing.
|
||||
pub async fn search_messages_page_for_accounts(
|
||||
&self,
|
||||
query: String,
|
||||
account_ids: Vec<i64>,
|
||||
cursor: Option<MessageSearchCursor>,
|
||||
limit: i64,
|
||||
) -> Result<MessageSearchPage> {
|
||||
let mut parsed = search::parse(&query);
|
||||
parsed.account_ids = Some(account_ids);
|
||||
self.search_messages_parsed(parsed, cursor, limit).await
|
||||
}
|
||||
|
||||
async fn search_messages_parsed(
|
||||
&self,
|
||||
parsed: search::ParsedQuery,
|
||||
cursor: Option<MessageSearchCursor>,
|
||||
limit: i64,
|
||||
) -> Result<MessageSearchPage> {
|
||||
let limit = limit.clamp(1, 100);
|
||||
self.db
|
||||
.read(move |conn| {
|
||||
repo::search::chronological_message_page(conn, &parsed, cursor, limit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn search_chronological_parsed(
|
||||
&self,
|
||||
parsed: search::ParsedQuery,
|
||||
@@ -8077,7 +8219,7 @@ impl Core {
|
||||
if let Err(e) = self
|
||||
.perform_action(PerformActionArgs {
|
||||
kind,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params,
|
||||
})
|
||||
.await
|
||||
@@ -8116,9 +8258,13 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Native sidebar badges for all accounts, computed in one grouped query.
|
||||
pub async fn mailbox_badge_counts(&self) -> Result<Vec<MailboxBadgeCounts>> {
|
||||
/// They count conversations or messages, as the mail list shows them.
|
||||
pub async fn mailbox_badge_counts(
|
||||
&self,
|
||||
grouping: MailGrouping,
|
||||
) -> Result<Vec<MailboxBadgeCounts>> {
|
||||
self.db
|
||||
.read(|conn| repo::counts::mailbox_badge_counts(conn))
|
||||
.read(move |conn| repo::counts::mailbox_badge_counts(conn, grouping))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -8867,11 +9013,59 @@ fn valid_automation_route(conn: &rusqlite::Connection, target: &str) -> Result<b
|
||||
Ok(conn.query_row(&sql, rusqlite::params![id], |row| row.get::<_, i64>(0))? != 0)
|
||||
}
|
||||
|
||||
/// The conversations of `message_ids`, each with its messages, in the order
|
||||
/// they first appear. Messages that are gone, or not yet in a conversation,
|
||||
/// are left out.
|
||||
fn messages_by_thread(
|
||||
conn: &rusqlite::Connection,
|
||||
message_ids: &[i64],
|
||||
) -> Result<Vec<(i64, Vec<i64>)>> {
|
||||
let mut stmt = conn.prepare_cached("SELECT thread_id FROM messages WHERE id = ?1")?;
|
||||
let mut scopes: Vec<(i64, Vec<i64>)> = Vec::new();
|
||||
for message_id in message_ids {
|
||||
let Some(thread_id) = stmt
|
||||
.query_row(rusqlite::params![message_id], |row| {
|
||||
row.get::<_, Option<i64>>(0)
|
||||
})
|
||||
.optional()?
|
||||
.flatten()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match scopes.iter_mut().find(|(id, _)| *id == thread_id) {
|
||||
Some((_, messages)) => messages.push(*message_id),
|
||||
None => scopes.push((thread_id, vec![*message_id])),
|
||||
}
|
||||
}
|
||||
Ok(scopes)
|
||||
}
|
||||
|
||||
/// Whether any message of the conversation is still in an inbox.
|
||||
fn thread_in_inbox(tx: &rusqlite::Transaction, thread_id: i64) -> Result<bool> {
|
||||
Ok(tx.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM messages m JOIN folders f ON f.id = m.folder_id
|
||||
WHERE m.thread_id = ?1 AND f.role = 'inbox')
|
||||
OR EXISTS (SELECT 1 FROM messages m
|
||||
JOIN message_folders mf ON mf.message_id = m.id
|
||||
JOIN folders f ON f.id = mf.folder_id
|
||||
WHERE m.thread_id = ?1 AND f.role = 'inbox')",
|
||||
rusqlite::params![thread_id],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Optimistic local mutation + enqueue, in one transaction.
|
||||
/// Returns (action_id, account_id) pairs.
|
||||
///
|
||||
/// `only` narrows the action to those messages of the conversation, as a mail
|
||||
/// list without conversations acts on the message it shows. Their queued
|
||||
/// actions are marked `messageOnly`, so providers that could change the whole
|
||||
/// conversation at once, such as Gmail, change just those messages. Snooze
|
||||
/// stays a conversation's.
|
||||
fn apply_thread_action(
|
||||
conn: &mut rusqlite::Connection,
|
||||
thread_id: i64,
|
||||
only: Option<&[i64]>,
|
||||
kind: ActionKind,
|
||||
params: Option<&ActionParams>,
|
||||
) -> Result<Vec<(i64, i64)>> {
|
||||
@@ -8890,10 +9084,12 @@ fn apply_thread_action(
|
||||
// Most thread actions intentionally ignore drafts. Archive and Trash are
|
||||
// exceptions: users can file or discard a draft from the Drafts view, and
|
||||
// excluding it here made the optimistic removal snap back on reconciliation.
|
||||
let include_drafts = matches!(
|
||||
kind,
|
||||
ActionKind::Archive | ActionKind::Trash | ActionKind::DeletePermanently
|
||||
);
|
||||
// A draft chosen on its own is acted on like any other message.
|
||||
let include_drafts = only.is_some()
|
||||
|| matches!(
|
||||
kind,
|
||||
ActionKind::Archive | ActionKind::Trash | ActionKind::DeletePermanently
|
||||
);
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT m.id, m.folder_id, m.uid, m.is_read, m.is_starred, COALESCE(f.role,'')
|
||||
FROM messages m LEFT JOIN folders f ON f.id = m.folder_id
|
||||
@@ -8913,6 +9109,17 @@ fn apply_thread_action(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
drop(stmt);
|
||||
let mut msgs = msgs;
|
||||
if let Some(only) = only {
|
||||
msgs.retain(|message| only.contains(&message.0));
|
||||
}
|
||||
let message_only = only.is_some();
|
||||
let scoped = |mut payload: serde_json::Value| {
|
||||
if message_only {
|
||||
payload["messageOnly"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
payload
|
||||
};
|
||||
|
||||
fn folder_of(tx: &rusqlite::Transaction, account_id: i64, role: &str) -> Result<Option<i64>> {
|
||||
Ok(repo::folders::by_role(tx, account_id, role)?.map(|f| f.id))
|
||||
@@ -8929,12 +9136,16 @@ fn apply_thread_action(
|
||||
target: i64,
|
||||
kind_str: &str,
|
||||
multi_mailbox: bool,
|
||||
message_only: bool,
|
||||
) -> Result<i64> {
|
||||
let payload = serde_json::json!({
|
||||
let mut payload = serde_json::json!({
|
||||
"srcFolderId": src_folder,
|
||||
"srcUid": src_uid,
|
||||
"targetFolderId": target,
|
||||
});
|
||||
if message_only {
|
||||
payload["messageOnly"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
repo::messages::set_uid_and_folder(tx, msg_id, target, None)?;
|
||||
if multi_mailbox {
|
||||
if let Some(src_folder) = src_folder {
|
||||
@@ -8991,7 +9202,7 @@ fn apply_thread_action(
|
||||
kind.as_str(),
|
||||
Some(*id),
|
||||
Some(thread_id),
|
||||
&serde_json::json!({"folderId": f, "uid": u}),
|
||||
&scoped(serde_json::json!({"folderId": f, "uid": u})),
|
||||
None,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
@@ -9002,7 +9213,7 @@ fn apply_thread_action(
|
||||
for (id, _f, _u, is_read, _s, _role) in &msgs {
|
||||
if *is_read != target_read {
|
||||
repo::messages::set_read(&tx, *id, target_read)?;
|
||||
let payload = serde_json::json!({});
|
||||
let payload = scoped(serde_json::json!({}));
|
||||
let aid = repo::actions::enqueue(
|
||||
&tx,
|
||||
account_id,
|
||||
@@ -9016,6 +9227,23 @@ fn apply_thread_action(
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionKind::Star if message_only => {
|
||||
for (id, _f, _u, _r, is_starred, _role) in &msgs {
|
||||
if !*is_starred {
|
||||
repo::messages::set_starred(&tx, *id, true)?;
|
||||
let aid = repo::actions::enqueue(
|
||||
&tx,
|
||||
account_id,
|
||||
"star",
|
||||
Some(*id),
|
||||
Some(thread_id),
|
||||
&scoped(serde_json::json!({})),
|
||||
None,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionKind::Star => {
|
||||
// Star the latest message only (thread-level star).
|
||||
if let Some((id, _f, _u, _r, is_starred, _role)) = msgs.iter().max_by_key(|m| m.0) {
|
||||
@@ -9047,7 +9275,7 @@ fn apply_thread_action(
|
||||
"unstar",
|
||||
Some(*id),
|
||||
Some(thread_id),
|
||||
&serde_json::json!({}),
|
||||
&scoped(serde_json::json!({})),
|
||||
None,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
@@ -9086,12 +9314,16 @@ fn apply_thread_action(
|
||||
target,
|
||||
kind_str,
|
||||
multi_mailbox,
|
||||
message_only,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
}
|
||||
}
|
||||
// Archiving also clears snooze.
|
||||
repo::snoozes::clear(&tx, thread_id)?;
|
||||
// Archiving also clears snooze, once the whole conversation has
|
||||
// left the inbox.
|
||||
if !message_only || !thread_in_inbox(&tx, thread_id)? {
|
||||
repo::snoozes::clear(&tx, thread_id)?;
|
||||
}
|
||||
}
|
||||
ActionKind::Unarchive | ActionKind::NotSpam => {
|
||||
let target = folder_of(&tx, account_id, roles::INBOX)?
|
||||
@@ -9115,6 +9347,7 @@ fn apply_thread_action(
|
||||
target,
|
||||
kind.as_str(),
|
||||
multi_mailbox,
|
||||
message_only,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
}
|
||||
@@ -9143,6 +9376,7 @@ fn apply_thread_action(
|
||||
target,
|
||||
"move",
|
||||
multi_mailbox,
|
||||
message_only,
|
||||
)?;
|
||||
out.push((aid, account_id));
|
||||
}
|
||||
@@ -9193,7 +9427,8 @@ fn apply_thread_action(
|
||||
)));
|
||||
}
|
||||
let add = kind == ActionKind::AddLabel;
|
||||
let payload = serde_json::json!({ "labelId": label_id, "keyword": label.keyword });
|
||||
let payload =
|
||||
scoped(serde_json::json!({ "labelId": label_id, "keyword": label.keyword }));
|
||||
for (id, ..) in &msgs {
|
||||
if add {
|
||||
repo::labels::add_to_message(&tx, *id, label_id)?;
|
||||
@@ -9390,8 +9625,14 @@ mod move_action_validation_tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let actions =
|
||||
apply_thread_action(&mut conn, thread_id, ActionKind::DeletePermanently, None).unwrap();
|
||||
let actions = apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
None,
|
||||
ActionKind::DeletePermanently,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
let action = repo::actions::get(&conn, actions[0].0).unwrap().unwrap();
|
||||
assert_eq!(action.message_id, Some(trash_id));
|
||||
@@ -9399,9 +9640,171 @@ mod move_action_validation_tests {
|
||||
assert!(repo::messages::get_row(&conn, trash_id).unwrap().is_some());
|
||||
assert!(repo::messages::get_row(&conn, inbox_id).unwrap().is_some());
|
||||
assert!(
|
||||
apply_thread_action(&mut conn, thread_id, ActionKind::DeletePermanently, None)
|
||||
apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
None,
|
||||
ActionKind::DeletePermanently,
|
||||
None
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// A conversation of two inbox messages, as two rows of a mail list
|
||||
/// without conversations. Returns (thread, first message, second message).
|
||||
fn two_message_conversation(conn: &rusqlite::Connection) -> (i64, i64, i64) {
|
||||
let (thread_id, first) =
|
||||
db::testutil::seed_message(conn, "sender@test.dev", "plans", false);
|
||||
let (own_thread, second) =
|
||||
db::testutil::seed_message(conn, "sender@test.dev", "re: plans", false);
|
||||
conn.execute(
|
||||
"UPDATE messages SET thread_id = ?1, date = 2000, uid = 10000 + id WHERE id = ?2",
|
||||
rusqlite::params![thread_id, second],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", [own_thread])
|
||||
.unwrap();
|
||||
repo::threads::recompute(conn, thread_id).unwrap();
|
||||
(thread_id, first, second)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archiving_one_message_leaves_the_rest_of_its_conversation() {
|
||||
let mut conn = db::testutil::conn();
|
||||
db::testutil::seed_account(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO folders (id, account_id, path, role) VALUES (2, 1, 'Archive', 'archive')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let (thread_id, first, second) = two_message_conversation(&conn);
|
||||
repo::snoozes::set(&conn, thread_id, 1, 9_999_999, Some(1)).unwrap();
|
||||
|
||||
let actions = apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
Some(&[first]),
|
||||
ActionKind::Archive,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
let action = repo::actions::get(&conn, actions[0].0).unwrap().unwrap();
|
||||
assert_eq!(action.message_id, Some(first));
|
||||
assert_eq!(action.payload["messageOnly"], true);
|
||||
let folder = |conn: &rusqlite::Connection, id| {
|
||||
repo::messages::get_row(conn, id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
.unwrap()
|
||||
.folder_id
|
||||
};
|
||||
assert_eq!(folder(&conn, first), Some(2));
|
||||
assert_eq!(
|
||||
folder(&conn, second),
|
||||
Some(1),
|
||||
"the reply stays in the inbox"
|
||||
);
|
||||
let snoozed = |conn: &rusqlite::Connection| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM snoozes WHERE thread_id = ?1",
|
||||
[thread_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(snoozed(&conn), 1, "the conversation is still in the inbox");
|
||||
|
||||
apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
Some(&[second]),
|
||||
ActionKind::Archive,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snoozed(&conn), 0, "the whole conversation left the inbox");
|
||||
|
||||
// Undoing an archive that already reached the server moves back only
|
||||
// that message.
|
||||
repo::actions::set_state(&conn, action.id, "done", None).unwrap();
|
||||
revert_action(&mut conn, &action).unwrap();
|
||||
let inverse = conn
|
||||
.query_row(
|
||||
"SELECT payload FROM pending_actions
|
||||
WHERE kind = 'move' AND message_id = ?1 AND state = 'pending'",
|
||||
[first],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap();
|
||||
let inverse: serde_json::Value = serde_json::from_str(&inverse).unwrap();
|
||||
assert_eq!(inverse["messageOnly"], true);
|
||||
assert_eq!(folder(&conn, first), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_and_starring_one_message_changes_only_it() {
|
||||
let mut conn = db::testutil::conn();
|
||||
db::testutil::seed_account(&conn);
|
||||
let (thread_id, first, second) = two_message_conversation(&conn);
|
||||
|
||||
apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
Some(&[first]),
|
||||
ActionKind::MarkRead,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let read = |id| {
|
||||
conn.query_row("SELECT is_read FROM messages WHERE id = ?1", [id], |row| {
|
||||
row.get::<_, bool>(0)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert!(read(first));
|
||||
assert!(!read(second));
|
||||
let unread: i64 = conn
|
||||
.query_row(
|
||||
"SELECT unread_count FROM threads WHERE id = ?1",
|
||||
[thread_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unread, 1);
|
||||
|
||||
// A conversation stars its newest message; a chosen message stars
|
||||
// itself.
|
||||
let actions =
|
||||
apply_thread_action(&mut conn, thread_id, Some(&[first]), ActionKind::Star, None)
|
||||
.unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
let starred = |id| {
|
||||
conn.query_row(
|
||||
"SELECT is_starred FROM messages WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(starred(first));
|
||||
assert!(!starred(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_messages_group_by_their_conversations() {
|
||||
let conn = db::testutil::conn();
|
||||
db::testutil::seed_account(&conn);
|
||||
let (thread_id, first, second) = two_message_conversation(&conn);
|
||||
let (other_thread, other) =
|
||||
db::testutil::seed_message(&conn, "someone@test.dev", "elsewhere", false);
|
||||
assert_eq!(
|
||||
messages_by_thread(&conn, &[first, other, 999, second]).unwrap(),
|
||||
[
|
||||
(thread_id, vec![first, second]),
|
||||
(other_thread, vec![other])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9424,7 +9827,7 @@ mod move_action_validation_tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let actions = apply_thread_action(&mut conn, thread_id, kind, None).unwrap();
|
||||
let actions = apply_thread_action(&mut conn, thread_id, None, kind, None).unwrap();
|
||||
|
||||
assert_eq!(actions.len(), 1);
|
||||
let row = repo::messages::get_row(&conn, message_id).unwrap().unwrap();
|
||||
@@ -9456,6 +9859,7 @@ mod move_action_validation_tests {
|
||||
let error = apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
None,
|
||||
ActionKind::Move,
|
||||
Some(&ActionParams {
|
||||
wake_at: None,
|
||||
@@ -9495,6 +9899,7 @@ mod move_action_validation_tests {
|
||||
let error = apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
None,
|
||||
ActionKind::AddLabel,
|
||||
Some(&ActionParams {
|
||||
wake_at: None,
|
||||
@@ -9533,6 +9938,7 @@ mod move_action_validation_tests {
|
||||
let actions = apply_thread_action(
|
||||
&mut conn,
|
||||
thread_id,
|
||||
None,
|
||||
ActionKind::AddLabel,
|
||||
Some(&ActionParams {
|
||||
wake_at: None,
|
||||
@@ -9572,6 +9978,14 @@ fn revert_action(
|
||||
let was_pending = repo::actions::try_cancel(conn, action.id)?;
|
||||
let tx = conn.transaction()?;
|
||||
let thread_id = action.thread_id;
|
||||
// The inverse of an action on one message changes only that message too.
|
||||
let message_only = action.payload["messageOnly"].as_bool() == Some(true);
|
||||
let scoped = |mut payload: serde_json::Value| {
|
||||
if message_only {
|
||||
payload["messageOnly"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
payload
|
||||
};
|
||||
|
||||
match action.kind.as_str() {
|
||||
"mark_read" | "mark_unread" => {
|
||||
@@ -9589,7 +10003,7 @@ fn revert_action(
|
||||
inverse,
|
||||
Some(mid),
|
||||
thread_id,
|
||||
&serde_json::json!({}),
|
||||
&scoped(serde_json::json!({})),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
@@ -9610,7 +10024,7 @@ fn revert_action(
|
||||
inverse,
|
||||
Some(mid),
|
||||
thread_id,
|
||||
&serde_json::json!({}),
|
||||
&scoped(serde_json::json!({})),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
@@ -9640,11 +10054,11 @@ fn revert_action(
|
||||
.filter(|row| row.folder_id == cur_folder)
|
||||
.and_then(|row| row.uid);
|
||||
repo::messages::set_uid_and_folder(&tx, mid, src, None)?;
|
||||
let payload = serde_json::json!({
|
||||
let payload = scoped(serde_json::json!({
|
||||
"srcFolderId": cur_folder,
|
||||
"srcUid": current_uid,
|
||||
"targetFolderId": src,
|
||||
});
|
||||
}));
|
||||
repo::actions::enqueue(
|
||||
&tx,
|
||||
action.account_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::Paths;
|
||||
use crate::db::repo;
|
||||
use crate::migration::{Source, SourceError};
|
||||
use crate::models::{
|
||||
ActionKind, AuthKind, MailProtocol, PerformActionArgs, Provider, now_ms, roles,
|
||||
ActionKind, ActionTarget, AuthKind, MailProtocol, PerformActionArgs, Provider, now_ms, roles,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -381,7 +381,10 @@ async fn saved_mail_goes_into_local_mail_once() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(found.threads.len(), 1);
|
||||
let badges = core.mailbox_badge_counts().await.unwrap();
|
||||
let badges = core
|
||||
.mailbox_badge_counts(crate::models::MailGrouping::Conversations)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
badges
|
||||
.iter()
|
||||
@@ -650,7 +653,7 @@ async fn local_mail_folders_trash_export_and_removal_work_without_a_server() {
|
||||
.unwrap();
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Trash,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -971,6 +971,49 @@ pub struct ThreadSummary {
|
||||
pub attachment_total: i64,
|
||||
}
|
||||
|
||||
/// Whether mail lists show conversations or each message on its own, like
|
||||
/// JMAP's `Email/query` with and without `collapseThreads`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MailGrouping {
|
||||
#[default]
|
||||
Conversations,
|
||||
Messages,
|
||||
}
|
||||
|
||||
/// One message as a mail-list row, for lists that do not group messages into
|
||||
/// conversations. Snooze and tab routing belong to the message's conversation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageSummary {
|
||||
pub id: i64,
|
||||
pub thread_id: i64,
|
||||
pub account_id: i64,
|
||||
/// `None` for local mail, which has no address.
|
||||
pub account_email: Option<String>,
|
||||
/// Verified addresses owned by this account, including send-as aliases.
|
||||
pub account_addresses: Vec<String>,
|
||||
pub subject: String,
|
||||
pub snippet: String,
|
||||
pub from: Address,
|
||||
pub to: Vec<Address>,
|
||||
pub date: i64,
|
||||
pub is_read: bool,
|
||||
pub is_starred: bool,
|
||||
pub is_draft: bool,
|
||||
pub is_outgoing: bool,
|
||||
pub has_attachments: bool,
|
||||
/// A sent reply refers to this message.
|
||||
pub has_replied: bool,
|
||||
/// When the message's conversation is snoozed until.
|
||||
pub snoozed_until: Option<i64>,
|
||||
pub labels: Vec<i64>,
|
||||
pub verification_code: Option<String>,
|
||||
/// The message's first file. Inline images are not files.
|
||||
pub first_attachment: Option<AttachmentMeta>,
|
||||
pub attachment_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Label {
|
||||
@@ -1099,6 +1142,36 @@ pub struct SearchPage {
|
||||
pub next_cursor: Option<SearchCursor>,
|
||||
}
|
||||
|
||||
/// Stable keyset cursor for the descending message timeline; the message id
|
||||
/// breaks ties between messages with the same date.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageCursor {
|
||||
pub date: i64,
|
||||
pub message_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessagePage {
|
||||
pub messages: Vec<MessageSummary>,
|
||||
pub next_cursor: Option<MessageCursor>,
|
||||
}
|
||||
|
||||
/// Position in a chronological message search. Keep the relaxed-query choice
|
||||
/// so every page uses the same match semantics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MessageSearchCursor {
|
||||
pub position: MessageCursor,
|
||||
pub relaxed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageSearchPage {
|
||||
pub messages: Vec<MessageSummary>,
|
||||
pub next_cursor: Option<MessageSearchCursor>,
|
||||
}
|
||||
|
||||
// Deliberately no `Debug`: it would print the account password.
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1156,6 +1229,16 @@ pub enum ActionKind {
|
||||
Send,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn mail_grouping(&self) -> MailGrouping {
|
||||
if self.conversation_view {
|
||||
MailGrouping::Conversations
|
||||
} else {
|
||||
MailGrouping::Messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -1208,11 +1291,20 @@ pub struct ActionParams {
|
||||
pub label_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// What an action applies to: whole conversations, or single messages, as a
|
||||
/// mail list without conversations shows them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ActionTarget {
|
||||
Threads(Vec<i64>),
|
||||
Messages(Vec<i64>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PerformActionArgs {
|
||||
pub kind: ActionKind,
|
||||
pub thread_ids: Vec<i64>,
|
||||
pub target: ActionTarget,
|
||||
#[serde(default)]
|
||||
pub params: Option<ActionParams>,
|
||||
}
|
||||
@@ -1724,6 +1816,10 @@ pub struct Settings {
|
||||
/// switcher in compact layouts. Saved tabs are kept while they are hidden.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tabs: bool,
|
||||
/// Group replies with the messages they answer into conversations. Off
|
||||
/// lists, opens and acts on every message on its own.
|
||||
#[serde(default = "default_true")]
|
||||
pub conversation_view: bool,
|
||||
/// Workspace presentation: "default" keeps lists and details side by
|
||||
/// side, while "minimal" navigates between those shared surfaces.
|
||||
/// `mailLayout` was the short-lived original key before Contacts shared
|
||||
@@ -2028,6 +2124,9 @@ pub struct StartupPresentation {
|
||||
/// first showing the window.
|
||||
pub close_to_tray: bool,
|
||||
pub app_icon: AppIcon,
|
||||
/// Whether the first mail list, and the cached one shown before it,
|
||||
/// group conversations.
|
||||
pub grouping: MailGrouping,
|
||||
}
|
||||
fn default_theme_preset() -> String {
|
||||
"default".into()
|
||||
@@ -2043,6 +2142,7 @@ impl Default for Settings {
|
||||
show_avatars: true,
|
||||
group_mail_by_date: true,
|
||||
show_tabs: true,
|
||||
conversation_view: true,
|
||||
workspace_layout: default_workspace_layout(),
|
||||
workspace_list_pane_width: default_workspace_list_pane_width(),
|
||||
window_frame: default_window_frame(),
|
||||
@@ -2417,6 +2517,7 @@ mod tests {
|
||||
assert_eq!(s.app_icon, AppIcon::Orange);
|
||||
assert!(s.group_mail_by_date);
|
||||
assert!(s.show_tabs);
|
||||
assert!(s.conversation_view);
|
||||
assert_eq!(s.workspace_layout, "default");
|
||||
assert_eq!(s.workspace_list_pane_width, MIN_WORKSPACE_LIST_PANE_WIDTH);
|
||||
assert_eq!(s.time_format, TimeFormat::Automatic);
|
||||
@@ -2470,6 +2571,7 @@ mod tests {
|
||||
assert_eq!(serialized["workspaceLayout"], "default");
|
||||
assert_eq!(serialized["groupMailByDate"], true);
|
||||
assert_eq!(serialized["showTabs"], true);
|
||||
assert_eq!(serialized["conversationView"], true);
|
||||
assert_eq!(
|
||||
serialized["workspaceListPaneWidth"],
|
||||
MIN_WORKSPACE_LIST_PANE_WIDTH
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::accounts::credentials::DevelopmentFileCredentialStore;
|
||||
use crate::models::{ActionKind, ConnectionSecurity, MailConnectionSettings, PerformActionArgs};
|
||||
use crate::models::{
|
||||
ActionKind, ActionTarget, ConnectionSecurity, MailConnectionSettings, PerformActionArgs,
|
||||
};
|
||||
use crate::{Core, config::Paths, db::testutil};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -215,7 +217,7 @@ async fn undo_and_star_find_a_moved_message_before_its_mailbox_syncs() {
|
||||
let perform = |kind| {
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params: None,
|
||||
})
|
||||
};
|
||||
|
||||
@@ -3516,6 +3516,7 @@ async fn apply_action(
|
||||
// its id. Other local-only messages have no remote intent to replay.
|
||||
return Ok(());
|
||||
};
|
||||
let provider_thread_id = conversation_scope(provider_thread_id, &action.payload);
|
||||
|
||||
let mut add = Vec::<String>::new();
|
||||
let mut remove = Vec::<String>::new();
|
||||
@@ -3632,6 +3633,16 @@ async fn apply_action(
|
||||
}
|
||||
}
|
||||
|
||||
/// The Gmail conversation an action may change as a whole. An action on one
|
||||
/// message, made where mail lists do not group conversations, must not reach
|
||||
/// the rest of its conversation, so it changes only its message.
|
||||
fn conversation_scope(
|
||||
provider_thread_id: Option<String>,
|
||||
payload: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
provider_thread_id.filter(|_| payload["messageOnly"].as_bool() != Some(true))
|
||||
}
|
||||
|
||||
async fn execute_actions(ctx: &SyncCtx, config: &AccountConfig, api: &GmailApi) -> Result<bool> {
|
||||
const LIMIT: i64 = 20;
|
||||
const MAX_ATTEMPTS: i64 = 8;
|
||||
@@ -4010,6 +4021,23 @@ async fn fetch_attachment(ctx: &SyncCtx, api: &GmailApi, attachment_id: i64) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_message_actions_leave_the_rest_of_the_conversation() {
|
||||
let thread = || Some("thread-1".to_owned());
|
||||
assert_eq!(
|
||||
conversation_scope(thread(), &json!({})).as_deref(),
|
||||
Some("thread-1")
|
||||
);
|
||||
assert_eq!(
|
||||
conversation_scope(thread(), &json!({ "messageOnly": true })),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
conversation_scope(thread(), &json!({ "messageOnly": false })).as_deref(),
|
||||
Some("thread-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_deduplicates_threads_and_advances_only_on_final_page() {
|
||||
let first = json!({
|
||||
|
||||
@@ -291,7 +291,7 @@ async fn full_sync_triage_and_search() {
|
||||
.expect("bob thread");
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Archive,
|
||||
thread_ids: vec![bob.id],
|
||||
target: ActionTarget::Threads(vec![bob.id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
@@ -321,7 +321,7 @@ async fn full_sync_triage_and_search() {
|
||||
// 8. Snooze hides a thread from inbox; unsnooze restores it.
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Snooze,
|
||||
thread_ids: vec![alice_id],
|
||||
target: ActionTarget::Threads(vec![alice_id]),
|
||||
params: Some(ActionParams {
|
||||
wake_at: Some(now_ms() + 3_600_000),
|
||||
target_folder_id: None,
|
||||
@@ -342,7 +342,7 @@ async fn full_sync_triage_and_search() {
|
||||
assert!(snoozed.threads.iter().any(|t| t.id == alice_id));
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Unsnooze,
|
||||
thread_ids: vec![alice_id],
|
||||
target: ActionTarget::Threads(vec![alice_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
@@ -412,7 +412,7 @@ async fn full_sync_triage_and_search() {
|
||||
let bob_id = bob.id;
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Archive,
|
||||
thread_ids: vec![bob_id],
|
||||
target: ActionTarget::Threads(vec![bob_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use flectar_mail_core::Core;
|
||||
use flectar_mail_core::config::Paths;
|
||||
use flectar_mail_core::models::{
|
||||
ActionKind, Address, DraftAttachmentIn, PerformActionArgs, SaveDraftArgs, View,
|
||||
ActionKind, ActionTarget, Address, DraftAttachmentIn, PerformActionArgs, SaveDraftArgs, View,
|
||||
};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, Core) {
|
||||
@@ -149,7 +149,7 @@ async fn archive_and_trash_remove_local_drafts_from_the_drafts_view() {
|
||||
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Archive,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
@@ -184,7 +184,7 @@ async fn archive_and_trash_remove_local_drafts_from_the_drafts_view() {
|
||||
.unwrap();
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Trash,
|
||||
thread_ids: vec![trashed_thread],
|
||||
target: ActionTarget::Threads(vec![trashed_thread]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -171,14 +171,14 @@ async fn offline_actions_queue_locally_and_survive_restart() {
|
||||
// Triage while offline: local mutation is immediate, intent is queued.
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::MarkRead,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
.expect("mark read offline");
|
||||
core.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Archive,
|
||||
thread_ids: vec![thread_id],
|
||||
target: ActionTarget::Threads(vec![thread_id]),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -176,7 +176,12 @@ async fn hundred_k_messages_stay_instant() {
|
||||
// All native sidebar badges are refreshed together after mutations.
|
||||
let t0 = Instant::now();
|
||||
let badges = db
|
||||
.read(|conn| repo::counts::mailbox_badge_counts(conn))
|
||||
.read(|conn| {
|
||||
repo::counts::mailbox_badge_counts(
|
||||
conn,
|
||||
flectar_mail_core::models::MailGrouping::Conversations,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let badge_ms = t0.elapsed().as_millis();
|
||||
|
||||
@@ -252,7 +252,7 @@ async fn a_pop3_account_downloads_files_and_sends_mail_on_this_device() {
|
||||
let result = core
|
||||
.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Move,
|
||||
thread_ids: vec![thread.id],
|
||||
target: ActionTarget::Threads(vec![thread.id]),
|
||||
params: Some(ActionParams {
|
||||
wake_at: None,
|
||||
target_folder_id: Some(projects),
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
### 🧵 سلاسل المحادثات
|
||||
|
||||
تبقى الردود مجمعة بترتيب زمني، مع توسيع الرسالة النشطة داخل جزء القراءة.
|
||||
تبقى الردود مجمعة بترتيب زمني، مع توسيع الرسالة النشطة داخل جزء القراءة. لعرض كل رسالة وفتحها والتعامل معها على حدة، أوقف **عرض المحادثات** في الإعدادات ← عام.
|
||||
|
||||
استخدم **أعلى/أسفل** للتنقل بين الرسائل، و**Home/End** للانتقال إلى أول رسالة محملة أو آخرها، و**Page Up/Page Down** للتحرك صفحة في كل مرة. يفتح **Enter** الرسالة الحالية وينقل التركيز إلى محتواها؛ ويحدد **Space** الرسالة أو يلغي تحديدها للإجراءات الجماعية. ينقل **Tab/Shift+Tab** التركيز بين عناصر التحكم. في مساحة العمل الكاملة، ينقل **F6** التركيز بين القائمة والقارئ. في التخطيط المبسط، تبرز مفاتيح الأسهم الرسائل ويفتحها **Enter**؛ ويعيد **Escape** أو **F6** إلى القائمة.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Du kannst auch die Farbe eines einzelnen Kontos überschreiben und die wirksame
|
||||
|
||||
### 🧵 Unterhaltungen
|
||||
|
||||
Antworten bleiben chronologisch gruppiert, wobei die aktive Nachricht im Lesebereich aufgeklappt ist.
|
||||
Antworten bleiben chronologisch gruppiert, wobei die aktive Nachricht im Lesebereich aufgeklappt ist. Um jede Nachricht einzeln aufzulisten, zu öffnen und zu bearbeiten, schalte **Unterhaltungsansicht** unter Einstellungen → Allgemein aus.
|
||||
|
||||
Mit **Pfeil hoch/runter** navigierst du durch Nachrichten, mit **Pos1/Ende** zur ersten oder letzten geladenen Nachricht und mit **Bild auf/Bild ab** seitenweise. **Enter** öffnet die aktuelle Nachricht und fokussiert ihren Inhalt; die **Leertaste** markiert sie für Sammelaktionen oder hebt die Markierung auf. **Tab/Umschalt+Tab** wechseln zwischen Bedienelementen. In der vollständigen Ansicht verschiebt **F6** den Fokus zwischen Liste und Lesebereich. Im minimalistischen Layout heben Pfeiltasten Nachrichten hervor und **Enter** öffnet sie; **Escape** oder **F6** kehrt zur Liste zurück.
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ usa morado para Trabajo y naranja para Soporte.
|
||||
### 🧵 Conversaciones
|
||||
|
||||
Las respuestas se agrupan en orden cronológico, con el mensaje activo desplegado
|
||||
en el panel de lectura.
|
||||
en el panel de lectura. Para ver, abrir y gestionar cada mensaje por separado,
|
||||
desactiva **Vista de conversación** en Ajustes → General.
|
||||
|
||||
Usa **Arriba/Abajo** para recorrer los mensajes, **Inicio/Fin** para ir al
|
||||
primer o último mensaje cargado y **Re Pág/Av Pág** para avanzar una página.
|
||||
|
||||
@@ -95,7 +95,7 @@ Vous pouvez aussi remplacer la couleur d’un compte particulier et afficher la
|
||||
|
||||
### 🧵 Fils de discussion
|
||||
|
||||
Les réponses restent regroupées par ordre chronologique, avec le message actif déplié dans le volet de lecture.
|
||||
Les réponses restent regroupées par ordre chronologique, avec le message actif déplié dans le volet de lecture. Pour afficher, ouvrir et traiter chaque message séparément, désactivez **Vue conversation** dans Paramètres → Général.
|
||||
|
||||
Utilisez **Haut/Bas** pour parcourir les messages, **Début/Fin** pour atteindre le premier ou le dernier message chargé, et **Page précédente/Page suivante** pour avancer d’une page. **Entrée** ouvre le message courant et place le focus sur son contenu ; **Espace** le coche ou le décoche pour les actions groupées. **Tab/Maj+Tab** passent d’un contrôle à l’autre. Dans l’espace complet, **F6** déplace le focus entre la liste et le lecteur. Dans la disposition minimaliste, les flèches mettent les messages en surbrillance et **Entrée** les ouvre ; **Échap** ou **F6** revient à la liste.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ JMAP/WebDAV स्टोरेज देखें, ईमेल अटैचम
|
||||
|
||||
### 🧵 बातचीत के थ्रेड
|
||||
|
||||
जवाब समय के क्रम में एक साथ रहते हैं और सक्रिय संदेश रीडिंग पैन में खुला दिखता है।
|
||||
जवाब समय के क्रम में एक साथ रहते हैं और सक्रिय संदेश रीडिंग पैन में खुला दिखता है। हर संदेश को अलग से देखने, खोलने और उस पर कार्रवाई करने के लिए सेटिंग्स → सामान्य में **बातचीत व्यू** बंद करें।
|
||||
|
||||
संदेशों के बीच जाने के लिए **ऊपर/नीचे**, पहले या आख़िरी लोड हुए संदेश पर जाने के लिए **Home/End** और एक पृष्ठ आगे-पीछे जाने के लिए **Page Up/Page Down** इस्तेमाल करें। **Enter** वर्तमान संदेश खोलता है और उसके कंटेंट पर फ़ोकस करता है; **Space** सामूहिक कार्रवाइयों के लिए उसे चुनता या चयन हटाता है। **Tab/Shift+Tab** कंट्रोल के बीच जाते हैं। पूर्ण कार्यक्षेत्र में **F6** संदेश सूची और रीडर के बीच फ़ोकस बदलता है। मिनिमल लेआउट में तीर कुंजियाँ संदेश हाइलाइट करती हैं और **Enter** उन्हें खोलता है; **Escape** या **F6** सूची पर लौटाता है।
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Você também pode substituir a cor de uma conta individual e mostrar a cor resu
|
||||
|
||||
### 🧵 Conversas
|
||||
|
||||
As respostas ficam agrupadas em ordem cronológica, com a mensagem ativa expandida no painel de leitura.
|
||||
As respostas ficam agrupadas em ordem cronológica, com a mensagem ativa expandida no painel de leitura. Para listar, abrir e tratar cada mensagem separadamente, desative **Visualização de conversa** em Configurações → Geral.
|
||||
|
||||
Use **Cima/Baixo** para navegar pelas mensagens, **Home/End** para ir à primeira ou à última mensagem carregada e **Page Up/Page Down** para avançar uma página por vez. **Enter** abre a mensagem atual e leva o foco ao conteúdo; **Espaço** marca ou desmarca a mensagem para ações em lote. **Tab/Shift+Tab** alternam entre controles. No espaço completo, **F6** move o foco entre a lista e o leitor. No layout minimalista, as setas destacam mensagens e **Enter** as abre; **Escape** ou **F6** retorna à lista.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ S/MIME и OpenPGP на мобильных устройствах пока не
|
||||
|
||||
### 🧵 Цепочки писем
|
||||
|
||||
Ответы группируются в хронологическом порядке, а активное сообщение разворачивается в панели чтения.
|
||||
Ответы группируются в хронологическом порядке, а активное сообщение разворачивается в панели чтения. Чтобы показывать, открывать и обрабатывать каждое письмо отдельно, отключите **Цепочки писем** в разделе Настройки → Общие.
|
||||
|
||||
Используйте **Вверх/Вниз** для перемещения по сообщениям, **Home/End** для перехода к первому или последнему загруженному письму и **Page Up/Page Down** для постраничного перемещения. **Enter** открывает текущее сообщение и переводит фокус на его содержимое; **Пробел** отмечает или снимает отметку для групповых действий. **Tab/Shift+Tab** переключают элементы управления. В полном интерфейсе **F6** переносит фокус между списком писем и областью чтения. В минималистичном виде стрелки выделяют письма, **Enter** открывает их, а **Escape** или **F6** возвращает к списку.
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Flectar Mail 是一个轻量、原生的客户端,用来承载你的邮件、
|
||||
|
||||
### 🧵 会话线程
|
||||
|
||||
回复按时间顺序归组显示,当前邮件在阅读窗格中展开。
|
||||
回复按时间顺序归组显示,当前邮件在阅读窗格中展开。如需逐封列出、打开和处理邮件,可在“设置 → 通用”中关闭 **Conversation view**(会话视图)。
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="../../resources/screenshots/desktop-thread-dark.png">
|
||||
|
||||
+665
-643
File diff suppressed because it is too large.
Load diff
+665
-643
File diff suppressed because it is too large.
Load diff
+657
-633
File diff suppressed because it is too large.
Load diff
+718
-696
File diff suppressed because it is too large.
Load diff
+654
-632
File diff suppressed because it is too large.
Load diff
+665
-643
File diff suppressed because it is too large.
Load diff
+665
-643
File diff suppressed because it is too large.
Load diff
+665
-643
File diff suppressed because it is too large.
Load diff
+657
-633
File diff suppressed because it is too large.
Load diff
+654
-632
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"format_version": 7,
|
||||
"saved_at_ms": 1787961600000,
|
||||
"scope": "Unified Inbox",
|
||||
"inbox_count": 2,
|
||||
"grouping": "conversations",
|
||||
"next_cursor": null,
|
||||
"accounts": [
|
||||
{
|
||||
"id": 1,
|
||||
"email": "alex@example.com",
|
||||
"displayName": "Example account",
|
||||
"avatarUrl": null,
|
||||
"provider": "generic",
|
||||
"authKind": "password",
|
||||
"mailProtocol": "imap",
|
||||
"syncState": "idle",
|
||||
"syncError": null,
|
||||
"canCreateTopLevelMailbox": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": 101,
|
||||
"thread_id": 101,
|
||||
"account": "Example account",
|
||||
"folder": "Inbox",
|
||||
"sender": "Design team",
|
||||
"address": "design@example.net",
|
||||
"domain": "example.net",
|
||||
"initials": "DT",
|
||||
"subject": "Updated launch review",
|
||||
"preview": "The latest native UI build is ready to review.",
|
||||
"date_ms": 1787954400000,
|
||||
"label": "UNREAD",
|
||||
"unread": true,
|
||||
"starred": true,
|
||||
"has_attachments": false,
|
||||
"has_replied": true,
|
||||
"sender_verification": ""
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"thread_id": 102,
|
||||
"account": "Example account",
|
||||
"folder": "Inbox",
|
||||
"sender": "Build service",
|
||||
"address": "builds@example.org",
|
||||
"domain": "example.org",
|
||||
"initials": "BS",
|
||||
"subject": "Native checks completed",
|
||||
"preview": "All desktop platform checks passed.",
|
||||
"date_ms": 1787868000000,
|
||||
"label": "UNREAD",
|
||||
"unread": true,
|
||||
"starred": false,
|
||||
"has_attachments": true,
|
||||
"has_replied": false,
|
||||
"sender_verification": "domain"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"thread_id": 103,
|
||||
"account": "Example account",
|
||||
"folder": "Inbox",
|
||||
"sender": "Product team",
|
||||
"address": "product@example.com",
|
||||
"domain": "example.com",
|
||||
"initials": "PT",
|
||||
"subject": "Mailbox startup notes",
|
||||
"preview": "Here are the measurements from the latest run.",
|
||||
"date_ms": 1787824800000,
|
||||
"label": "MAIL",
|
||||
"unread": false,
|
||||
"starred": false,
|
||||
"has_attachments": false,
|
||||
"has_replied": false,
|
||||
"sender_verification": ""
|
||||
}
|
||||
],
|
||||
"mailboxes": [
|
||||
{
|
||||
"account_id": 1,
|
||||
"label": "Example account",
|
||||
"scope": null,
|
||||
"context": "Example account",
|
||||
"detail": "alex@example.com",
|
||||
"avatar": "EA",
|
||||
"is_account": true,
|
||||
"can_create_children": true,
|
||||
"count": "2"
|
||||
},
|
||||
{
|
||||
"account_id": 1,
|
||||
"label": "Inbox",
|
||||
"scope": "Account:1:Inbox",
|
||||
"context": "Example account",
|
||||
"detail": "",
|
||||
"avatar": "",
|
||||
"is_account": false,
|
||||
"count": "2"
|
||||
},
|
||||
{
|
||||
"account_id": 1,
|
||||
"label": "Starred",
|
||||
"scope": "Account:1:Starred",
|
||||
"context": "Example account",
|
||||
"detail": "",
|
||||
"avatar": "",
|
||||
"is_account": false,
|
||||
"count": "1"
|
||||
}
|
||||
],
|
||||
"unified_mailboxes": [
|
||||
{
|
||||
"account_id": 0,
|
||||
"label": "Starred",
|
||||
"scope": "Unified Starred",
|
||||
"context": "",
|
||||
"detail": "",
|
||||
"avatar": "",
|
||||
"is_account": false,
|
||||
"count": "1"
|
||||
},
|
||||
{
|
||||
"account_id": 0,
|
||||
"label": "Sent",
|
||||
"scope": "Unified Sent",
|
||||
"context": "",
|
||||
"detail": "",
|
||||
"avatar": "",
|
||||
"is_account": false,
|
||||
"count": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -817,12 +817,12 @@ fn register_keys_and_reader(app: &AppWindow, state: &Rc<RefCell<InboxState>>, ta
|
||||
ui.on_read_message(move || {
|
||||
let Some(app) = weak.upgrade() else { return };
|
||||
let Some(core) = core(&state) else { return };
|
||||
let Some((selected_id, thread_id)) = ({
|
||||
let Some((selected_id, target)) = ({
|
||||
let state = state.borrow();
|
||||
state
|
||||
.selected_id
|
||||
.and_then(|id| state.messages.iter().find(|m| m.id == id))
|
||||
.and_then(|m| m.thread_id.map(|thread| (m.id, thread)))
|
||||
.and_then(|m| crate::mail::MailTarget::of(m).map(|target| (m.id, target)))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
@@ -842,11 +842,18 @@ fn register_keys_and_reader(app: &AppWindow, state: &Rc<RefCell<InboxState>>, ta
|
||||
&app,
|
||||
true,
|
||||
async move {
|
||||
let detail = core
|
||||
.get_latest_thread_body(thread_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
core.open_openpgp_message(detail.id)
|
||||
// A conversation opens its newest message; a single message's
|
||||
// row, that message.
|
||||
let message_id = match target {
|
||||
crate::mail::MailTarget::Message { id, .. } => id,
|
||||
crate::mail::MailTarget::Thread(thread_id) => {
|
||||
core.get_latest_thread_body(thread_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.id
|
||||
}
|
||||
};
|
||||
core.open_openpgp_message(message_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
},
|
||||
|
||||
@@ -265,14 +265,14 @@ pub(super) fn perform_selected_action(
|
||||
_runtime: &tokio::runtime::Runtime,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
let (selected_id, thread_id, core, using_core) = {
|
||||
let (selected_id, target, core, using_core) = {
|
||||
let state = state.borrow();
|
||||
let selected = state
|
||||
.selected_id
|
||||
.and_then(|id| state.messages.iter().find(|message| message.id == id));
|
||||
(
|
||||
state.selected_id,
|
||||
selected.and_then(|message| message.thread_id),
|
||||
selected.and_then(MailTarget::of),
|
||||
state.core.clone(),
|
||||
state.using_core,
|
||||
)
|
||||
@@ -281,13 +281,13 @@ pub(super) fn perform_selected_action(
|
||||
return Err("mail account is not ready".to_owned());
|
||||
}
|
||||
let core = core.ok_or_else(|| "mail core is unavailable".to_owned())?;
|
||||
let thread_id = thread_id.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
let target = target.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
mail_work::enqueue(
|
||||
state,
|
||||
core,
|
||||
vec![(
|
||||
selected_id.unwrap(),
|
||||
thread_id,
|
||||
target,
|
||||
mail_work::Operation::Action(action.to_owned()),
|
||||
)],
|
||||
)
|
||||
|
||||
@@ -420,6 +420,12 @@ pub(super) fn restore_account_backup(
|
||||
{
|
||||
settings.show_tabs = enabled;
|
||||
}
|
||||
if let Some(enabled) = preferences
|
||||
.get("conversationView")
|
||||
.and_then(|value| value.as_bool())
|
||||
{
|
||||
settings.conversation_view = enabled;
|
||||
}
|
||||
if let Some(frame) = preferences
|
||||
.get("windowFrame")
|
||||
.and_then(|value| value.as_str())
|
||||
@@ -638,6 +644,7 @@ pub(super) fn restore_account_backup(
|
||||
.set_preference(settings.message_appearance.as_str().into());
|
||||
app.set_show_avatars(settings.show_avatars);
|
||||
app.set_show_tabs(settings.show_tabs);
|
||||
apply_mail_grouping(app, state, runtime, settings.mail_grouping());
|
||||
app.set_show_account_markers(settings.show_account_badges);
|
||||
crate::window_chrome::apply_preference(
|
||||
app,
|
||||
@@ -1308,6 +1315,7 @@ pub(super) fn register_data_management_callbacks(
|
||||
"messageAppearance": settings.message_appearance,
|
||||
"showAvatars": settings.show_avatars,
|
||||
"showTabs": settings.show_tabs,
|
||||
"conversationView": settings.conversation_view,
|
||||
"workspaceLayout": settings.workspace_layout,
|
||||
"workspaceListPaneWidth": settings.workspace_list_pane_width,
|
||||
"monochromeSidebarIcons": settings.monochrome_sidebar_icons,
|
||||
@@ -1626,6 +1634,12 @@ pub(super) fn register_data_management_callbacks(
|
||||
crate::app_icon::apply(&app, Default::default());
|
||||
app.set_show_avatars(true);
|
||||
app.set_show_tabs(true);
|
||||
apply_mail_grouping(
|
||||
&app,
|
||||
&data_reset_state,
|
||||
&data_reset_runtime,
|
||||
MailGrouping::Conversations,
|
||||
);
|
||||
app.set_show_account_markers(false);
|
||||
app.set_workspace_layout("default".into());
|
||||
app.set_workspace_list_pane_width(MIN_WORKSPACE_LIST_PANE_WIDTH as f32);
|
||||
|
||||
+409
-46
@@ -5,13 +5,14 @@ use flectar_mail_core::{
|
||||
config::Paths,
|
||||
events::CoreEvent,
|
||||
models::{
|
||||
Account, AccountConfig, ActionKind, ActionParams, AddPasswordAccountArgs, Address,
|
||||
CalendarConnection, CalendarEvent, CardDavConnection, ConnectCaldavArgs,
|
||||
Account, AccountConfig, ActionKind, ActionParams, ActionTarget, AddPasswordAccountArgs,
|
||||
Address, CalendarConnection, CalendarEvent, CardDavConnection, ConnectCaldavArgs,
|
||||
ConnectCardDavArgs, ContactRecordCursor, ContactRecordPage, CreateEventArgs, CustomTheme,
|
||||
DraftAttachmentIn, FolderInfo, Label, MailHistory, MailProfile, MailProtocol,
|
||||
MailboxBadgeCounts, MessageDetail, PerformActionArgs, PortableAccountConfig, Provider,
|
||||
QueueSendArgs, QueueSendResult, SaveDraftArgs, SearchCursor, Settings, Snippet,
|
||||
ThreadCursor, ThreadSummary, View, normalized_workspace_list_pane_width,
|
||||
DraftAttachmentIn, FolderInfo, Label, MailGrouping, MailHistory, MailProfile, MailProtocol,
|
||||
MailboxBadgeCounts, MessageCursor, MessageDetail, MessageSearchCursor, MessageSummary,
|
||||
PerformActionArgs, PortableAccountConfig, Provider, QueueSendArgs, QueueSendResult,
|
||||
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary, View,
|
||||
normalized_workspace_list_pane_width,
|
||||
},
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -48,6 +49,9 @@ pub struct EmailFixture {
|
||||
pub struct MailMessage {
|
||||
pub id: i32,
|
||||
pub thread_id: Option<i64>,
|
||||
/// The message this row shows on its own, in mail lists without
|
||||
/// conversations; `None` for a conversation's row.
|
||||
pub message_id: Option<i64>,
|
||||
pub account_id: i64,
|
||||
pub account: String,
|
||||
pub folder: String,
|
||||
@@ -94,6 +98,7 @@ impl MailMessage {
|
||||
Self {
|
||||
id: email.id,
|
||||
thread_id: None,
|
||||
message_id: None,
|
||||
account_id: -1,
|
||||
account: email.account.to_owned(),
|
||||
folder: email.folder.to_owned(),
|
||||
@@ -200,6 +205,40 @@ pub struct MailPage {
|
||||
pub enum MailCursor {
|
||||
Thread(ThreadCursor),
|
||||
Search(SearchCursor),
|
||||
Message(MessageCursor),
|
||||
MessageSearch(MessageSearchCursor),
|
||||
}
|
||||
|
||||
/// What an action on a mail-list row applies to: its conversation, or the one
|
||||
/// message the row shows. Tab routing and snooze are a conversation's either way.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum MailTarget {
|
||||
Thread(i64),
|
||||
Message { id: i64, thread_id: i64 },
|
||||
}
|
||||
|
||||
impl MailTarget {
|
||||
/// The target of a row, when it is backed by the mail core.
|
||||
pub fn of(row: &MailMessage) -> Option<Self> {
|
||||
let thread_id = row.thread_id?;
|
||||
Some(match row.message_id {
|
||||
Some(id) => Self::Message { id, thread_id },
|
||||
None => Self::Thread(thread_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread_id(self) -> i64 {
|
||||
match self {
|
||||
Self::Thread(thread_id) | Self::Message { thread_id, .. } => thread_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_target(self) -> ActionTarget {
|
||||
match self {
|
||||
Self::Thread(thread_id) => ActionTarget::Threads(vec![thread_id]),
|
||||
Self::Message { id, .. } => ActionTarget::Messages(vec![id]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -297,6 +336,8 @@ pub struct CoreMailSource {
|
||||
/// The active profile's accounts, sorted; the mail view shows only these.
|
||||
/// `None` shows every account.
|
||||
profile_accounts: Arc<std::sync::Mutex<Option<Arc<[i64]>>>>,
|
||||
/// Whether mail lists show conversations or each message on its own.
|
||||
grouping: Arc<std::sync::Mutex<MailGrouping>>,
|
||||
}
|
||||
|
||||
pub struct AccountSnapshot {
|
||||
@@ -337,6 +378,34 @@ impl CoreMailSource {
|
||||
true
|
||||
}
|
||||
|
||||
/// Show conversations, or each message on its own. Returns whether that
|
||||
/// changed; pages and counts loaded in the other grouping are then out of
|
||||
/// date.
|
||||
pub fn set_grouping(&self, grouping: MailGrouping) -> bool {
|
||||
{
|
||||
let mut current = self
|
||||
.grouping
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if *current == grouping {
|
||||
return false;
|
||||
}
|
||||
*current = grouping;
|
||||
}
|
||||
// After the new grouping is in place: a load that reads the new
|
||||
// revision also reads it.
|
||||
self.account_revision
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn grouping(&self) -> MailGrouping {
|
||||
*self
|
||||
.grouping
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn profile_accounts(&self) -> Option<Arc<[i64]>> {
|
||||
self.profile_accounts
|
||||
.lock()
|
||||
@@ -390,6 +459,7 @@ impl CoreMailSource {
|
||||
core: Arc::new(core),
|
||||
account_revision: Arc::default(),
|
||||
profile_accounts: Arc::default(),
|
||||
grouping: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -535,7 +605,7 @@ impl CoreMailSource {
|
||||
) -> Result<MailMetadata, String> {
|
||||
let mut badges = self
|
||||
.core
|
||||
.mailbox_badge_counts()
|
||||
.mailbox_badge_counts(self.grouping())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
// Unified totals add up the shown accounts only.
|
||||
@@ -600,13 +670,34 @@ impl CoreMailSource {
|
||||
// accounts; an account's own mailboxes need no more than its id.
|
||||
let profile_accounts = profile.filter(|_| resolved.account_id.is_none());
|
||||
|
||||
if self.grouping() == MailGrouping::Messages {
|
||||
let (rows, next_cursor) = self
|
||||
.load_message_rows(&resolved, profile_accounts, query, cursor, limit)
|
||||
.await?;
|
||||
let messages = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
message_summary_to_message(row, accounts, resolved.title.as_str())
|
||||
})
|
||||
.collect();
|
||||
return Ok(MailPage {
|
||||
account_revision: self.account_revision(),
|
||||
messages,
|
||||
labels,
|
||||
mailboxes,
|
||||
next_cursor,
|
||||
account_count,
|
||||
unified_mailboxes: metadata
|
||||
.map(|metadata| metadata.unified_mailboxes)
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
let (threads, next_cursor) = if query.trim().is_empty() {
|
||||
let cursor = match cursor {
|
||||
None => None,
|
||||
Some(MailCursor::Thread(cursor)) => Some(cursor),
|
||||
Some(MailCursor::Search(_)) => {
|
||||
return Err("search cursor used for a mailbox".into());
|
||||
}
|
||||
Some(_) => return Err("this cursor belongs to another list".into()),
|
||||
};
|
||||
let page = match profile_accounts {
|
||||
Some(accounts) => {
|
||||
@@ -643,7 +734,7 @@ impl CoreMailSource {
|
||||
let cursor = match cursor {
|
||||
None => None,
|
||||
Some(MailCursor::Search(cursor)) => Some(cursor),
|
||||
Some(MailCursor::Thread(_)) => return Err("mailbox cursor used for search".into()),
|
||||
Some(_) => return Err("this cursor belongs to another list".into()),
|
||||
};
|
||||
let query = query.trim().to_owned();
|
||||
let page = match profile_accounts {
|
||||
@@ -685,6 +776,77 @@ impl CoreMailSource {
|
||||
})
|
||||
}
|
||||
|
||||
/// One page of the mailbox or search as single messages, for mail lists
|
||||
/// without conversations.
|
||||
async fn load_message_rows(
|
||||
&self,
|
||||
resolved: &ScopeResolution,
|
||||
profile_accounts: Option<&[i64]>,
|
||||
query: &str,
|
||||
cursor: Option<MailCursor>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<MessageSummary>, Option<MailCursor>), String> {
|
||||
if query.trim().is_empty() {
|
||||
let cursor = match cursor {
|
||||
None => None,
|
||||
Some(MailCursor::Message(cursor)) => Some(cursor),
|
||||
Some(_) => return Err("this cursor belongs to another list".into()),
|
||||
};
|
||||
let page = flectar_mail_core::MessagePageRequest { cursor, limit };
|
||||
let page = match profile_accounts {
|
||||
Some(accounts) => {
|
||||
self.core
|
||||
.list_messages_for_accounts(
|
||||
resolved.view,
|
||||
resolved.split_id,
|
||||
accounts.to_vec(),
|
||||
resolved.label_id,
|
||||
resolved.folder_id,
|
||||
page,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.core
|
||||
.list_messages(
|
||||
resolved.view,
|
||||
resolved.split_id,
|
||||
resolved.account_id,
|
||||
resolved.label_id,
|
||||
resolved.folder_id,
|
||||
page,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok((page.messages, page.next_cursor.map(MailCursor::Message)));
|
||||
}
|
||||
let cursor = match cursor {
|
||||
None => None,
|
||||
Some(MailCursor::MessageSearch(cursor)) => Some(cursor),
|
||||
Some(_) => return Err("this cursor belongs to another list".into()),
|
||||
};
|
||||
let query = query.trim().to_owned();
|
||||
let page = match profile_accounts {
|
||||
Some(accounts) => {
|
||||
self.core
|
||||
.search_messages_page_for_accounts(query, accounts.to_vec(), cursor, limit)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.core
|
||||
.search_messages_page(query, resolved.account_id, cursor, limit)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((
|
||||
page.messages,
|
||||
page.next_cursor.map(MailCursor::MessageSearch),
|
||||
))
|
||||
}
|
||||
|
||||
/// Load the selected thread as independent messages. Bodies are still
|
||||
/// fetched lazily by the core and the UI expands only one message at a
|
||||
/// time, so opening a long conversation does not create a renderer per
|
||||
@@ -694,6 +856,19 @@ impl CoreMailSource {
|
||||
row: &MailMessage,
|
||||
selected_message_id: Option<i64>,
|
||||
) -> Result<MailConversation, String> {
|
||||
// A row of a list without conversations opens only its message.
|
||||
if let Some(message_id) = row.message_id {
|
||||
let message = self
|
||||
.core
|
||||
.get_body(message_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let message = detail_to_conversation_message(row, &message, true)
|
||||
.ok_or_else(|| "message id is out of range".to_owned())?;
|
||||
return Ok(MailConversation {
|
||||
messages: vec![message],
|
||||
});
|
||||
}
|
||||
let thread_id = row
|
||||
.thread_id
|
||||
.ok_or_else(|| "message is not backed by a core thread".to_owned())?;
|
||||
@@ -736,6 +911,8 @@ impl CoreMailSource {
|
||||
row: &MailMessage,
|
||||
message_id: Option<i64>,
|
||||
) -> Result<ComposeSource, String> {
|
||||
// A single message's row answers that message.
|
||||
let message_id = message_id.or(row.message_id);
|
||||
let thread_id = row
|
||||
.thread_id
|
||||
.ok_or_else(|| "message is not backed by a core thread".to_owned())?;
|
||||
@@ -835,6 +1012,14 @@ impl CoreMailSource {
|
||||
}
|
||||
|
||||
pub async fn load_draft(&self, row: &MailMessage) -> Result<SaveDraftArgs, String> {
|
||||
// A draft listed on its own is the one to edit.
|
||||
if let Some(draft_id) = row.message_id {
|
||||
return self
|
||||
.core
|
||||
.get_draft(draft_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
let thread_id = row
|
||||
.thread_id
|
||||
.ok_or_else(|| "draft is not backed by a core thread".to_owned())?;
|
||||
@@ -1192,6 +1377,15 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn set_conversation_view(&self, enabled: bool) -> Result<(), String> {
|
||||
let mut settings = self.load_settings().await?;
|
||||
settings.conversation_view = enabled;
|
||||
self.core
|
||||
.set_settings(settings)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn set_group_mail_by_date(&self, enabled: bool) -> Result<(), String> {
|
||||
self.core
|
||||
.set_group_mail_by_date(enabled)
|
||||
@@ -1496,13 +1690,17 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn perform_message_action(&self, thread_id: i64, action: &str) -> Result<(), String> {
|
||||
pub async fn perform_message_action(
|
||||
&self,
|
||||
target: MailTarget,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
let kind = ActionKind::parse(action)
|
||||
.ok_or_else(|| format!("unsupported message action: {action}"))?;
|
||||
self.core
|
||||
.perform_action(PerformActionArgs {
|
||||
kind,
|
||||
thread_ids: vec![thread_id],
|
||||
target: target.action_target(),
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
@@ -1520,7 +1718,7 @@ impl CoreMailSource {
|
||||
|
||||
pub async fn perform_label_action(
|
||||
&self,
|
||||
thread_id: i64,
|
||||
target: MailTarget,
|
||||
label_id: i64,
|
||||
add: bool,
|
||||
) -> Result<(), String> {
|
||||
@@ -1531,7 +1729,7 @@ impl CoreMailSource {
|
||||
} else {
|
||||
ActionKind::RemoveLabel
|
||||
},
|
||||
thread_ids: vec![thread_id],
|
||||
target: target.action_target(),
|
||||
params: Some(ActionParams {
|
||||
wake_at: None,
|
||||
target_folder_id: None,
|
||||
@@ -1543,15 +1741,15 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn move_thread_to_folder(
|
||||
pub async fn move_to_folder(
|
||||
&self,
|
||||
thread_id: i64,
|
||||
target: MailTarget,
|
||||
target_folder_id: i64,
|
||||
) -> Result<(), String> {
|
||||
self.core
|
||||
.perform_action(PerformActionArgs {
|
||||
kind: ActionKind::Move,
|
||||
thread_ids: vec![thread_id],
|
||||
target: target.action_target(),
|
||||
params: Some(ActionParams {
|
||||
wake_at: None,
|
||||
target_folder_id: Some(target_folder_id),
|
||||
@@ -1563,9 +1761,10 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn route_thread_to_tab(&self, thread_id: i64, target: String) -> Result<(), String> {
|
||||
/// Inbox tabs route conversations, so a message's routes its conversation.
|
||||
pub async fn route_to_tab(&self, target: MailTarget, tab: String) -> Result<(), String> {
|
||||
self.core
|
||||
.route_thread_to_tab(thread_id, target)
|
||||
.route_thread_to_tab(target.thread_id(), tab)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -2463,28 +2662,7 @@ fn summary_to_message(
|
||||
if display_participants.is_empty() {
|
||||
display_participants.extend(thread.participants.iter());
|
||||
}
|
||||
let participant = display_participants.first().copied();
|
||||
let address = participant
|
||||
.map(|person| person.email.clone())
|
||||
.unwrap_or_default();
|
||||
fn display_name(person: &Address) -> &str {
|
||||
person
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or(&person.email)
|
||||
}
|
||||
let sender = match display_participants.as_slice() {
|
||||
[] => address.clone(),
|
||||
[only] => display_name(only).to_owned(),
|
||||
[first, second] => format!("{}, {}", display_name(first), display_name(second)),
|
||||
[first, second, rest @ ..] => format!(
|
||||
"{}, {} +{}",
|
||||
display_name(first),
|
||||
display_name(second),
|
||||
rest.len()
|
||||
),
|
||||
};
|
||||
let (sender, address) = display_people(&display_participants);
|
||||
let thread_account = accounts
|
||||
.iter()
|
||||
.find(|account| account.id == thread.account_id);
|
||||
@@ -2497,6 +2675,7 @@ fn summary_to_message(
|
||||
Some(MailMessage {
|
||||
id,
|
||||
thread_id: Some(thread.id),
|
||||
message_id: None,
|
||||
account_id: thread.account_id,
|
||||
account,
|
||||
folder: folder.to_owned(),
|
||||
@@ -2533,6 +2712,111 @@ fn summary_to_message(
|
||||
})
|
||||
}
|
||||
|
||||
/// A row's people, named as "A", "A, B" or "A, B +N", and the first one's
|
||||
/// address, which names the row's avatar.
|
||||
fn display_people(people: &[&Address]) -> (String, String) {
|
||||
fn display_name(person: &Address) -> &str {
|
||||
person
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or(&person.email)
|
||||
}
|
||||
let address = people
|
||||
.first()
|
||||
.map(|person| person.email.clone())
|
||||
.unwrap_or_default();
|
||||
let sender = match people {
|
||||
[] => address.clone(),
|
||||
[only] => display_name(only).to_owned(),
|
||||
[first, second] => format!("{}, {}", display_name(first), display_name(second)),
|
||||
[first, second, rest @ ..] => format!(
|
||||
"{}, {} +{}",
|
||||
display_name(first),
|
||||
display_name(second),
|
||||
rest.len()
|
||||
),
|
||||
};
|
||||
(sender, address)
|
||||
}
|
||||
|
||||
/// One message as its own mail-list row. A row is named by the other side of
|
||||
/// the exchange: the sender, or for mail sent from the account, its
|
||||
/// recipients. The subject keeps its reply prefixes, which tell messages of a
|
||||
/// conversation apart.
|
||||
fn message_summary_to_message(
|
||||
message: MessageSummary,
|
||||
accounts: &[Account],
|
||||
folder: &str,
|
||||
) -> Option<MailMessage> {
|
||||
let id = i32::try_from(message.id).ok()?;
|
||||
let own = |person: &Address| {
|
||||
message
|
||||
.account_addresses
|
||||
.iter()
|
||||
.any(|address| address.eq_ignore_ascii_case(&person.email))
|
||||
};
|
||||
let sent = message.is_outgoing || message.is_draft || own(&message.from);
|
||||
let people = if sent && !message.to.is_empty() {
|
||||
message.to.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![&message.from]
|
||||
};
|
||||
let (sender, address) = display_people(&people);
|
||||
let message_account = accounts
|
||||
.iter()
|
||||
.find(|account| account.id == message.account_id);
|
||||
let account = message_account
|
||||
.map(account_label)
|
||||
.unwrap_or_else(|| message.account_email.clone().unwrap_or_default());
|
||||
let domain = domain_from_address(&address).unwrap_or_default();
|
||||
let initials = initials(&sender);
|
||||
Some(MailMessage {
|
||||
id,
|
||||
thread_id: Some(message.thread_id),
|
||||
message_id: Some(message.id),
|
||||
account_id: message.account_id,
|
||||
account,
|
||||
folder: folder.to_owned(),
|
||||
sender,
|
||||
address,
|
||||
domain,
|
||||
initials,
|
||||
subject: message.subject.trim().to_owned(),
|
||||
preview: message.snippet,
|
||||
date_ms: message.date,
|
||||
to: message
|
||||
.to
|
||||
.first()
|
||||
.map(|address| address.email.clone())
|
||||
.unwrap_or_default(),
|
||||
label: if message.is_draft {
|
||||
"DRAFT".to_owned()
|
||||
} else if !message.is_read {
|
||||
"UNREAD".to_owned()
|
||||
} else {
|
||||
"MAIL".to_owned()
|
||||
},
|
||||
unread: !message.is_read && !message.is_draft,
|
||||
starred: message.is_starred,
|
||||
has_attachments: message.has_attachments,
|
||||
message_count: 1,
|
||||
attachments: Vec::new(),
|
||||
has_replied: message.has_replied,
|
||||
is_outgoing: message.is_outgoing,
|
||||
labels: message.labels,
|
||||
html: None,
|
||||
text: None,
|
||||
body_pending: true,
|
||||
sender_verification: String::new(),
|
||||
verification_code: message.verification_code,
|
||||
account_uses_labels: message_account
|
||||
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
|
||||
first_attachment: message.first_attachment,
|
||||
attachment_total: message.attachment_total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep the newest message's display casing and local prefixes while removing
|
||||
/// transport reply prefixes that belong to individual messages, not the
|
||||
/// conversation title.
|
||||
@@ -2590,6 +2874,7 @@ fn detail_to_message(row: &MailMessage, message: &MessageDetail, body_loaded: bo
|
||||
MailMessage {
|
||||
id: row.id,
|
||||
thread_id: row.thread_id,
|
||||
message_id: row.message_id,
|
||||
account_id: row.account_id,
|
||||
account: row.account.clone(),
|
||||
folder: row.folder.clone(),
|
||||
@@ -2849,13 +3134,15 @@ pub fn fixtures() -> Vec<EmailFixture> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ComposeMessage, compose_args, display_thread_subject, mailbox_entries, markdown_to_html,
|
||||
markdown_to_plain_text, readable_message_html, resolve_scope, summary_to_message,
|
||||
validated_startup_scope, with_local_mail,
|
||||
ComposeMessage, MailTarget, compose_args, display_thread_subject, mailbox_entries,
|
||||
markdown_to_html, markdown_to_plain_text, message_summary_to_message,
|
||||
readable_message_html, resolve_scope, summary_to_message, validated_startup_scope,
|
||||
with_local_mail,
|
||||
};
|
||||
use crate::mail_scope::{LabelKind, Scope, StandardMailbox};
|
||||
use flectar_mail_core::models::{
|
||||
Account, Address, AuthKind, FolderInfo, Label, MailProtocol, Provider, ThreadSummary, View,
|
||||
Account, ActionTarget, Address, AuthKind, FolderInfo, Label, MailProtocol, MessageSummary,
|
||||
Provider, ThreadSummary, View,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -3001,6 +3288,81 @@ mod tests {
|
||||
assert_eq!(prepared.plain_text, text);
|
||||
}
|
||||
|
||||
fn message_summary(from: &str, to: &[&str], outgoing: bool) -> MessageSummary {
|
||||
MessageSummary {
|
||||
id: 41,
|
||||
thread_id: 22,
|
||||
account_id: 1,
|
||||
account_email: Some("person@example.com".into()),
|
||||
account_addresses: vec!["person@example.com".into()],
|
||||
subject: "Re: Launch review".into(),
|
||||
snippet: "Tuesday works.".into(),
|
||||
from: Address {
|
||||
name: None,
|
||||
email: from.into(),
|
||||
},
|
||||
to: to
|
||||
.iter()
|
||||
.map(|email| Address {
|
||||
name: None,
|
||||
email: (*email).into(),
|
||||
})
|
||||
.collect(),
|
||||
date: 5,
|
||||
is_read: false,
|
||||
is_starred: true,
|
||||
is_draft: false,
|
||||
is_outgoing: outgoing,
|
||||
has_attachments: false,
|
||||
has_replied: false,
|
||||
snoozed_until: None,
|
||||
labels: vec![3],
|
||||
verification_code: None,
|
||||
first_attachment: None,
|
||||
attachment_total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_row_shows_that_message_and_acts_on_it_alone() {
|
||||
let row = message_summary_to_message(
|
||||
message_summary("maya@example.com", &["person@example.com"], false),
|
||||
&[test_account()],
|
||||
"Inbox",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(row.id, 41);
|
||||
assert_eq!(row.message_id, Some(41));
|
||||
assert_eq!(row.thread_id, Some(22));
|
||||
assert_eq!(row.sender, "maya@example.com");
|
||||
// Reply prefixes tell the messages of a conversation apart.
|
||||
assert_eq!(row.subject, "Re: Launch review");
|
||||
assert_eq!(row.message_count, 1);
|
||||
assert!(row.unread && row.starred);
|
||||
assert_eq!(row.labels, [3]);
|
||||
assert_eq!(
|
||||
MailTarget::of(&row),
|
||||
Some(MailTarget::Message {
|
||||
id: 41,
|
||||
thread_id: 22
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
MailTarget::of(&row).unwrap().action_target(),
|
||||
ActionTarget::Messages(vec![41])
|
||||
);
|
||||
|
||||
// Mail the account sent is named by its recipients.
|
||||
let sent = message_summary(
|
||||
"person@example.com",
|
||||
&["maya@example.com", "alex@example.com"],
|
||||
true,
|
||||
);
|
||||
let row = message_summary_to_message(sent, &[test_account()], "Sent").unwrap();
|
||||
assert_eq!(row.sender, "maya@example.com, alex@example.com");
|
||||
assert_eq!(row.address, "maya@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_summary_projects_participants_and_message_count() {
|
||||
let thread = ThreadSummary {
|
||||
@@ -3050,6 +3412,7 @@ mod tests {
|
||||
|
||||
assert_eq!(row.sender, "Maya, Alex +1");
|
||||
assert_eq!(row.address, "maya@example.com");
|
||||
assert_eq!(MailTarget::of(&row), Some(MailTarget::Thread(22)));
|
||||
assert_eq!(row.subject, "Launch review");
|
||||
assert_eq!(row.message_count, 4);
|
||||
assert_eq!(row.id, 22);
|
||||
|
||||
@@ -1785,6 +1785,7 @@ mod tests {
|
||||
MailMessage {
|
||||
id,
|
||||
thread_id: Some(i64::from(id)),
|
||||
message_id: None,
|
||||
account_id: 1,
|
||||
account: String::new(),
|
||||
folder: String::new(),
|
||||
|
||||
+37
-24
@@ -66,22 +66,20 @@ impl Operation {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
async fn perform(&self, core: &CoreMailSource, thread: i64) -> Result<(), String> {
|
||||
async fn perform(&self, core: &CoreMailSource, target: MailTarget) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Action(a) => core.perform_message_action(thread, a).await,
|
||||
Self::Action(a) => core.perform_message_action(target, a).await,
|
||||
Self::EmptyTrash(account_id) => core.empty_trash(*account_id).await,
|
||||
Self::Label(id, add) => core.perform_label_action(thread, *id, *add).await,
|
||||
Self::Label(id, add) => core.perform_label_action(target, *id, *add).await,
|
||||
Self::Drop(MailDropDestination::Action(a)) => {
|
||||
core.perform_message_action(thread, a).await
|
||||
}
|
||||
Self::Drop(MailDropDestination::Folder(id)) => {
|
||||
core.move_thread_to_folder(thread, *id).await
|
||||
core.perform_message_action(target, a).await
|
||||
}
|
||||
Self::Drop(MailDropDestination::Folder(id)) => core.move_to_folder(target, *id).await,
|
||||
Self::Drop(MailDropDestination::Label(id)) => {
|
||||
core.perform_label_action(thread, *id, true).await
|
||||
core.perform_label_action(target, *id, true).await
|
||||
}
|
||||
Self::Drop(MailDropDestination::Route(tab)) => {
|
||||
core.route_thread_to_tab(thread, tab.clone()).await
|
||||
core.route_to_tab(target, tab.clone()).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +87,7 @@ impl Operation {
|
||||
struct ActionRequest {
|
||||
core: CoreMailSource,
|
||||
epoch: u64,
|
||||
operations: Vec<(i32, i64, Operation)>,
|
||||
operations: Vec<(i32, MailTarget, Operation)>,
|
||||
}
|
||||
struct ActionResult {
|
||||
epoch: u64,
|
||||
@@ -138,9 +136,9 @@ pub(super) fn register(
|
||||
let result = collect_results(
|
||||
request.epoch,
|
||||
request.operations,
|
||||
move |thread, operation| {
|
||||
move |target, operation| {
|
||||
let core = core.clone();
|
||||
async move { operation.perform(&core, thread).await }
|
||||
async move { operation.perform(&core, target).await }
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -372,7 +370,7 @@ fn load(
|
||||
pub(super) fn enqueue(
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
core: CoreMailSource,
|
||||
operations: Vec<(i32, i64, Operation)>,
|
||||
operations: Vec<(i32, MailTarget, Operation)>,
|
||||
) -> Result<(), String> {
|
||||
if operations.is_empty() {
|
||||
return Err("no message is selected".into());
|
||||
@@ -435,11 +433,11 @@ pub(super) fn invalidate(state: &mut InboxState) {
|
||||
|
||||
async fn collect_results<F, Fut>(
|
||||
epoch: u64,
|
||||
operations: Vec<(i32, i64, Operation)>,
|
||||
operations: Vec<(i32, MailTarget, Operation)>,
|
||||
perform: F,
|
||||
) -> ActionResult
|
||||
where
|
||||
F: Fn(i64, Operation) -> Fut,
|
||||
F: Fn(MailTarget, Operation) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
let mut result = ActionResult {
|
||||
@@ -450,11 +448,11 @@ where
|
||||
error: None,
|
||||
deletion_queued: false,
|
||||
};
|
||||
for (id, thread, operation) in operations {
|
||||
for (id, target, operation) in operations {
|
||||
let deletion = operation.queues_permanent_deletion();
|
||||
result.ids.push(id);
|
||||
let moves = operation.moves();
|
||||
match perform(thread, operation).await {
|
||||
match perform(target, operation).await {
|
||||
Ok(()) => {
|
||||
result.deletion_queued |= deletion;
|
||||
result.completed.push(id);
|
||||
@@ -527,14 +525,25 @@ mod tests {
|
||||
let result = collect_results(
|
||||
7,
|
||||
vec![
|
||||
(1, 101, Operation::Action("archive".into())),
|
||||
(2, 102, Operation::Action("trash".into())),
|
||||
(3, 103, Operation::Action("star".into())),
|
||||
(
|
||||
1,
|
||||
MailTarget::Thread(101),
|
||||
Operation::Action("archive".into()),
|
||||
),
|
||||
(
|
||||
2,
|
||||
MailTarget::Message {
|
||||
id: 202,
|
||||
thread_id: 102,
|
||||
},
|
||||
Operation::Action("trash".into()),
|
||||
),
|
||||
(3, MailTarget::Thread(103), Operation::Action("star".into())),
|
||||
],
|
||||
|thread, _| {
|
||||
calls.borrow_mut().push(thread);
|
||||
|target, _| {
|
||||
calls.borrow_mut().push(target.thread_id());
|
||||
async move {
|
||||
if thread == 102 {
|
||||
if target.thread_id() == 102 {
|
||||
Err("database busy".into())
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -557,7 +566,11 @@ mod tests {
|
||||
let worker_gate = gate.clone();
|
||||
let worker = tokio::spawn(collect_results(
|
||||
0,
|
||||
vec![(1, 101, Operation::Action("archive".into()))],
|
||||
vec![(
|
||||
1,
|
||||
MailTarget::Thread(101),
|
||||
Operation::Action("archive".into()),
|
||||
)],
|
||||
move |_, _| {
|
||||
let gate = worker_gate.clone();
|
||||
async move {
|
||||
|
||||
+107
-50
@@ -98,14 +98,14 @@ use flectar_mail_core::models::ThreadCursor;
|
||||
use flectar_mail_core::models::{
|
||||
Account, AccountConfig, AddPasswordAccountArgs, CalendarConnection, CardDavConnection,
|
||||
ConnectionSecurity, ContactRecord, ContactRecordCursor, ContactRecordPage, CreateEventArgs,
|
||||
DraftAttachmentIn, Label, MailConnectionSettings, MailProfile, MailProtocol, Provider,
|
||||
Settings, Snippet, UpdateEventArgs,
|
||||
DraftAttachmentIn, Label, MailConnectionSettings, MailGrouping, MailProfile, MailProtocol,
|
||||
Provider, Settings, Snippet, UpdateEventArgs,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use mail::fixture_messages;
|
||||
use mail::{
|
||||
ComposeMessage, ComposeSource, CoreMailSource, MailCursor, MailMessage, MailboxEntry,
|
||||
display_preview,
|
||||
ComposeMessage, ComposeSource, CoreMailSource, MailCursor, MailMessage, MailTarget,
|
||||
MailboxEntry, display_preview,
|
||||
};
|
||||
use mail_groups::{
|
||||
MailGroupState, mail_group_key, mail_list_entry_key, project_mail_list, same_mail_list_entry,
|
||||
@@ -121,7 +121,7 @@ use sidebar_model::{SidebarAnimation, SidebarModel, refresh_sidebar, refresh_sid
|
||||
use slint::{DataTransfer, Image, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, Timer, VecModel};
|
||||
use startup::{
|
||||
PendingCoreUpdates, StartupCalendarSnapshot, StartupSnapshot, StartupUpdate,
|
||||
WarmStartCacheWriter, WarmStartProjection, WarmStartSnapshot, apply_settings,
|
||||
WarmStartCacheWriter, WarmStartCursor, WarmStartProjection, WarmStartSnapshot, apply_settings,
|
||||
apply_warm_start_snapshot, load_startup_calendar_snapshot, load_startup_mail_metadata,
|
||||
load_startup_snapshot, load_warm_start_snapshot, spawn_core_event_listener,
|
||||
};
|
||||
@@ -681,6 +681,8 @@ struct InboxState {
|
||||
remote_images_enabled: bool,
|
||||
remote_images_override_id: Option<i32>,
|
||||
mark_read_on_open: bool,
|
||||
/// Whether the mail list shows conversations or each message on its own.
|
||||
grouping: MailGrouping,
|
||||
warm_start_cache: WarmStartCacheWriter,
|
||||
}
|
||||
|
||||
@@ -746,7 +748,7 @@ fn resolve_single_mail_drop(
|
||||
target_scope: Scope,
|
||||
target_account_id: i32,
|
||||
target_folder_id: i32,
|
||||
) -> Result<(i64, MailDropDestination), String> {
|
||||
) -> Result<(MailTarget, MailDropDestination), String> {
|
||||
if !state.using_core || state.core.is_none() {
|
||||
return Err("mail account is not ready".to_owned());
|
||||
}
|
||||
@@ -755,9 +757,8 @@ fn resolve_single_mail_drop(
|
||||
.iter()
|
||||
.find(|message| message.id == item.message_id)
|
||||
.ok_or_else(|| "message is no longer available".to_owned())?;
|
||||
let thread_id = message
|
||||
.thread_id
|
||||
.ok_or_else(|| "message thread is unavailable".to_owned())?;
|
||||
let target =
|
||||
MailTarget::of(message).ok_or_else(|| "message thread is unavailable".to_owned())?;
|
||||
if message.account_id != i64::from(item.account_id) {
|
||||
return Err("dragged message account is stale".to_owned());
|
||||
}
|
||||
@@ -877,7 +878,7 @@ fn resolve_single_mail_drop(
|
||||
}
|
||||
};
|
||||
|
||||
Ok((thread_id, destination))
|
||||
Ok((target, destination))
|
||||
}
|
||||
|
||||
fn resolve_mail_drop(
|
||||
@@ -886,7 +887,7 @@ fn resolve_mail_drop(
|
||||
target_scope: Scope,
|
||||
target_account_id: i32,
|
||||
target_folder_id: i32,
|
||||
) -> Result<Vec<(i32, i64, MailDropDestination)>, String> {
|
||||
) -> Result<Vec<(i32, MailTarget, MailDropDestination)>, String> {
|
||||
if payload.items.is_empty() {
|
||||
return Err("no messages were dragged".to_owned());
|
||||
}
|
||||
@@ -901,7 +902,7 @@ fn resolve_mail_drop(
|
||||
target_account_id,
|
||||
target_folder_id,
|
||||
)
|
||||
.map(|(thread_id, destination)| (item.message_id, thread_id, destination))
|
||||
.map(|(target, destination)| (item.message_id, target, destination))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -938,11 +939,56 @@ fn perform_mail_drop(
|
||||
core,
|
||||
operations
|
||||
.into_iter()
|
||||
.map(|(id, thread, destination)| (id, thread, mail_work::Operation::Drop(destination)))
|
||||
.map(|(id, target, destination)| (id, target, mail_work::Operation::Drop(destination)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Show conversations, or each message on its own. The list starts over like
|
||||
/// a newly chosen mailbox, and tabs forget the conversations or messages they
|
||||
/// had open, whose ids belong to the other grouping. Returns whether the
|
||||
/// grouping changed.
|
||||
fn apply_mail_grouping(
|
||||
app: &AppWindow,
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
grouping: MailGrouping,
|
||||
) -> bool {
|
||||
app.set_conversation_view(grouping == MailGrouping::Conversations);
|
||||
let core = state.borrow().core.clone();
|
||||
let changed = core.is_some_and(|core| core.set_grouping(grouping));
|
||||
if state.borrow().grouping == grouping && !changed {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let mut state = state.borrow_mut();
|
||||
state.grouping = grouping;
|
||||
state.pending_selection = None;
|
||||
state.pending_selection_opens = false;
|
||||
state.restore_thread_message = None;
|
||||
state.mail_groups.clear();
|
||||
state.page = 1;
|
||||
state.next_cursor = None;
|
||||
state.selected_id = None;
|
||||
state.checked_ids.clear();
|
||||
state.preview_closed = true;
|
||||
state.conversation_owner_id = None;
|
||||
state.conversation_messages.clear();
|
||||
state.conversation_selected_index = 0;
|
||||
}
|
||||
let tabs = app.global::<TabsUi>();
|
||||
tabs.set_list_offset(0.0);
|
||||
tabs.set_list_restore(-1.0);
|
||||
tabs.invoke_regroup();
|
||||
// Single-pane layouts go back to the list, as for a tab with nothing open.
|
||||
tabs.set_shown_conversation(false);
|
||||
tabs.set_shown(tabs.get_shown().wrapping_add(1));
|
||||
if changed && let Err(error) = refresh_from_source(app, state, runtime, false, &[]) {
|
||||
app.set_render_status(UiMessage::detail("Mail refresh failed: {}", error));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn perform_mail_list_action(
|
||||
_app: &AppWindow,
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
@@ -975,9 +1021,8 @@ fn perform_mail_list_action(
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
message
|
||||
.thread_id
|
||||
.map(|thread_id| (id, thread_id))
|
||||
MailTarget::of(message)
|
||||
.map(|target| (id, target))
|
||||
.ok_or_else(|| "message thread is unavailable".to_owned()),
|
||||
)
|
||||
})
|
||||
@@ -996,7 +1041,7 @@ fn perform_mail_list_action(
|
||||
core,
|
||||
operations
|
||||
.into_iter()
|
||||
.map(|(id, thread)| (id, thread, mail_work::Operation::Action(action.to_owned())))
|
||||
.map(|(id, target)| (id, target, mail_work::Operation::Action(action.to_owned())))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -1154,6 +1199,7 @@ impl InboxState {
|
||||
remote_images_enabled,
|
||||
remote_images_override_id: None,
|
||||
mark_read_on_open: true,
|
||||
grouping: MailGrouping::Conversations,
|
||||
warm_start_cache,
|
||||
}
|
||||
}
|
||||
@@ -1260,10 +1306,8 @@ impl InboxState {
|
||||
.save(WarmStartSnapshot::capture(WarmStartProjection {
|
||||
scope: self.scope,
|
||||
inbox_count: self.inbox_count,
|
||||
next_cursor: self.next_cursor.and_then(|cursor| match cursor {
|
||||
MailCursor::Thread(cursor) => Some(cursor),
|
||||
MailCursor::Search(_) => None,
|
||||
}),
|
||||
grouping: self.grouping,
|
||||
next_cursor: self.next_cursor.and_then(WarmStartCursor::of),
|
||||
accounts: &self.connected_accounts,
|
||||
profile_accounts: self.profile_accounts.as_deref(),
|
||||
messages: &self.messages,
|
||||
@@ -1424,7 +1468,11 @@ fn spawn_startup_load(
|
||||
}
|
||||
}
|
||||
}
|
||||
let warm = load_warm_start_snapshot(&paths.warm_start_file()).await;
|
||||
// Rows saved in the other grouping would be replaced at once.
|
||||
let grouping = flectar_mail_core::startup_presentation(&paths).grouping;
|
||||
let warm = load_warm_start_snapshot(&paths.warm_start_file())
|
||||
.await
|
||||
.filter(|snapshot| snapshot.grouping == grouping);
|
||||
let preferred_scope = warm
|
||||
.as_ref()
|
||||
.map_or(Scope::UNIFIED_INBOX, |snapshot| snapshot.scope);
|
||||
@@ -4214,6 +4262,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
state.mark_read_on_open = settings
|
||||
.as_ref()
|
||||
.is_none_or(|settings| settings.mark_read_on_open);
|
||||
state.grouping = core.grouping();
|
||||
state.favicon_loader = if allow_remote_images {
|
||||
match FaviconLoader::new(&startup_paths_for_ui.cache_dir) {
|
||||
Ok(loader) => Some(loader),
|
||||
@@ -5559,9 +5608,19 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
let operations = match (account_id, &state.profile_accounts) {
|
||||
(None, Some(accounts)) => accounts
|
||||
.iter()
|
||||
.map(|account| (-1, 0, mail_work::Operation::EmptyTrash(Some(*account))))
|
||||
.map(|account| {
|
||||
(
|
||||
-1,
|
||||
MailTarget::Thread(0),
|
||||
mail_work::Operation::EmptyTrash(Some(*account)),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
(account_id, _) => vec![(-1, 0, mail_work::Operation::EmptyTrash(account_id))],
|
||||
(account_id, _) => vec![(
|
||||
-1,
|
||||
MailTarget::Thread(0),
|
||||
mail_work::Operation::EmptyTrash(account_id),
|
||||
)],
|
||||
};
|
||||
state.core.clone().map(|core| (core, operations))
|
||||
};
|
||||
@@ -5579,23 +5638,23 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
let Some(app) = app_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let (core, selected_id, thread_id) = {
|
||||
let (core, selected_id, target) = {
|
||||
let state = state_for_label.borrow();
|
||||
let thread_id = state
|
||||
let target = state
|
||||
.selected_id
|
||||
.and_then(|id| state.messages.iter().find(|message| message.id == id))
|
||||
.and_then(|message| message.thread_id);
|
||||
(state.core.clone(), state.selected_id, thread_id)
|
||||
.and_then(MailTarget::of);
|
||||
(state.core.clone(), state.selected_id, target)
|
||||
};
|
||||
let result = (|| {
|
||||
let core = core.ok_or_else(|| "mail core is unavailable".to_owned())?;
|
||||
let thread_id = thread_id.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
let target = target.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
mail_work::enqueue(
|
||||
&state_for_label,
|
||||
core,
|
||||
vec![(
|
||||
selected_id.unwrap(),
|
||||
thread_id,
|
||||
target,
|
||||
mail_work::Operation::Label(i64::from(label_id), applied),
|
||||
)],
|
||||
)
|
||||
@@ -5613,14 +5672,14 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
let Some(app) = app_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let (core, thread_id, selected_account_id, parent_path) = {
|
||||
let (core, target, selected_account_id, parent_path) = {
|
||||
let state = state_for_save_label.borrow();
|
||||
let selected = state
|
||||
.selected_id
|
||||
.and_then(|id| state.messages.iter().find(|message| message.id == id));
|
||||
(
|
||||
state.core.clone(),
|
||||
selected.and_then(|message| message.thread_id),
|
||||
selected.and_then(MailTarget::of),
|
||||
selected.map(|message| message.account_id),
|
||||
if parent_label_id >= 0 {
|
||||
state
|
||||
@@ -5680,9 +5739,9 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
},
|
||||
))?;
|
||||
if existing_id.is_none() && owner_account_id < 0 {
|
||||
let thread_id = thread_id.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
let target = target.ok_or_else(|| "no message is selected".to_owned())?;
|
||||
runtime_for_save_label
|
||||
.block_on(core.perform_label_action(thread_id, label.id, true))?;
|
||||
.block_on(core.perform_label_action(target, label.id, true))?;
|
||||
}
|
||||
refresh_from_source(
|
||||
&app,
|
||||
@@ -7875,16 +7934,19 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
|
||||
// Automatic read marking must never wait for SQLite or a page refresh
|
||||
// inside the pointer callback. A bounded, serial worker preserves requests.
|
||||
let (mark_read_tx, mut mark_read_rx) = tokio::sync::mpsc::channel::<(CoreMailSource, i64)>(64);
|
||||
let (read_result_tx, read_result_rx) = bounded_ui_channel::<(i64, Result<(), String>)>();
|
||||
// A conversation's row marks the conversation read; a single message's
|
||||
// row marks only that message.
|
||||
let (mark_read_tx, mut mark_read_rx) =
|
||||
tokio::sync::mpsc::channel::<(CoreMailSource, MailTarget)>(64);
|
||||
let (read_result_tx, read_result_rx) = bounded_ui_channel::<(MailTarget, Result<(), String>)>();
|
||||
let read_results = UiSender::new(
|
||||
read_result_tx,
|
||||
UiWake::new(app.as_weak(), |app| app.invoke_drain_read_updates()),
|
||||
);
|
||||
runtime.spawn(async move {
|
||||
while let Some((core, thread_id)) = mark_read_rx.recv().await {
|
||||
let result = core.perform_message_action(thread_id, "mark_read").await;
|
||||
let _ = read_results.send((thread_id, result)).await;
|
||||
while let Some((core, target)) = mark_read_rx.recv().await {
|
||||
let result = core.perform_message_action(target, "mark_read").await;
|
||||
let _ = read_results.send((target, result)).await;
|
||||
}
|
||||
});
|
||||
let pending_reads = Rc::new(RefCell::new(HashSet::new()));
|
||||
@@ -7892,8 +7954,8 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
let read_result_rx = Rc::new(RefCell::new(read_result_rx));
|
||||
let read_app = app.as_weak();
|
||||
app.on_drain_read_updates(move || {
|
||||
while let Ok((thread_id, result)) = read_result_rx.borrow_mut().try_recv() {
|
||||
pending_reads_for_results.borrow_mut().remove(&thread_id);
|
||||
while let Ok((target, result)) = read_result_rx.borrow_mut().try_recv() {
|
||||
pending_reads_for_results.borrow_mut().remove(&target);
|
||||
// Successful mutations publish CoreEvent updates, which use the
|
||||
// existing asynchronous list refresh and preserve the current selection.
|
||||
if let Err(error) = result
|
||||
@@ -7925,7 +7987,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
.messages
|
||||
.iter()
|
||||
.find(|row| row.id == id && row.unread)
|
||||
.and_then(|row| row.thread_id),
|
||||
.and_then(MailTarget::of),
|
||||
)
|
||||
};
|
||||
match select_message(&app, &state_for_selection, &runtime_for_selection, id) {
|
||||
@@ -7935,14 +7997,9 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some((core, thread_id)) = read_target
|
||||
&& ui_dispatch::enqueue_once(
|
||||
&mark_read_tx,
|
||||
&pending_reads,
|
||||
thread_id,
|
||||
(core, thread_id),
|
||||
)
|
||||
.is_err()
|
||||
if let Some((core, target)) = read_target
|
||||
&& ui_dispatch::enqueue_once(&mark_read_tx, &pending_reads, target, (core, target))
|
||||
.is_err()
|
||||
{
|
||||
app.set_render_status(UiMessage::detail(
|
||||
"Could not mark message as read: {}",
|
||||
|
||||
@@ -520,6 +520,42 @@ pub(super) fn register_settings_preference_callbacks(
|
||||
}
|
||||
});
|
||||
|
||||
let app_weak = app.as_weak();
|
||||
let state_for_grouping = Rc::clone(state);
|
||||
let runtime_for_grouping = Rc::clone(runtime);
|
||||
app.on_save_conversation_view(move |enabled| {
|
||||
let Some(app) = app_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let grouping = if enabled {
|
||||
MailGrouping::Conversations
|
||||
} else {
|
||||
MailGrouping::Messages
|
||||
};
|
||||
apply_mail_grouping(&app, &state_for_grouping, &runtime_for_grouping, grouping);
|
||||
let Some(core) = state_for_grouping.borrow().core.clone() else {
|
||||
app.set_sync_status(UiMessage::plain(
|
||||
"Conversation view updated for this session.",
|
||||
));
|
||||
return;
|
||||
};
|
||||
match runtime_for_grouping.block_on(core.set_conversation_view(enabled)) {
|
||||
Ok(()) => app.set_sync_status(UiMessage::plain("Conversation view preference saved.")),
|
||||
Err(error) => {
|
||||
let previous = if enabled {
|
||||
MailGrouping::Messages
|
||||
} else {
|
||||
MailGrouping::Conversations
|
||||
};
|
||||
apply_mail_grouping(&app, &state_for_grouping, &runtime_for_grouping, previous);
|
||||
app.set_sync_status(UiMessage::detail(
|
||||
"Could not save conversation view preference: {}",
|
||||
error,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app_weak = app.as_weak();
|
||||
let state_for_date_groups = Rc::clone(state);
|
||||
let runtime_for_date_groups = Rc::clone(runtime);
|
||||
|
||||
+68
-11
@@ -20,8 +20,8 @@ use flectar_mail_core::{
|
||||
config::Paths,
|
||||
events::CoreEvent,
|
||||
models::{
|
||||
Account, AccountConfig, CalendarConnection, CardDavConnection, Settings, ThreadCursor,
|
||||
normalized_workspace_list_pane_width,
|
||||
Account, AccountConfig, CalendarConnection, CardDavConnection, MailGrouping, MessageCursor,
|
||||
Settings, ThreadCursor, normalized_workspace_list_pane_width,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -38,7 +38,8 @@ use tokio::io::AsyncReadExt;
|
||||
// Version 4 keys account mailboxes by id instead of display name. Version 5
|
||||
// stores the provider and mail protocol names of the current account model.
|
||||
// Version 6 keeps message dates as timestamps instead of English text.
|
||||
const WARM_START_FORMAT_VERSION: u32 = 6;
|
||||
// Version 7 records whether the rows are conversations or single messages.
|
||||
const WARM_START_FORMAT_VERSION: u32 = 7;
|
||||
const MAX_WARM_START_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const MAX_WARM_START_ACCOUNTS: usize = 64;
|
||||
const MAX_WARM_START_MESSAGES: usize = 25;
|
||||
@@ -48,6 +49,8 @@ const MAX_WARM_START_MAILBOXES: usize = 512;
|
||||
pub(crate) struct WarmStartMessage {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) thread_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub(crate) message_id: Option<i64>,
|
||||
#[serde(default = "missing_folder_id")]
|
||||
pub(crate) account_id: i64,
|
||||
pub(crate) account: String,
|
||||
@@ -82,6 +85,7 @@ impl From<&mail::MailMessage> for WarmStartMessage {
|
||||
Self {
|
||||
id: message.id,
|
||||
thread_id: message.thread_id,
|
||||
message_id: message.message_id,
|
||||
account_id: message.account_id,
|
||||
account: message.account.clone(),
|
||||
folder: message.folder.clone(),
|
||||
@@ -110,6 +114,7 @@ impl From<WarmStartMessage> for mail::MailMessage {
|
||||
Self {
|
||||
id: message.id,
|
||||
thread_id: message.thread_id,
|
||||
message_id: message.message_id,
|
||||
account_id: message.account_id,
|
||||
account: message.account,
|
||||
folder: message.folder,
|
||||
@@ -234,13 +239,42 @@ impl From<WarmStartMailbox> for mail::MailboxEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the saved rows' mailbox continues: after a conversation, or after a
|
||||
/// message in lists without conversations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum WarmStartCursor {
|
||||
Thread(ThreadCursor),
|
||||
Message(MessageCursor),
|
||||
}
|
||||
|
||||
impl WarmStartCursor {
|
||||
/// Search results are not saved, so neither is a search cursor.
|
||||
pub(crate) fn of(cursor: MailCursor) -> Option<Self> {
|
||||
match cursor {
|
||||
MailCursor::Thread(cursor) => Some(Self::Thread(cursor)),
|
||||
MailCursor::Message(cursor) => Some(Self::Message(cursor)),
|
||||
MailCursor::Search(_) | MailCursor::MessageSearch(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mail_cursor(self) -> MailCursor {
|
||||
match self {
|
||||
Self::Thread(cursor) => MailCursor::Thread(cursor),
|
||||
Self::Message(cursor) => MailCursor::Message(cursor),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct WarmStartSnapshot {
|
||||
format_version: u32,
|
||||
pub(crate) saved_at_ms: i64,
|
||||
pub(crate) scope: Scope,
|
||||
pub(crate) inbox_count: usize,
|
||||
pub(crate) next_cursor: Option<ThreadCursor>,
|
||||
/// Whether the saved rows are conversations or single messages.
|
||||
pub(crate) grouping: MailGrouping,
|
||||
pub(crate) next_cursor: Option<WarmStartCursor>,
|
||||
pub(crate) accounts: Vec<Account>,
|
||||
/// The active profile's accounts, which the saved rows show.
|
||||
#[serde(default)]
|
||||
@@ -253,7 +287,8 @@ pub(crate) struct WarmStartSnapshot {
|
||||
pub(crate) struct WarmStartProjection<'a> {
|
||||
pub(crate) scope: Scope,
|
||||
pub(crate) inbox_count: usize,
|
||||
pub(crate) next_cursor: Option<ThreadCursor>,
|
||||
pub(crate) grouping: MailGrouping,
|
||||
pub(crate) next_cursor: Option<WarmStartCursor>,
|
||||
pub(crate) accounts: &'a [Account],
|
||||
pub(crate) profile_accounts: Option<&'a [i64]>,
|
||||
pub(crate) messages: &'a [mail::MailMessage],
|
||||
@@ -275,6 +310,7 @@ impl WarmStartSnapshot {
|
||||
saved_at_ms: chrono::Utc::now().timestamp_millis(),
|
||||
scope: projection.scope,
|
||||
inbox_count: projection.inbox_count,
|
||||
grouping: projection.grouping,
|
||||
next_cursor: projection.next_cursor,
|
||||
accounts: projection
|
||||
.accounts
|
||||
@@ -359,7 +395,8 @@ pub(crate) fn apply_warm_start_snapshot(
|
||||
let _ = state.set_connected_accounts(snapshot.accounts);
|
||||
state.set_scope(snapshot.scope);
|
||||
state.inbox_count = snapshot.inbox_count;
|
||||
state.next_cursor = snapshot.next_cursor.map(MailCursor::Thread);
|
||||
state.grouping = snapshot.grouping;
|
||||
state.next_cursor = snapshot.next_cursor.map(WarmStartCursor::mail_cursor);
|
||||
state.messages = snapshot
|
||||
.messages
|
||||
.into_iter()
|
||||
@@ -578,6 +615,12 @@ pub(crate) async fn load_startup_snapshot(
|
||||
metrics.emit("core_opened", serde_json::Value::Null);
|
||||
// The first page shows only the active profile's accounts.
|
||||
let settings = core.load_settings().await.ok();
|
||||
// The first page is listed the way the saved preference groups mail.
|
||||
let _ = core.set_grouping(
|
||||
settings
|
||||
.as_ref()
|
||||
.map_or(MailGrouping::Conversations, Settings::mail_grouping),
|
||||
);
|
||||
let _ = core.set_profile_accounts(settings.as_ref().and_then(|settings| {
|
||||
AccountPresentationSettings::from_settings(settings).active_profile_accounts()
|
||||
}));
|
||||
@@ -773,6 +816,7 @@ pub(crate) fn apply_settings(app: &AppWindow, settings: &Settings) {
|
||||
app.set_show_avatars(settings.show_avatars);
|
||||
app.set_group_mail_by_date(settings.group_mail_by_date);
|
||||
app.set_show_tabs(settings.show_tabs);
|
||||
app.set_conversation_view(settings.conversation_view);
|
||||
app.set_show_account_markers(settings.show_account_badges);
|
||||
app.set_workspace_layout(
|
||||
match settings.workspace_layout.as_str() {
|
||||
@@ -981,6 +1025,7 @@ mod warm_start_tests {
|
||||
WarmStartSnapshot::capture(WarmStartProjection {
|
||||
scope: Scope::UNIFIED_INBOX,
|
||||
inbox_count: 3,
|
||||
grouping: MailGrouping::Conversations,
|
||||
next_cursor: None,
|
||||
accounts: &[account()],
|
||||
profile_accounts: None,
|
||||
@@ -1136,6 +1181,7 @@ mod warm_start_tests {
|
||||
mail::MailMessage {
|
||||
id: 9,
|
||||
thread_id: Some(19),
|
||||
message_id: None,
|
||||
account_id: 7,
|
||||
account: "Person".into(),
|
||||
folder: "Inbox".into(),
|
||||
@@ -1205,10 +1251,11 @@ mod warm_start_tests {
|
||||
let snapshot = WarmStartSnapshot::capture(WarmStartProjection {
|
||||
scope: PERSON_INBOX,
|
||||
inbox_count: 3,
|
||||
next_cursor: Some(ThreadCursor {
|
||||
last_message_at: 123,
|
||||
thread_id: 7,
|
||||
}),
|
||||
grouping: MailGrouping::Messages,
|
||||
next_cursor: Some(WarmStartCursor::Message(MessageCursor {
|
||||
date: 123,
|
||||
message_id: 9,
|
||||
})),
|
||||
accounts: &accounts,
|
||||
profile_accounts: Some(&[7]),
|
||||
messages: &messages,
|
||||
@@ -1226,6 +1273,14 @@ mod warm_start_tests {
|
||||
|
||||
let restored = load_warm_start_snapshot(&path).await.unwrap();
|
||||
assert_eq!(restored.scope, PERSON_INBOX);
|
||||
assert_eq!(restored.grouping, MailGrouping::Messages);
|
||||
assert_eq!(
|
||||
restored.next_cursor,
|
||||
Some(WarmStartCursor::Message(MessageCursor {
|
||||
date: 123,
|
||||
message_id: 9,
|
||||
}))
|
||||
);
|
||||
assert_eq!(restored.profile_accounts, Some(vec![7]));
|
||||
assert_eq!(restored.messages.len(), 1);
|
||||
let restored_message = mail::MailMessage::from(restored.messages[0].clone());
|
||||
@@ -1278,6 +1333,7 @@ mod warm_start_tests {
|
||||
let snapshot = WarmStartSnapshot::capture(WarmStartProjection {
|
||||
scope: Scope::UNIFIED_INBOX,
|
||||
inbox_count: messages.len(),
|
||||
grouping: MailGrouping::Conversations,
|
||||
next_cursor: None,
|
||||
accounts: &accounts,
|
||||
profile_accounts: None,
|
||||
@@ -1292,7 +1348,7 @@ mod warm_start_tests {
|
||||
#[test]
|
||||
fn benchmark_warm_cache_fixture_matches_the_current_format() {
|
||||
let snapshot: WarmStartSnapshot = serde_json::from_str(include_str!(
|
||||
"../resources/benchmarks/warm-cache/warm-start-mailbox-v6.json"
|
||||
"../resources/benchmarks/warm-cache/warm-start-mailbox-v7.json"
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
@@ -1300,5 +1356,6 @@ mod warm_start_tests {
|
||||
assert_eq!(snapshot.messages.len(), 3);
|
||||
// Caches written before profiles filtered mail show every account.
|
||||
assert_eq!(snapshot.profile_accounts, None);
|
||||
assert_eq!(snapshot.grouping, MailGrouping::Conversations);
|
||||
}
|
||||
}
|
||||
+87
@@ -680,6 +680,8 @@ pub(crate) fn register(app: &AppWindow, context: TabContext) {
|
||||
}
|
||||
});
|
||||
let owner = Rc::clone(&tabs);
|
||||
global.on_regroup(move || owner.regroup());
|
||||
let owner = Rc::clone(&tabs);
|
||||
global.on_navigated(move || {
|
||||
if owner.restored.get() {
|
||||
owner.schedule_save();
|
||||
@@ -1443,6 +1445,39 @@ impl Tabs {
|
||||
self.changed();
|
||||
}
|
||||
|
||||
/// Mail is grouped differently: an open conversation's id, a message's
|
||||
/// in lists without conversations, and the rows and positions tabs kept
|
||||
/// all belong to the old grouping. Tabs keep their mailboxes and searches,
|
||||
/// and are written at once, so no old id is ever read in the new grouping.
|
||||
fn regroup(&self) {
|
||||
fn forget_open(place: &mut Place, title: &mut String) {
|
||||
if let Location::Mail { conversation, .. } = &mut place.location
|
||||
&& conversation.take().is_some()
|
||||
{
|
||||
title.clear();
|
||||
}
|
||||
place.thread_message = None;
|
||||
place.account = None;
|
||||
place.scroll = Scroll::default();
|
||||
place.unseen = false;
|
||||
}
|
||||
self.reader_timer.stop();
|
||||
self.reader_restore.set(None);
|
||||
{
|
||||
let mut strip = self.strip.borrow_mut();
|
||||
for tab in &mut strip.tabs {
|
||||
forget_open(&mut tab.place, &mut tab.title);
|
||||
tab.rows = None;
|
||||
}
|
||||
for closed in &mut strip.closed {
|
||||
forget_open(&mut closed.place, &mut closed.title);
|
||||
}
|
||||
}
|
||||
self.publish();
|
||||
self.save_timer.stop();
|
||||
self.save(true);
|
||||
}
|
||||
|
||||
/// Publish rows, then save once the tab strip has been quiet briefly.
|
||||
fn changed(&self) {
|
||||
self.publish();
|
||||
@@ -2443,6 +2478,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Ids of open conversations and of single messages are different ids:
|
||||
/// after mail is grouped differently, no tab may reopen one by the other.
|
||||
#[test]
|
||||
fn regrouping_mail_forgets_what_tabs_had_open_and_saves_at_once() {
|
||||
let (app, context, _dir, _) = headless();
|
||||
let path = context.path.clone();
|
||||
let tabs = Tabs::new(&app, context);
|
||||
tabs.strip.borrow_mut().open(
|
||||
Place {
|
||||
location: Location::Mail {
|
||||
scope: Scope::UNIFIED_INBOX,
|
||||
query: "plans".into(),
|
||||
filter: default_filter(),
|
||||
conversation: Some(42),
|
||||
},
|
||||
thread_message: Some(420),
|
||||
account: Some(1),
|
||||
scroll: Scroll {
|
||||
list: 300.0,
|
||||
reader: 80.0,
|
||||
},
|
||||
unseen: true,
|
||||
..Place::default()
|
||||
},
|
||||
"Plans".into(),
|
||||
None,
|
||||
);
|
||||
|
||||
tabs.regroup();
|
||||
|
||||
let strip = tabs.strip.borrow();
|
||||
let place = &strip.tabs[1].place;
|
||||
assert_eq!(place.conversation(), None);
|
||||
assert_eq!(place.thread_message, None);
|
||||
assert_eq!(place.account, None);
|
||||
assert_eq!(place.scroll, Scroll::default());
|
||||
assert!(!place.unseen);
|
||||
assert!(strip.tabs[1].title.is_empty());
|
||||
assert!(
|
||||
matches!(&place.location, Location::Mail { query, .. } if query == "plans"),
|
||||
"the tab keeps its search"
|
||||
);
|
||||
let saved = read_file(&path).expect("written at once");
|
||||
assert!(saved.tabs.iter().all(|tab| !matches!(
|
||||
tab.location,
|
||||
Location::Mail {
|
||||
conversation: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_account_leaves_no_subject_in_the_tabs() {
|
||||
let (app, context, _dir, _) = headless();
|
||||
|
||||
+3
-3
@@ -83,10 +83,10 @@ impl<T> UiSender<T> {
|
||||
|
||||
/// Enqueue UI-triggered work without blocking, deduplicating outstanding IDs.
|
||||
/// The caller removes IDs when completions arrive; rejection releases them here.
|
||||
pub(crate) fn enqueue_once<T>(
|
||||
pub(crate) fn enqueue_once<T, K: std::hash::Hash + Eq + Copy>(
|
||||
sender: &mpsc::Sender<T>,
|
||||
pending: &std::cell::RefCell<std::collections::HashSet<i64>>,
|
||||
id: i64,
|
||||
pending: &std::cell::RefCell<std::collections::HashSet<K>>,
|
||||
id: K,
|
||||
request: T,
|
||||
) -> Result<(), ()> {
|
||||
if !pending.borrow_mut().insert(id) {
|
||||
|
||||
@@ -275,6 +275,9 @@ export global I18n {
|
||||
if (key == "Mail grouping is active for this session.") { return @tr("Mail grouping is active for this session."); }
|
||||
if (key == "Mail grouping preference saved.") { return @tr("Mail grouping preference saved."); }
|
||||
if (key == "Could not save mail grouping preference: {}") { return @tr("Could not save mail grouping preference: {}", argument); }
|
||||
if (key == "Conversation view updated for this session.") { return @tr("Conversation view updated for this session."); }
|
||||
if (key == "Conversation view preference saved.") { return @tr("Conversation view preference saved."); }
|
||||
if (key == "Could not save conversation view preference: {}") { return @tr("Could not save conversation view preference: {}", argument); }
|
||||
if (key == "Profile changes require local mail data.") { return @tr("Profile changes require local mail data."); }
|
||||
if (key == "Profile saved.") { return @tr("Profile saved."); }
|
||||
if (key == "Could not save profile: {}") { return @tr("Could not save profile: {}", argument); }
|
||||
@@ -638,6 +641,7 @@ export component AppWindow inherits Window {
|
||||
callback save_custom_theme(color, color, color, color, color, color, color, color, color, color) -> bool;
|
||||
callback save_avatar_visibility(bool);
|
||||
callback save_show_tabs(bool);
|
||||
callback save_conversation_view(bool);
|
||||
callback save_group_mail_by_date(bool);
|
||||
callback choose_download_directory();
|
||||
callback clear_download_directory();
|
||||
@@ -989,6 +993,8 @@ export component AppWindow inherits Window {
|
||||
// Show tabs: the desktop tab strip, or the compact tab switcher.
|
||||
in-out property <bool> show_tabs: true;
|
||||
in-out property <bool> group_mail_by_date: true;
|
||||
// Group replies into conversations; off lists each message on its own.
|
||||
in-out property <bool> conversation_view: true;
|
||||
in-out property <bool> show_account_markers: false;
|
||||
in-out property <string> workspace_layout: "default";
|
||||
in-out property <length> workspace_list_pane_width: LayoutMetrics.list-pane-width;
|
||||
@@ -1931,6 +1937,7 @@ export component AppWindow inherits Window {
|
||||
show_avatars <=> root.show_avatars;
|
||||
show_tabs <=> root.show_tabs;
|
||||
group_mail_by_date <=> root.group_mail_by_date;
|
||||
conversation_view <=> root.conversation_view;
|
||||
show_account_markers <=> root.show_account_markers;
|
||||
workspace_layout <=> root.workspace_layout;
|
||||
language_mode <=> root.language_mode;
|
||||
@@ -2043,6 +2050,7 @@ export component AppWindow inherits Window {
|
||||
save_sync_interval(minutes) => { root.save_sync_interval(minutes); }
|
||||
save_mark_read_on_open(enabled) => { root.save_mark_read_on_open(enabled); }
|
||||
save_group_mail_by_date(enabled) => { root.save_group_mail_by_date(enabled); }
|
||||
save_conversation_view(enabled) => { root.save_conversation_view(enabled); }
|
||||
save_contact_discovery_settings(outgoing, incoming, all-accounts, suggest) => {
|
||||
root.save_contact_discovery_settings(outgoing, incoming, all-accounts, suggest);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ export global TabsUi {
|
||||
callback reset();
|
||||
// An account was removed: tabs forget its mailboxes and conversations.
|
||||
callback account-removed(string);
|
||||
// Mail is grouped differently now: tabs forget the conversations or
|
||||
// messages they had open, whose ids belong to the old grouping.
|
||||
callback regroup();
|
||||
// Save every tab now, before the application exits.
|
||||
callback flush();
|
||||
}
|
||||
|
||||
@@ -481,6 +481,7 @@ export component SettingsDialog inherits Rectangle {
|
||||
in-out property <bool> show_avatars: true;
|
||||
in-out property <bool> show_tabs: true;
|
||||
in-out property <bool> group_mail_by_date: true;
|
||||
in-out property <bool> conversation_view: true;
|
||||
in-out property <bool> show_account_markers: false;
|
||||
in-out property <string> workspace_layout: "default";
|
||||
in-out property <string> language_mode: "system";
|
||||
@@ -582,6 +583,7 @@ export component SettingsDialog inherits Rectangle {
|
||||
callback save_avatar_visibility(bool);
|
||||
callback save_show_tabs(bool);
|
||||
callback save_group_mail_by_date(bool);
|
||||
callback save_conversation_view(bool);
|
||||
callback choose_download_directory();
|
||||
callback clear_download_directory();
|
||||
// Save a profile's name and color, and make the checked accounts of
|
||||
@@ -710,7 +712,7 @@ export component SettingsDialog inherits Rectangle {
|
||||
pure function settings-section-visible(section: string) -> bool {
|
||||
if root.settings-search-query == "" { return true; }
|
||||
let terms = section == "General"
|
||||
? @tr("general theme appearance palette app icon dock taskbar launcher language time clock 12-hour 24-hour workspace layout window frameless title bar renderer privacy remote images sync group messages date inbox tray background startup sign in login download location folder attachments save tabs")
|
||||
? @tr("general theme appearance palette app icon dock taskbar launcher language time clock 12-hour 24-hour workspace layout window frameless title bar renderer privacy remote images sync group messages date inbox tray background startup sign in login download location folder attachments save tabs conversations threads")
|
||||
: section == "Accounts"
|
||||
? @tr("accounts profiles personal work colors providers gmail outlook imap pop3 jmap caldav carddav mail history server oauth security signatures")
|
||||
: section == "Contacts"
|
||||
@@ -1231,7 +1233,7 @@ export component SettingsDialog inherits Rectangle {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: max(root.compact ? 324px : 262px, self.preferred-height);
|
||||
height: max(root.compact ? 413px : 321px, self.preferred-height);
|
||||
background: root.surface;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
@@ -1263,6 +1265,19 @@ export component SettingsDialog inherits Rectangle {
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: root.border-subtle; }
|
||||
SettingsSwitchRow {
|
||||
compact: root.compact;
|
||||
title: @tr("Conversation view");
|
||||
description: @tr("Group replies with the messages they answer. Turn this off to list, open and act on each message on its own.");
|
||||
checked: root.conversation_view;
|
||||
title-color: root.body-text;
|
||||
description-color: root.faint-text;
|
||||
toggled(checked) => {
|
||||
root.conversation_view = checked;
|
||||
root.save_conversation_view(checked);
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: root.border-subtle; }
|
||||
HorizontalBox {
|
||||
height: (root.compact ? 16px : 13px) + mark-read-title.height + mark-read-description.height;
|
||||
spacing: 16px;
|
||||
|
||||
Reference in new issue
Block a user