some fixes

This commit is contained in:
pj committed 2026-10-03 21:49:47 +05:30
1 parent 660aef323b
commit 7274a54a6e
29 files changed
+860 -315

No files matched your search

+14
View File
@@ -125,3 +125,17 @@ hr {
border: 0; border: 0;
background: var(--m-line); background: var(--m-line);
} }
body[data-paper] {
max-width: none;
font-family: Arial, sans-serif;
font-size: 13px;
}
body[data-paper] p {
margin: 1em 0;
}
body[data-paper] table {
border-collapse: separate;
}
+33 -13
View File
@@ -392,28 +392,22 @@ fn free_path(dir: &Path, filename: &str) -> PathBuf {
// Commands // Commands
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
/// The reader has asked to see the pictures.
///
/// The answer is a view and not a stored row on purpose: the `bodies` table has no column saying
/// whether images were loaded, so this is per view rather than remembered, and closing the thread
/// puts the block back. Inventing a column for it would also be inventing a policy, and the policy
/// that roams with a person is the per sender allowance on the contact card, not a flag on a body.
#[tauri::command] #[tauri::command]
pub async fn message_show_images( pub async fn message_show_images(
app: tauri::AppHandle, app: tauri::AppHandle,
account_id: String,
message_id: String, message_id: String,
) -> Result<MessageView, String> { ) -> Result<MessageView, String> {
let db = db_of(&app)?; let db = db_of(&app)?;
let account_id = account_holding(
db.inner(),
"SELECT COUNT(*) FROM messages WHERE id = ?1",
&message_id,
)?;
let (raw, options) = db.with(&account_id, |conn| { let (raw, options) = db.with(&account_id, |conn| {
let options = sync::hydrate::render_options(conn)?;
let view = read::message_view(conn, &account_id, &message_id, &options.own_addresses)?;
if !view.images_allowed {
return Err("images are hidden for this email".into());
}
let raw = read::raw_body(conn, &message_id)? let raw = read::raw_body(conn, &message_id)?
.ok_or("that message has not been fetched yet")?; .ok_or("that message has not been fetched yet")?;
Ok((raw, sync::hydrate::render_options(conn)?)) Ok((raw, options))
})?; })?;
let urls = wanted(&raw, &options)?; let urls = wanted(&raw, &options)?;
@@ -422,6 +416,9 @@ pub async fn message_show_images(
let mut view = db.with(&account_id, |conn| { let mut view = db.with(&account_id, |conn| {
read::message_view(conn, &account_id, &message_id, &options.own_addresses) read::message_view(conn, &account_id, &message_id, &options.own_addresses)
})?; })?;
if !view.images_allowed {
return Ok(view);
}
view.html = rendered.html; view.html = rendered.html;
view.quoted_html = rendered.quoted_html; view.quoted_html = rendered.quoted_html;
view.trackers = rendered.trackers; view.trackers = rendered.trackers;
@@ -430,6 +427,29 @@ pub async fn message_show_images(
Ok(view) Ok(view)
} }
#[tauri::command]
pub fn message_images_set(
app: tauri::AppHandle,
account_id: String,
message_id: String,
allowed: bool,
) -> Result<MessageView, String> {
let db = db_of(&app)?;
db.with(&account_id, |conn| {
let options = sync::hydrate::render_options(conn)?;
let mut view = read::message_view(conn, &account_id, &message_id, &options.own_addresses)?;
if view.images_allowed != allowed {
crate::state::write::set_pref(
conn,
&read::message_images_key(&view.message_id, &view.id),
if allowed { "true" } else { "false" },
)?;
}
view.images_allowed = allowed;
Ok(view)
})
}
/// The inline preview. Refuses anything too big to be a data URI before it fetches a byte, so the /// The inline preview. Refuses anything too big to be a data URI before it fetches a byte, so the
/// answer to a hundred megabyte video is a sentence rather than a spinner. /// answer to a hundred megabyte video is a sentence rather than a spinner.
#[tauri::command] #[tauri::command]
+192 -29
View File
@@ -20,10 +20,11 @@
// the queue is the `drafts` table, which already has the two columns it needs. // the queue is the `drafts` table, which already has the two columns it needs.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::{Mutex, OnceLock}; use std::sync::OnceLock;
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::decisions::db_of; use crate::decisions::db_of;
use crate::dto::{Draft, DraftAttachment, DraftSaved, Person}; use crate::dto::{Draft, DraftAttachment, DraftSaved, Person};
@@ -341,7 +342,10 @@ pub async fn upload<S: Store, P: sync::Remote + ?Sized>(
for (id, provider_draft_id, held) in store.with(|conn| due(conn, now))? { for (id, provider_draft_id, held) in store.with(|conn| due(conn, now))? {
let subject = held.draft.subject.clone(); let subject = held.draft.subject.clone();
let from = crate::send::sender(&held.draft, from); let from = crate::send::sender(&held.draft, from);
let raw = store.with(|conn| built(conn, &held.draft, &from, None, None, &[], &subject))?; let raw = store.with(|conn| {
let threading = crate::send::threading(conn, &held.draft)?;
built(conn, &held.draft, &from, None, threading.in_reply_to.as_deref(), &threading.references, &subject)
})?;
let thread_hint = store.with(|conn| thread_hint(conn, held.draft.thread_key.as_deref()))?; let thread_hint = store.with(|conn| thread_hint(conn, held.draft.thread_key.as_deref()))?;
let put = provider let put = provider
@@ -370,9 +374,9 @@ pub fn thread_hint(conn: &Connection, thread_key: Option<&str>) -> Result<Option
} }
/// One account at a time, so two saves in the same second cannot both upload the same draft. /// One account at a time, so two saves in the same second cannot both upload the same draft.
fn uploading() -> &'static Mutex<HashSet<String>> { fn uploading() -> &'static tokio::sync::Mutex<()> {
static UPLOADING: OnceLock<Mutex<HashSet<String>>> = OnceLock::new(); static UPLOADING: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
UPLOADING.get_or_init(|| Mutex::new(HashSet::new())) UPLOADING.get_or_init(|| tokio::sync::Mutex::new(()))
} }
/// Puts every draft of one account that is due in front of the provider. /// Puts every draft of one account that is due in front of the provider.
@@ -381,18 +385,8 @@ fn uploading() -> &'static Mutex<HashSet<String>> {
/// before the app was quit is uploaded when the app comes back, rather than waiting for somebody to /// before the app was quit is uploaded when the app comes back, rather than waiting for somebody to
/// open the composer again. /// open the composer again.
pub async fn upload_pending(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> { pub async fn upload_pending(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
if !uploading() let _guard = uploading().lock().await;
.lock() upload_now(app, account_id).await
.map(|mut held| held.insert(account_id.to_string()))
.unwrap_or(false)
{
return Ok(0);
}
let done = upload_now(app, account_id).await;
if let Ok(mut held) = uploading().lock() {
held.remove(account_id);
}
done
} }
async fn upload_now(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> { async fn upload_now(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
@@ -427,9 +421,145 @@ pub fn draft_save(app: tauri::AppHandle, draft: Draft) -> Result<DraftSaved, Str
tokio::time::sleep(std::time::Duration::from_millis(UPLOAD_EVERY_MS as u64)).await; tokio::time::sleep(std::time::Duration::from_millis(UPLOAD_EVERY_MS as u64)).await;
let _ = upload_pending(&handle, &account_id).await; let _ = upload_pending(&handle, &account_id).await;
}); });
crate::emit_store_changed(&app, "drafts");
Ok(saved) Ok(saved)
} }
#[tauri::command(async)]
pub fn draft_list(app: tauri::AppHandle, account_id: Option<String>) -> Result<Vec<Draft>, String> {
let db = db_of(&app)?;
let mut drafts = Vec::new();
for (account_id, _) in sync::accounts(db.inner(), account_id.as_deref()) {
drafts.extend(db.with(&account_id, |conn| {
let mut stmt = conn
.prepare("SELECT payload FROM drafts ORDER BY updated_at DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
rows.map(|row| {
let payload = row.map_err(|e| e.to_string())?;
serde_json::from_str::<Stored>(&payload)
.map(|held| held.draft)
.map_err(|e| e.to_string())
})
.collect::<Result<Vec<_>, String>>()
})?);
}
Ok(drafts)
}
#[tauri::command]
pub async fn draft_import(
app: tauri::AppHandle,
account_id: String,
message_id: String,
) -> Result<Draft, String> {
let db = db_of(&app)?;
let auth = app
.try_state::<crate::google::AuthState>()
.ok_or("no Google session store")?;
let token = crate::google::auth::valid_access_token(&app, auth.inner(), &account_id).await?;
let mut page = None;
let provider_id = loop {
let found = crate::google::api::with_retry(|| {
crate::google::api::drafts_list(&token, page.as_deref())
})
.await
.map_err(|e| e.to_string())?;
if let Some(draft) = found
.drafts
.into_iter()
.find(|draft| draft.message.as_ref().is_some_and(|m| m.id == message_id))
{
break draft.id;
}
page = found.next_page_token;
if page.is_none() {
return Err(
"that Gmail draft is no longer available; sync the mailbox again".to_string(),
);
}
};
db.with(&account_id, |conn| {
let existing: Option<String> = conn
.query_row(
"SELECT id FROM drafts WHERE provider_draft_id = ?1",
[&provider_id],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
let existing = existing.map(|id| stored(conn, &id)).transpose()?.flatten();
if let Some((held, _, _)) = &existing {
if held.version > held.uploaded_version {
return Ok(held.draft.clone());
}
}
let raw = read::raw_body(conn, &message_id)?.ok_or("the draft body is still loading")?;
let rendered = crate::mime::render(&raw, &crate::sync::hydrate::render_options(conn)?)?;
let key: String = conn
.query_row(
"SELECT thread_key FROM messages WHERE id = ?1 AND draft = 1",
[&message_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
let id = existing
.as_ref()
.and_then(|(held, _, _)| held.draft.id.clone())
.unwrap_or_else(|| write::fresh_id("draft"));
let files = db.account_dir(&account_id).join("draft-files").join(&id);
let mut attachments = Vec::new();
for (index, file) in read::attachments(conn, &message_id)?
.into_iter()
.enumerate()
{
let mut attachment = DraftAttachment {
path: None,
attachment_id: Some(file.id),
filename: file.filename,
mime_type: file.mime_type,
size: file.size,
};
let bytes = attachment_bytes(conn, &attachment)?;
std::fs::create_dir_all(&files).map_err(|e| e.to_string())?;
let path = files.join(index.to_string());
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
attachment.path = Some(path.to_string_lossy().into_owned());
attachment.attachment_id = None;
attachments.push(attachment);
}
let draft = Draft {
id: Some(id),
account_id: account_id.clone(),
thread_key: Some(key),
in_reply_to: rendered.in_reply_to,
from_alias: Some(rendered.from.address),
to: rendered.to,
cc: rendered.cc,
bcc: rendered.bcc,
subject: rendered.subject,
body_html: format!(
"{}{}",
rendered.html,
rendered.quoted_html.unwrap_or_default()
),
attachments,
remind_at_ms: None,
};
let saved = save(conn, &draft)?;
uploaded(
conn,
&saved.id,
&provider_id,
existing.map(|(held, _, _)| held.version).unwrap_or(0) + 1,
write::now_ms(),
)?;
get(conn, &saved.id)
})
}
#[tauri::command(async)] #[tauri::command(async)]
pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> { pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
let db = db_of(&app)?; let db = db_of(&app)?;
@@ -443,23 +573,56 @@ pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
#[tauri::command] #[tauri::command]
pub async fn draft_delete(app: tauri::AppHandle, id: String) -> Result<(), String> { pub async fn draft_delete(app: tauri::AppHandle, id: String) -> Result<(), String> {
let _guard = uploading().lock().await;
let db = db_of(&app)?; let db = db_of(&app)?;
for (account_id, _) in sync::accounts(db.inner(), None) { for (account_id, _) in sync::accounts(db.inner(), None) {
let held = db.with(&account_id, |conn| stored(conn, &id))?; let Some((_, provider_id, _)) = db.with(&account_id, |conn| stored(conn, &id))? else {
if held.is_none() {
continue; continue;
} };
let provider_draft_id = db.with(&account_id, |conn| delete(conn, &id))?; let mut message_id = None;
if let Some(provider_draft_id) = provider_draft_id { if let Some(provider_id) = provider_id {
// The local row has gone either way. A provider that will not take the delete leaves a if crate::accounts::list(&app)?
// draft in the mailbox's own Drafts, which is visible and fixable, and refusing the .iter()
// command over it would leave the one on this machine that the person asked to be rid .any(|a| a.id == account_id && a.kind == crate::dto::AccountKind::Google)
// of. {
if let Some(provider) = sync::remote_for(&account_id) { let auth = app
let _ = provider.draft_delete(&provider_draft_id).await; .try_state::<crate::google::AuthState>()
.ok_or("no Google session store")?;
let token =
crate::google::auth::valid_access_token(&app, auth.inner(), &account_id)
.await?;
let mut page = None;
loop {
let found = crate::google::api::with_retry(|| {
crate::google::api::drafts_list(&token, page.as_deref())
})
.await
.map_err(|e| e.to_string())?;
if let Some(draft) = found.drafts.into_iter().find(|d| d.id == provider_id) {
message_id = draft.message.map(|m| m.id);
break;
}
page = found.next_page_token;
if page.is_none() {
break;
}
}
} }
let provider = sync::remote_for(&account_id)
.ok_or("connect this account before discarding its remote draft")?;
provider
.draft_delete(&provider_id)
.await
.map_err(|e| e.to_string())?;
} }
crate::emit_store_changed(&app, "threads"); db.with(&account_id, |conn| {
delete(conn, &id)?;
if let Some(message_id) = message_id {
write::delete_message(conn, &message_id)?;
}
Ok(())
})?;
crate::emit_store_changed(&app, "drafts threads thread");
return Ok(()); return Ok(());
} }
Err("that draft is not on this device".to_string()) Err("that draft is not on this device".to_string())
+2
View File
@@ -441,6 +441,8 @@ pub struct MessageView {
pub trackers: Vec<Tracker>, pub trackers: Vec<Tracker>,
/// Ordinary remote images that were blocked, which is a different count from the trackers. /// Ordinary remote images that were blocked, which is a different count from the trackers.
pub blocked_images: u32, pub blocked_images: u32,
#[serde(default)]
pub images_allowed: bool,
/// Whether the body currently rendered has remote images loaded. /// Whether the body currently rendered has remote images loaded.
pub images_loaded: bool, pub images_loaded: bool,
pub seen: bool, pub seen: bool,
+27 -1
View File
@@ -126,6 +126,7 @@ pub enum Call {
BatchModify, BatchModify,
MessagesModify, MessagesModify,
MessagesSend, MessagesSend,
DraftsList,
DraftsCreate, DraftsCreate,
DraftsUpdate, DraftsUpdate,
DraftsDelete, DraftsDelete,
@@ -143,7 +144,7 @@ impl Call {
Call::MessagesGet | Call::AttachmentsGet => 20, Call::MessagesGet | Call::AttachmentsGet => 20,
Call::DraftsUpdate => 15, Call::DraftsUpdate => 15,
Call::DraftsCreate | Call::DraftsDelete => 10, Call::DraftsCreate | Call::DraftsDelete => 10,
Call::MessagesList | Call::MessagesModify => 5, Call::MessagesList | Call::MessagesModify | Call::DraftsList => 5,
Call::HistoryList => 2, Call::HistoryList => 2,
Call::LabelsList | Call::SendAsList | Call::GetProfile => 1, Call::LabelsList | Call::SendAsList | Call::GetProfile => 1,
} }
@@ -166,6 +167,7 @@ impl Call {
Call::BatchModify => "Gmail label change", Call::BatchModify => "Gmail label change",
Call::MessagesModify => "Gmail label change", Call::MessagesModify => "Gmail label change",
Call::MessagesSend => "Gmail send", Call::MessagesSend => "Gmail send",
Call::DraftsList => "Gmail draft list",
Call::DraftsCreate => "Gmail draft create", Call::DraftsCreate => "Gmail draft create",
Call::DraftsUpdate => "Gmail draft update", Call::DraftsUpdate => "Gmail draft update",
Call::DraftsDelete => "Gmail draft delete", Call::DraftsDelete => "Gmail draft delete",
@@ -1183,6 +1185,30 @@ pub async fn messages_send(
read_json(resp, call.name(), call.scope()).await read_json(resp, call.name(), call.scope()).await
} }
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftsPage {
#[serde(default)]
pub drafts: Vec<Draft>,
pub next_page_token: Option<String>,
}
pub async fn drafts_list(
access_token: &str,
page_token: Option<&str>,
) -> Result<DraftsPage, ApiError> {
let mut params = vec![("maxResults", MAX_LIST_RESULTS)];
if let Some(token) = page_token {
params.push(("pageToken", token));
}
let resp = HTTP
.get(url_with(&format!("{BASE}/users/me/drafts"), &params))
.bearer_auth(access_token)
.send()
.await?;
read_json(resp, Call::DraftsList.name(), Call::DraftsList.scope()).await
}
pub async fn drafts_create( pub async fn drafts_create(
access_token: &str, access_token: &str,
raw: &[u8], raw: &[u8],
+3
View File
@@ -403,6 +403,7 @@ pub fn run() {
imap::imap_trust_cert, imap::imap_trust_cert,
imap::imap_forget_cert, imap::imap_forget_cert,
attachments::message_show_images, attachments::message_show_images,
attachments::message_images_set,
attachments::attachment_data_url, attachments::attachment_data_url,
attachments::attachment_save, attachments::attachment_save,
attachments::attachment_open, attachments::attachment_open,
@@ -451,6 +452,8 @@ pub fn run() {
// Writing // Writing
drafts::draft_save, drafts::draft_save,
drafts::draft_get, drafts::draft_get,
drafts::draft_list,
drafts::draft_import,
drafts::draft_delete, drafts::draft_delete,
send::send, send::send,
send::send_now, send::send_now,
+1 -1
View File
@@ -20,7 +20,7 @@ use std::collections::HashMap;
use crate::dto::{Invite, Person, Surface, Tracker, Unsubscribe}; use crate::dto::{Invite, Person, Surface, Tracker, Unsubscribe};
pub const RENDER_VERSION: i32 = 2; pub const RENDER_VERSION: i32 = 5;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct RenderedAttachment { pub struct RenderedAttachment {
+9
View File
@@ -43,6 +43,15 @@ pub fn render(raw: &[u8], options: &RenderOptions) -> Result<Rendered, String> {
let inline_parts = inline_parts(&message); let inline_parts = inline_parts(&message);
let (source, is_html, text) = body(&message); let (source, is_html, text) = body(&message);
let source = if is_html {
css_inline::CSSInliner::options()
.load_remote_stylesheets(false)
.build()
.inline(&source)
.unwrap_or(source)
} else {
source
};
let (visible_source, quoted_source) = if is_html { let (visible_source, quoted_source) = if is_html {
quoted::split_html(&source) quoted::split_html(&source)
} else { } else {
+19 -5
View File
@@ -153,7 +153,7 @@ fn place_clause(
Place::Sent => "t.trashed = 0 AND EXISTS (SELECT 1 FROM messages m \ Place::Sent => "t.trashed = 0 AND EXISTS (SELECT 1 FROM messages m \
WHERE m.provider_thread_id = t.provider_thread_id AND m.sent = 1)" WHERE m.provider_thread_id = t.provider_thread_id AND m.sent = 1)"
.to_string(), .to_string(),
Place::Drafts => "t.has_draft = 1 AND t.trashed = 0".to_string(), Place::Drafts => "(t.has_draft = 1 OR EXISTS (SELECT 1 FROM drafts d WHERE d.thread_key = t.thread_key)) AND t.trashed = 0".to_string(),
Place::Starred => "t.starred = 1 AND t.trashed = 0".to_string(), Place::Starred => "t.starred = 1 AND t.trashed = 0".to_string(),
Place::Spam => "t.spam = 1".to_string(), Place::Spam => "t.spam = 1".to_string(),
Place::Trash => "t.trashed = 1".to_string(), Place::Trash => "t.trashed = 1".to_string(),
@@ -305,7 +305,7 @@ const COLUMNS: &str = "\
t.trashed AS trashed, t.trashed AS trashed,
t.spam AS spam, t.spam AS spam,
t.has_attachment AS has_attachment, t.has_attachment AS has_attachment,
t.has_draft AS has_draft, (t.has_draft OR EXISTS (SELECT 1 FROM drafts d WHERE d.thread_key = t.thread_key)) AS has_draft,
pl.pile AS pile, pl.pile AS pile,
COALESCE(sn.return_at, rt.due_ms) AS snoozed_until, COALESCE(sn.return_at, rt.due_ms) AS snoozed_until,
COALESCE(tf.ignored, 0) AS ignored, COALESCE(tf.ignored, 0) AS ignored,
@@ -557,8 +557,8 @@ const VIEW_SQL: &str = "\
COALESCE(b.blocked_images, 0) AS blocked_images, COALESCE(b.blocked_images, 0) AS blocked_images,
(SELECT json_group_array(json_object( (SELECT json_group_array(json_object(
'id', a.id, 'messageId', a.message_id, 'filename', a.filename, 'id', a.id, 'messageId', a.message_id, 'filename', a.filename,
'mimeType', a.mime_type, 'size', a.size, 'inline', a.inline = 1, 'mimeType', a.mime_type, 'size', a.size, 'inline', json(CASE WHEN a.inline = 1 THEN 'true' ELSE 'false' END),
'contentId', a.content_id, 'cached', a.cached_path IS NOT NULL)) 'contentId', a.content_id, 'cached', json(CASE WHEN a.cached_path IS NOT NULL THEN 'true' ELSE 'false' END)))
FROM attachments a WHERE a.message_id = m.id) AS attachments_json, FROM attachments a WHERE a.message_id = m.id) AS attachments_json,
(SELECT name FROM state.renames WHERE thread_key = ?) AS rename, (SELECT name FROM state.renames WHERE thread_key = ?) AS rename,
(SELECT subject FROM threads WHERE thread_key = ? ORDER BY latest_ms DESC LIMIT 1) (SELECT subject FROM threads WHERE thread_key = ? ORDER BY latest_ms DESC LIMIT 1)
@@ -632,6 +632,7 @@ pub fn thread_view(
trackers: serde_json::from_str(&row.get::<_, String>("trackers")?) trackers: serde_json::from_str(&row.get::<_, String>("trackers")?)
.unwrap_or_default(), .unwrap_or_default(),
blocked_images: row.get::<_, i64>("blocked_images")? as u32, blocked_images: row.get::<_, i64>("blocked_images")? as u32,
images_allowed: false,
// The sanitiser fetches nothing, so a body is only ever served with its remote // The sanitiser fetches nothing, so a body is only ever served with its remote
// images already inlined by the caller that asked for them. // images already inlined by the caller that asked for them.
images_loaded: false, images_loaded: false,
@@ -677,7 +678,11 @@ pub fn thread_view(
let mut participants: Vec<Person> = Vec::new(); let mut participants: Vec<Person> = Vec::new();
let mut labels: Vec<String> = Vec::new(); let mut labels: Vec<String> = Vec::new();
let mut messages = Vec::new(); let mut messages = Vec::new();
for (message, message_labels, _) in rows { for (mut message, message_labels, _) in rows {
message.images_allowed =
state::read::pref(conn, &message_images_key(&message.message_id, &message.id))?
.as_deref()
== Some("true");
for label in serde_json::from_str::<Vec<String>>(&message_labels).unwrap_or_default() { for label in serde_json::from_str::<Vec<String>>(&message_labels).unwrap_or_default() {
if !labels.contains(&label) { if !labels.contains(&label) {
labels.push(label); labels.push(label);
@@ -1052,6 +1057,15 @@ pub fn thread_key_of(conn: &Connection, provider_thread_id: &str) -> Result<Opti
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
pub fn message_images_key(message_id: &str, provider_id: &str) -> String {
let id = if message_id.trim().is_empty() {
provider_id
} else {
message_id
};
format!("message-images:{id}")
}
/// One message's view, taken out of its thread's rather than assembled a second time. Sharing the /// One message's view, taken out of its thread's rather than assembled a second time. Sharing the
/// statement is what keeps a message looking the same whichever command handed it over. /// statement is what keeps a message looking the same whichever command handed it over.
pub fn message_view( pub fn message_view(
+69 -12
View File
@@ -43,6 +43,7 @@ use crate::dto::Tracker;
const STYLE_PROPERTIES: &[&str] = &[ const STYLE_PROPERTIES: &[&str] = &[
"color", "color",
"background-color", "background-color",
"background",
"font", "font",
"font-family", "font-family",
"font-size", "font-size",
@@ -126,16 +127,14 @@ const EXTRA_TAGS: &[&str] = &["tfoot", "font", "address"];
/// `style` because of `STYLE_PROPERTIES` above, `dir` because right to left mail is mail. /// `style` because of `STYLE_PROPERTIES` above, `dir` because right to left mail is mail.
const EXTRA_GENERIC_ATTRIBUTES: &[&str] = &["style", "dir"]; const EXTRA_GENERIC_ATTRIBUTES: &[&str] = &["style", "dir"];
/// The presentational attributes a table based newsletter is built out of. `background` is not
/// here: it is a URL in an attribute, which is the same fetch by another name.
const EXTRA_TAG_ATTRIBUTES: &[(&str, &[&str])] = &[ const EXTRA_TAG_ATTRIBUTES: &[(&str, &[&str])] = &[
("table", &["bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]), ("table", &["background", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]),
("thead", &["bgcolor", "valign"]), ("thead", &["bgcolor", "valign"]),
("tbody", &["bgcolor", "valign"]), ("tbody", &["bgcolor", "valign"]),
("tfoot", &["align", "bgcolor", "valign"]), ("tfoot", &["align", "bgcolor", "valign"]),
("tr", &["bgcolor", "height", "valign"]), ("tr", &["bgcolor", "height", "valign"]),
("td", &["bgcolor", "height", "nowrap", "valign", "width"]), ("td", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
("th", &["bgcolor", "height", "nowrap", "valign", "width"]), ("th", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
("font", &["color", "face", "size"]), ("font", &["color", "face", "size"]),
("img", &["border", "hspace", "vspace"]), ("img", &["border", "hspace", "vspace"]),
("a", &["name"]), ("a", &["name"]),
@@ -174,7 +173,8 @@ pub fn clean(
remote_images: &HashMap<String, Vec<u8>>, remote_images: &HashMap<String, Vec<u8>>,
policy: Policy, policy: Policy,
) -> Result<Sanitized, String> { ) -> Result<Sanitized, String> {
let plans = plan_images(source, inline_parts, remote_images, policy); let source = background_images(source);
let plans = plan_images(&source, inline_parts, remote_images, policy);
let log: Arc<Mutex<Vec<ImageEvent>>> = Arc::new(Mutex::new(Vec::new())); let log: Arc<Mutex<Vec<ImageEvent>>> = Arc::new(Mutex::new(Vec::new()));
let filter_log = Arc::clone(&log); let filter_log = Arc::clone(&log);
@@ -194,7 +194,7 @@ pub fn clean(
builder.add_tag_attributes(tag, attributes.iter().copied()); builder.add_tag_attributes(tag, attributes.iter().copied());
} }
let html = builder.clean(source).to_string(); let html = builder.clean(&source).to_string();
let events = log let events = log
.lock() .lock()
@@ -234,7 +234,7 @@ fn filter<'u>(
}; };
match (element, attribute) { match (element, attribute) {
("img", "src") => match plans.get(value.trim()) { ("img", "src") | (_, "background") => match plans.get(value.trim()) {
Some(ImagePlan::Inline { Some(ImagePlan::Inline {
data_url, data_url,
content_id, content_id,
@@ -282,10 +282,19 @@ fn plan_images(
) -> HashMap<String, ImagePlan> { ) -> HashMap<String, ImagePlan> {
let mut plans = HashMap::new(); let mut plans = HashMap::new();
for tag in scan(source) { for tag in scan(source) {
if tag.closing || tag.name != "img" { if tag.closing {
continue; continue;
} }
let Some(src) = tag.attr("src").map(str::trim).filter(|src| !src.is_empty()) else { let attribute = match tag.name.as_str() {
"img" => "src",
"table" | "td" | "th" => "background",
_ => continue,
};
let Some(src) = tag
.attr(attribute)
.map(str::trim)
.filter(|src| !src.is_empty())
else {
continue; continue;
}; };
if let Some(plan) = plan_one(src, &tag, inline_parts, remote_images, policy) { if let Some(plan) = plan_one(src, &tag, inline_parts, remote_images, policy) {
@@ -295,6 +304,54 @@ fn plan_images(
plans plans
} }
fn background_images(source: &str) -> Cow<'_, str> {
let mut out = String::new();
let mut cursor = 0;
for tag in scan(source) {
if tag.closing || !matches!(tag.name.as_str(), "table" | "td" | "th") {
continue;
}
if tag.attr("background").is_some() {
continue;
}
let Some(url) = tag.attr("style").and_then(|style| {
style
.split(';')
.filter_map(|declaration| {
let (name, value) = declaration.split_once(':')?;
matches!(
name.trim().to_ascii_lowercase().as_str(),
"background" | "background-image"
)
.then_some(value)
})
.find_map(|value| {
let start = value.to_ascii_lowercase().find("url(")? + 4;
let url = value[start..]
.split_once(')')?
.0
.trim()
.trim_matches(['\'', '"']);
(!url.is_empty()).then_some(url)
})
}) else {
continue;
};
let insertion = tag.start + 1 + tag.name.len();
out.push_str(&source[cursor..insertion]);
out.push_str(" background=\"");
out.push_str(&escape_html(url));
out.push('"');
cursor = insertion;
}
if cursor == 0 {
Cow::Borrowed(source)
} else {
out.push_str(&source[cursor..]);
Cow::Owned(out)
}
}
fn plan_one( fn plan_one(
src: &str, src: &str,
tag: &Tag, tag: &Tag,
@@ -304,8 +361,8 @@ fn plan_one(
) -> Option<ImagePlan> { ) -> Option<ImagePlan> {
let lowercase = src.to_ascii_lowercase(); let lowercase = src.to_ascii_lowercase();
if let Some(reference) = lowercase.strip_prefix("cid:") { if lowercase.starts_with("cid:") {
let key = decode_entities(reference) let key = decode_entities(&src[4..])
.trim_matches(|character| character == '<' || character == '>') .trim_matches(|character| character == '<' || character == '>')
.to_string(); .to_string();
let part = inline_parts.get(&key)?; let part = inline_parts.get(&key)?;
+10 -2
View File
@@ -289,7 +289,7 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
"style" => { "style" => {
let style = declarations(value) let style = declarations(value)
.filter(|(property, _)| match *property { .filter(|(property, _)| match *property {
"background-color" => !drop_background, "background-color" | "background" => !drop_background,
"color" => !drop_foreground, "color" => !drop_foreground,
_ => true, _ => true,
}) })
@@ -323,6 +323,11 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
fn background_of(tag: &Tag) -> Option<Rgba> { fn background_of(tag: &Tag) -> Option<Rgba> {
declaration(tag, "background-color") declaration(tag, "background-color")
.or_else(|| {
declarations(tag.attr("style")?)
.rfind(|(name, _)| *name == "background")
.and_then(|(_, value)| parse_colour(value).or_else(|| shorthand_colour(value)))
})
.or_else(|| tag.attr("bgcolor").and_then(parse_colour)) .or_else(|| tag.attr("bgcolor").and_then(parse_colour))
} }
@@ -441,7 +446,10 @@ const NAMED: &[(&str, u32)] = &[
]; ];
fn parse_colour(value: &str) -> Option<Rgba> { fn parse_colour(value: &str) -> Option<Rgba> {
let value = value.trim(); let value = value.rsplit_once('!')
.filter(|(_, priority)| priority.trim().eq_ignore_ascii_case("important"))
.map_or(value, |(colour, _)| colour)
.trim();
if value.is_empty() { if value.is_empty() {
return None; return None;
} }
+1 -1
View File
@@ -123,7 +123,7 @@ pub fn threading(conn: &Connection, draft: &Draft) -> Result<Threading, String>
"SELECT m.id, m.message_id, m.provider_thread_id, m.subject "SELECT m.id, m.message_id, m.provider_thread_id, m.subject
FROM messages m FROM messages m
JOIN threads t ON t.provider_thread_id = m.provider_thread_id JOIN threads t ON t.provider_thread_id = m.provider_thread_id
WHERE t.thread_key = ?1 WHERE t.thread_key = ?1 AND m.draft = 0
ORDER BY m.date_ms ASC, m.id ASC", ORDER BY m.date_ms ASC, m.id ASC",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
+2
View File
@@ -31,6 +31,7 @@ import { useEscapeLayer } from "./escape";
import { registerCommands } from "./keys/commands"; import { registerCommands } from "./keys/commands";
import { setAccountSwitch, useKeyContext, useKeymap } from "./keys/keymap"; import { setAccountSwitch, useKeyContext, useKeymap } from "./keys/keymap";
import { handleMenuAction } from "./keys/menu"; import { handleMenuAction } from "./keys/menu";
import { useCompose } from "./store/useCompose";
import { useAccounts } from "./store/useAccounts"; import { useAccounts } from "./store/useAccounts";
import { useMail } from "./store/useMail"; import { useMail } from "./store/useMail";
import { useStage } from "./store/useStage"; import { useStage } from "./store/useStage";
@@ -322,6 +323,7 @@ function Shell() {
// rename, a merge. Those show on the row as well as in the pane, so this one does reach the // rename, a merge. Those show on the row as well as in the pane, so this one does reach the
// list, and joins whatever query is already pending. // list, and joins whatever query is already pending.
if (scopes.some((scope) => scope.startsWith("thread:"))) reloadList(); if (scopes.some((scope) => scope.startsWith("thread:"))) reloadList();
if (scopes.includes("drafts")) void useCompose.getState().loadDrafts();
if (scopes.includes("accounts")) void useAccounts.getState().refresh(); if (scopes.includes("accounts")) void useAccounts.getState().refresh();
// The Screener's pill counts senders waiting rather than rows, so it is not part of the page // The Screener's pill counts senders waiting rather than rows, so it is not part of the page
// the list loaded and it has to be asked for by name. // the list loaded and it has to be asked for by name.
+5 -2
View File
@@ -5,8 +5,11 @@ import { call, type MessageView } from "../ipc";
* a referrer, and inlines the results as `data:` URIs, so the webview never makes a request of its * a referrer, and inlines the results as `data:` URIs, so the webview never makes a request of its
* own and the sender learns nothing but that somebody asked once. * own and the sender learns nothing but that somebody asked once.
*/ */
export const messageShowImages = (messageId: string) => export const messageShowImages = (accountId: string, messageId: string) =>
call<MessageView>("message_show_images", { messageId }); call<MessageView>("message_show_images", { accountId, messageId });
export const messageImagesSet = (accountId: string, messageId: string, allowed: boolean) =>
call<MessageView>("message_images_set", { accountId, messageId, allowed });
/** The bytes of an attachment as a `data:` URI, for the inline preview. Fetched on demand. */ /** The bytes of an attachment as a `data:` URI, for the inline preview. Fetched on demand. */
export const attachmentDataUrl = (attachmentId: string) => export const attachmentDataUrl = (attachmentId: string) =>
+3
View File
@@ -36,3 +36,6 @@ export const unsubscribe = (
alsoTrash: boolean, alsoTrash: boolean,
alsoScreenOut: boolean, alsoScreenOut: boolean,
) => call<Undo>("unsubscribe", { accountId, address, alsoTrash, alsoScreenOut }); ) => call<Undo>("unsubscribe", { accountId, address, alsoTrash, alsoScreenOut });
export const draftList = (accountId: string | null = null) => call<Draft[]>("draft_list", { accountId });
export const draftImport = (accountId: string, messageId: string) => call<Draft>("draft_import", { accountId, messageId });
+1
View File
@@ -291,6 +291,7 @@ export interface MessageView {
trackers: Tracker[]; trackers: Tracker[];
blockedImages: number; blockedImages: number;
imagesLoaded: boolean; imagesLoaded: boolean;
imagesAllowed?: boolean;
seen: boolean; seen: boolean;
draft: boolean; draft: boolean;
sentByMe: boolean; sentByMe: boolean;
+42 -7
View File
@@ -10,12 +10,12 @@ import {
} from "react"; } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { Editor as TiptapEditor } from "@tiptap/react"; import type { Editor as TiptapEditor } from "@tiptap/react";
import { Avatar, Button, Icon, icons, Key, NO_AUTOFILL, Popover } from "../ui"; import { Avatar, Button, Icon, icons, Key, NO_AUTOFILL, Popover, Sheet } from "../ui";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { registerCommands } from "../keys/commands"; import { registerCommands } from "../keys/commands";
import { useKeyContext } from "../keys/keymap"; import { useKeyContext } from "../keys/keymap";
import { contactsSuggest } from "../api/contacts"; import { contactsSuggest } from "../api/contacts";
import { isTauri, type DraftAttachment, type Person } from "../ipc"; import { isTauri, type Draft, type DraftAttachment, type Person } from "../ipc";
import { useAccounts } from "../store/useAccounts"; import { useAccounts } from "../store/useAccounts";
import { useCompose, type Composer, type ComposerAt } from "../store/useCompose"; import { useCompose, type Composer, type ComposerAt } from "../store/useCompose";
import { useMail } from "../store/useMail"; import { useMail } from "../store/useMail";
@@ -179,11 +179,14 @@ export function Compose() {
const compose = useCompose((s) => s.compose); const compose = useCompose((s) => s.compose);
const closeCard = useCompose((s) => s.closeCard); const closeCard = useCompose((s) => s.closeCard);
const toggleExpanded = useCompose((s) => s.toggleExpanded); const toggleExpanded = useCompose((s) => s.toggleExpanded);
const [closing, setClosing] = useState(false);
const requestClose = () => card && !card.pristine ? setClosing(true) : closeCard();
// `c` and the Write button, for as long as this is mounted, which is the whole life of the app: // `c` and the Write button, for as long as this is mounted, which is the whole life of the app:
// this is mounted once at the top of the tree, so writing a message is something you can do from // this is mounted once at the top of the tree, so writing a message is something you can do from
// the Feed and the Screener and not only from a list. // the Feed and the Screener and not only from a list.
useEffect(() => registerCommands({ compose }), [compose]); useEffect(() => registerCommands({ compose }), [compose]);
useEffect(() => { void useCompose.getState().loadDrafts(); }, []);
// The signature, the undo delay, the reply-all default and the instant intro line are all // The signature, the undo delay, the reply-all default and the instant intro line are all
// settings, and settings are only read when the settings screen asks for them. Writing is the // settings, and settings are only read when the settings screen asks for them. Writing is the
@@ -199,17 +202,20 @@ export function Compose() {
return registerCommands({ undo: () => void useCompose.getState().undoSend() }); return registerCommands({ undo: () => void useCompose.getState().undoSend() });
}, [holding]); }, [holding]);
useEscapeLayer(card !== null, closeCard); useEscapeLayer(card !== null && !closing, requestClose);
if (!card) return null; if (!card) return null;
return ( return (
<>
<CloseDraft at="card" open={closing} onClose={() => setClosing(false)} />
<ComposeCard <ComposeCard
composer={card} composer={card}
expanded={expanded} expanded={expanded}
onClose={closeCard} onClose={requestClose}
onExpand={toggleExpanded} onExpand={toggleExpanded}
/> />
</>
); );
} }
@@ -232,14 +238,14 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
className="compose" className="compose"
data-expanded={expanded ? "" : undefined} data-expanded={expanded ? "" : undefined}
role="dialog" role="dialog"
aria-label={CARD_TITLE} aria-label={draft.id ? "Draft" : CARD_TITLE}
ref={frame.ref} ref={frame.ref}
onDragOver={allowDrop} onDragOver={allowDrop}
onDrop={(e) => dropped(e, "card")} onDrop={(e) => dropped(e, "card")}
onPaste={(e) => pasted(e, "card")} onPaste={(e) => pasted(e, "card")}
> >
<div className="compose-head"> <div className="compose-head">
<h2>{CARD_TITLE}</h2> <h2>{draft.id ? "Draft" : CARD_TITLE}</h2>
<Button <Button
variant="ghost" variant="ghost"
iconOnly iconOnly
@@ -695,6 +701,7 @@ export function ComposeFoot({ at, composer, onSend, extra }: ComposeFootProps) {
label="Attach a file" label="Attach a file"
onClick={() => pickFiles(at)} onClick={() => pickFiles(at)}
/> />
<Button variant="ghost" icon={icons.TRASH} onClick={() => void useCompose.getState().discard(at)}>Discard draft</Button>
<span className="compose-delay">{`Undo send: ${delay} s`}</span> <span className="compose-delay">{`Undo send: ${delay} s`}</span>
</div> </div>
); );
@@ -717,6 +724,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
const setAll = useCompose((s) => s.setAll); const setAll = useCompose((s) => s.setAll);
const setShowCc = useCompose((s) => s.setShowCc); const setShowCc = useCompose((s) => s.setShowCc);
const closeReply = useCompose((s) => s.closeReply); const closeReply = useCompose((s) => s.closeReply);
const [closing, setClosing] = useState(false);
const editor = useRef<TiptapEditor | null>(null); const editor = useRef<TiptapEditor | null>(null);
const draft = composer.draft; const draft = composer.draft;
@@ -737,6 +745,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
onDrop={(e) => dropped(e, "reply")} onDrop={(e) => dropped(e, "reply")}
onPaste={(e) => pasted(e, "reply")} onPaste={(e) => pasted(e, "reply")}
> >
<CloseDraft at="reply" open={closing} onClose={() => setClosing(false)} />
<div className="reply-head"> <div className="reply-head">
<span className="reply-who"> <span className="reply-who">
{forwarding ? "Forward" : "Reply to "} {forwarding ? "Forward" : "Reply to "}
@@ -758,7 +767,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
className="reply-close" className="reply-close"
title="Close, keeping the draft" title="Close, keeping the draft"
aria-label="Close, keeping the draft" aria-label="Close, keeping the draft"
onClick={closeReply} onClick={() => composer.pristine ? closeReply() : setClosing(true)}
> >
<Icon d={icons.CLOSE} size={12} /> <Icon d={icons.CLOSE} size={12} />
</button> </button>
@@ -839,3 +848,29 @@ export function SendingLine({ threadKey }: { threadKey: string }) {
} }
export default Compose; export default Compose;
function CloseDraft({ at, open, onClose }: { at: ComposerAt; open: boolean; onClose: () => void }) {
const [busy, setBusy] = useState(false);
const save = async () => {
setBusy(true);
const compose = useCompose.getState();
if (at === "card") await compose.closeCard(); else await compose.closeReply();
const current = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
setBusy(false);
if (current?.phase === "error") return;
onClose();
};
return <Sheet open={open} size="mini" title="Keep your draft?" busy={busy} onClose={onClose} foot={<>
<Button disabled={busy} onClick={onClose}>Keep editing</Button>
<Button variant="danger" disabled={busy} onClick={() => { onClose(); void useCompose.getState().discard(at); }}>Discard</Button>
<Button variant="primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save draft and close"}</Button>
</>}><p>Save what you wrote before closing, or discard this draft.</p></Sheet>;
}
export function DraftCard({ draft }: { draft: Draft }) {
return <div className="draft-card">
<span><b>Draft</b> · {draft.subject || "(no subject)"} · {draft.to.map(displayName).join(", ")}</span>
<Button size="sm" onClick={() => useCompose.getState().resumeDraft(draft)}>Resume draft</Button>
<Button size="sm" variant="ghost" onClick={() => { if (draft.id) void useCompose.getState().deleteDraft(draft.id); }}>Discard draft</Button>
</div>;
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { MessageContent, MessageImageBanner } from "./MessageContent";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Banner, Button, EmptyState, icons } from "../ui"; import { Avatar, Banner, Button, EmptyState, icons } from "../ui";
import { registerCommands, runCommand } from "../keys/commands"; import { registerCommands, runCommand } from "../keys/commands";
@@ -8,7 +9,7 @@ import { useContacts } from "../store/useContacts";
import { useFeed } from "../store/useFeed"; import { useFeed } from "../store/useFeed";
import { useMail } from "../store/useMail"; import { useMail } from "../store/useMail";
import { displayName, isBrand, messageTime } from "./format"; import { displayName, isBrand, messageTime } from "./format";
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody"; import { BodyMissing, BodySkeleton } from "./MessageBody";
import * as triage from "./triage"; import * as triage from "./triage";
import "./feed.css"; import "./feed.css";
@@ -261,6 +262,7 @@ function Card({ thread, view, focused, onFocus, onToggle, hold }: CardProps) {
data-selected={focused ? "" : undefined} data-selected={focused ? "" : undefined}
onClick={onFocus} onClick={onFocus}
> >
{message ? <MessageImageBanner accountId={thread.accountId} message={message} /> : null}
<div className="feed-head"> <div className="feed-head">
<Avatar <Avatar
name={displayName(thread.from)} name={displayName(thread.from)}
@@ -285,7 +287,7 @@ function Card({ thread, view, focused, onFocus, onToggle, hold }: CardProps) {
data-open={expanded ? "" : undefined} data-open={expanded ? "" : undefined}
data-paper={message.surface === "paper" ? "" : undefined} data-paper={message.surface === "paper" ? "" : undefined}
> >
<MessageBody html={message.html} surface={message.surface} /> <MessageContent accountId={thread.accountId} message={message} />
{clipped && !expanded ? <div className="feed-fade" /> : null} {clipped && !expanded ? <div className="feed-fade" /> : null}
</div> </div>
) : ( ) : (
+15 -3
View File
@@ -8,9 +8,10 @@ import { useCompose } from "../store/useCompose";
import { useMail } from "../store/useMail"; import { useMail } from "../store/useMail";
import { usePiles } from "../store/usePiles"; import { usePiles } from "../store/usePiles";
import { useSettings } from "../store/useSettings"; import { useSettings } from "../store/useSettings";
import { notify } from "../store/useToast";
import { useStage } from "../store/useStage"; import { useStage } from "../store/useStage";
import { cap, displayName, isBrand, messageTime } from "./format"; import { cap, displayName, isBrand, messageTime } from "./format";
import { MessageBody } from "./MessageBody"; import { MessageContent, MessageImageBanner } from "./MessageContent";
import "./focus.css"; import "./focus.css";
/** /**
@@ -98,6 +99,7 @@ export function FocusReply() {
if (!view || !last || !body) return; if (!view || !last || !body) return;
const compose = useCompose.getState(); const compose = useCompose.getState();
if (compose.reply) { notify("Close the current reply before sending from Focus & Reply"); return; }
compose.answer(view, last, "reply", useSettings.getState().settings?.replyAllDefault ?? false); compose.answer(view, last, "reply", useSettings.getState().settings?.replyAllDefault ?? false);
compose.edit("reply", { compose.edit("reply", {
bodyHtml: body bodyHtml: body
@@ -105,7 +107,7 @@ export function FocusReply() {
.map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`) .map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
.join(""), .join(""),
}); });
const to = compose.reply?.draft.to[0]; const to = useCompose.getState().reply?.draft.to[0];
void compose.post("reply", now); void compose.post("reply", now);
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) })); setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
step(1); step(1);
@@ -160,6 +162,15 @@ export function FocusReply() {
if (key) boxes.current.get(key)?.focus(); if (key) boxes.current.get(key)?.focus();
}, [at, items]); }, [at, items]);
const hadItems = useRef(false);
useEffect(() => {
if (items.length > 0) hadItems.current = true;
else if (hadItems.current) {
useStage.getState().close();
useMail.getState().goTo("inbox");
}
}, [items.length]);
if (items.length === 0) { if (items.length === 0) {
return ( return (
<main className="stage focus"> <main className="stage focus">
@@ -279,6 +290,7 @@ function Item({
<article className="focus-item" data-active={active ? "" : undefined} onClick={onFocus}> <article className="focus-item" data-active={active ? "" : undefined} onClick={onFocus}>
<div className="focus-message"> <div className="focus-message">
<h2 className="focus-subject">{thread.subject}</h2> <h2 className="focus-subject">{thread.subject}</h2>
{message ? <MessageImageBanner accountId={thread.accountId} message={message} /> : null}
<div className="focus-from"> <div className="focus-from">
<Avatar name={name} address={from.address} brand={isBrand(from)} /> <Avatar name={name} address={from.address} brand={isBrand(from)} />
<div className="focus-who"> <div className="focus-who">
@@ -292,7 +304,7 @@ function Item({
</div> </div>
<div className="focus-body"> <div className="focus-body">
{message ? ( {message ? (
<MessageBody html={message.html} surface={message.surface} /> <MessageContent accountId={thread.accountId} message={message} />
) : ( ) : (
<p className="focus-snippet">{thread.snippet}</p> <p className="focus-snippet">{thread.snippet}</p>
)} )}
+7 -2
View File
@@ -4,6 +4,8 @@ import { Button, EmptyState, GroupHead, icons, Pill, Row } from "../ui";
import { registerCommands, runCommand } from "../keys/commands"; import { registerCommands, runCommand } from "../keys/commands";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { GROUPS, type Place, type ThreadSummary } from "../ipc"; import { GROUPS, type Place, type ThreadSummary } from "../ipc";
import { useCompose } from "../store/useCompose";
import { DraftCard } from "./Compose";
import { useAccounts } from "../store/useAccounts"; import { useAccounts } from "../store/useAccounts";
import { useMail } from "../store/useMail"; import { useMail } from "../store/useMail";
import { usePiles } from "../store/usePiles"; import { usePiles } from "../store/usePiles";
@@ -187,6 +189,8 @@ export function ListColumn() {
const waiting = useScreener((s) => s.cards.length); const waiting = useScreener((s) => s.cards.length);
const loadScreener = useScreener((s) => s.load); const loadScreener = useScreener((s) => s.load);
const drafts = useCompose((s) => s.drafts).filter((d) => !accountId || d.accountId === accountId);
useEffect(() => { if (place === "drafts") void useCompose.getState().loadDrafts(); }, [place, accountId]);
const entries = useMemo(() => entriesOf(threads), [threads]); const entries = useMemo(() => entriesOf(threads), [threads]);
const searching = place === "search"; const searching = place === "search";
const searchOpen = searchPhase !== "off"; const searchOpen = searchPhase !== "off";
@@ -390,7 +394,7 @@ export function ListColumn() {
address={entry.thread.from.address} address={entry.thread.from.address}
brand={isBrand(entry.thread.from)} brand={isBrand(entry.thread.from)}
time={timeOf(entry.thread, place)} time={timeOf(entry.thread, place)}
subject={entry.thread.subject} subject={entry.thread.hasDraft ? `Draft · ${entry.thread.subject}` : entry.thread.subject}
snippet={entry.thread.snippet} snippet={entry.thread.snippet}
count={entry.thread.messageCount} count={entry.thread.messageCount}
note={entry.thread.note ?? undefined} note={entry.thread.note ?? undefined}
@@ -438,12 +442,13 @@ export function ListColumn() {
) : null} ) : null}
</div> </div>
{place === "drafts" ? drafts.filter((d) => !threads.some((t) => t.key === d.threadKey)).map((draft) => <DraftCard key={draft.id} draft={draft} />) : null}
{entries.length === 0 ? ( {entries.length === 0 ? (
<div className="list list-blank"> <div className="list list-blank">
{phase === "loading" ? null : fill && !searching ? ( {phase === "loading" ? null : fill && !searching ? (
<Filling fill={fill} /> <Filling fill={fill} />
) : ( ) : (
<EmptyState>{EMPTY[place] ?? "Nothing here"}</EmptyState> <EmptyState>{place === "drafts" && drafts.length > 0 ? "" : EMPTY[place] ?? "Nothing here"}</EmptyState>
)} )}
{searching ? searchFoot() : null} {searching ? searchFoot() : null}
</div> </div>
+20 -6
View File
@@ -3,7 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import { isTauri, type Surface } from "../ipc"; import { isTauri, type Surface } from "../ipc";
import { useTheme } from "../store/useTheme"; import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast"; import { notify } from "../store/useToast";
import { Pill } from "../ui"; import { Button, Pill, Sheet } from "../ui";
/** /**
* One message's body, in a sandboxed iframe. * One message's body, in a sandboxed iframe.
@@ -50,6 +50,7 @@ export interface MessageBodyProps {
* unreadable on our own paper off a `theme` body, so what is left inherits ours. * unreadable on our own paper off a `theme` body, so what is left inherits ours.
*/ */
surface?: Surface; surface?: Surface;
imagesAllowed?: boolean;
} }
/** /**
@@ -153,8 +154,10 @@ function rootRule(surface: Surface, theme: string): string {
return `:root{${values};--m-color-scheme:${pinned ? "only light" : theme}}`; return `:root{${values};--m-color-scheme:${pinned ? "only light" : theme}}`;
} }
export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps) { export function MessageBody({ html, plain, surface = "theme", imagesAllowed }: MessageBodyProps) {
const frame = useRef<HTMLIFrameElement | null>(null); const frame = useRef<HTMLIFrameElement | null>(null);
const [link, setLink] = useState<string | null>(null);
const allowImages = imagesAllowed ?? false;
const theme = useTheme((s) => s.theme); const theme = useTheme((s) => s.theme);
const [css, setCss] = useState<string | null>(sheetText); const [css, setCss] = useState<string | null>(sheetText);
@@ -177,8 +180,8 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
() => () =>
css === null css === null
? "" ? ""
: `<!doctype html><html><head><meta charset="utf-8"><style>${tokens}\n${css}</style></head><body${plain ? " data-plain" : ""}>${html}</body></html>`, : `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="img-src ${allowImages ? "data: blob:" : "'none'"}"><style>${tokens}\n${css}</style></head><body${plain ? " data-plain" : ""}${surface === "paper" ? " data-paper" : ""}>${html}</body></html>`,
[css, html, plain, tokens], [css, html, plain, tokens, surface, allowImages],
); );
useEffect(() => { useEffect(() => {
@@ -196,8 +199,7 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
event.preventDefault(); event.preventDefault();
const href = anchor.getAttribute("href"); const href = anchor.getAttribute("href");
if (!href || href.startsWith("#")) return; if (!href || href.startsWith("#")) return;
if (isTauri) void openUrl(href).catch((e) => notify(`Could not open that link: ${String(e)}`)); setLink(href);
else window.open(href, "_blank", "noopener,noreferrer");
}; };
/** Whether there was a document there to take. */ /** Whether there was a document there to take. */
@@ -256,6 +258,17 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
}, [srcdoc]); }, [srcdoc]);
return ( return (
<>
<Sheet open={link !== null} size="mini" title="Link" onClose={() => setLink(null)} foot={<>
<Button onClick={() => { if (link) void navigator.clipboard.writeText(link).then(() => { notify("Link copied"); setLink(null); }).catch((e) => notify(`Could not copy the link: ${String(e)}`)); }}>Copy link</Button>
<Button variant="primary" onClick={() => {
if (!link) return;
if (isTauri) void openUrl(link).then(() => setLink(null)).catch((e) => notify(`Could not open that link: ${String(e)}`));
else { window.open(link, "_blank", "noopener,noreferrer"); setLink(null); }
}}>Open in browser</Button>
</>}>
<p style={{ overflowWrap: "anywhere" }}>{link}</p>
</Sheet>
<iframe <iframe
className="msg-frame" className="msg-frame"
ref={frame} ref={frame}
@@ -266,6 +279,7 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
sandbox="allow-same-origin" sandbox="allow-same-origin"
srcDoc={srcdoc} srcDoc={srcdoc}
/> />
</>
); );
} }
+56
View File
@@ -0,0 +1,56 @@
import { useEffect } from "react";
import type { MessageView, Surface } from "../ipc";
import { imageKey, useImages } from "../store/useImages";
import { Banner, icons } from "../ui";
import { MessageBody } from "./MessageBody";
interface MessageContentProps {
accountId: string;
message: MessageView;
plain?: boolean;
surface?: Surface;
quoted?: boolean;
}
function useMessageImages(accountId: string, message: MessageView) {
const entry = useImages((s) => s.entries[imageKey(accountId, message.id)]);
const ensure = useImages((s) => s.ensure);
useEffect(() => ensure(accountId, message), [accountId, message, ensure]);
const allowed = entry?.allowed ?? message.imagesAllowed ?? false;
const current = entry?.html === message.html && entry.quotedHtml === message.quotedHtml;
const content = allowed && current && entry?.content ? entry.content : message;
return { entry, allowed, current, content };
}
export function MessageImageBanner({ accountId, message }: Pick<MessageContentProps, "accountId" | "message">) {
const { entry, allowed, current, content } = useMessageImages(accountId, message);
const hasImages = message.blockedImages > 0 || /<img\b|\bbackground\s*=/i.test(message.html + (message.quotedHtml ?? ""));
const trackers = content.trackers;
const vendors = [...new Set(trackers.map((tracker) => tracker.vendor))];
const failed = allowed && entry?.phase === "error";
const loading = allowed && hasImages && !failed && (entry?.phase === "loading" || !current || !entry?.content);
const missing = allowed && current && entry?.content ? content.blockedImages : 0;
if (!(hasImages && !allowed) && !loading && !failed && missing === 0 && trackers.length === 0) return null;
return <div className="thread-banner">
<Banner
icon={icons.SHIELD}
action={hasImages && (!allowed || loading || failed || missing > 0) ? {
label: allowed ? "Try again" : "Show images",
busy: loading,
busyLabel: "Loading images…",
onClick: () => void (allowed ? useImages.getState().load(accountId, message) : useImages.getState().allow(accountId, message, true)),
} : undefined}
>
{trackers.length > 0 ? <>Blocked <b>{`${trackers.length} tracker${trackers.length === 1 ? "" : "s"}`}</b>{vendors.length > 0 ? ` from ${vendors.join(", ")}. ` : ". "}</> : null}
{hasImages && !allowed ? `${message.blockedImages > 0 ? "Remote" : "Embedded"} images are off for this email.` : failed ? "Could not load images." : missing > 0 ? `${missing} image${missing === 1 ? "" : "s"} did not load.` : null}
</Banner>
</div>;
}
export function MessageContent({ accountId, message, plain, surface, quoted }: MessageContentProps) {
const { allowed, content } = useMessageImages(accountId, message);
return <>
<MessageBody html={content.html} plain={plain} surface={surface ?? message.surface} imagesAllowed={allowed} />
{quoted && content.quotedHtml ? <MessageBody html={content.quotedHtml} plain={plain} surface={surface ?? message.surface} imagesAllowed={allowed} /> : null}
</>;
}
+112 -173
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Avatar, AvatarStack, Banner, Button, EmptyState, Field, icons, Pill, Sheet } from "../ui"; import { Avatar, AvatarStack, Banner, Button, Confirm, EmptyState, Field, icons, Pill, Sheet } from "../ui";
import { registerCommands, runCommand } from "../keys/commands"; import { registerCommands, runCommand } from "../keys/commands";
import { attachmentOpen, messageShowImages } from "../api/messages"; import { attachmentOpen } from "../api/messages";
import { draftImport } from "../api/write";
import { noteAdd } from "../api/notes"; import { noteAdd } from "../api/notes";
import { threadMerge, threadRename, threadUnmerge } from "../api/threads"; import { threadMerge, threadRename, threadUnmerge } from "../api/threads";
import { undoToken } from "../api/undo"; import { undoToken } from "../api/undo";
@@ -12,14 +13,14 @@ import { useCompose, type ComposerKind } from "../store/useCompose";
import { useMail } from "../store/useMail"; import { useMail } from "../store/useMail";
import { usePiles } from "../store/usePiles"; import { usePiles } from "../store/usePiles";
import { useSelection } from "../store/useSelection"; import { useSelection } from "../store/useSelection";
import { useSettings } from "../store/useSettings";
import { useSnooze } from "../store/useSnooze"; import { useSnooze } from "../store/useSnooze";
import { useTheme } from "../store/useTheme"; import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast"; import { notify } from "../store/useToast";
import { LabelPicker, type PickerMode } from "./ActionBar"; import { LabelPicker, type PickerMode } from "./ActionBar";
import { ReplyBox, SendingLine } from "./Compose"; import { DraftCard, ReplyBox, SendingLine } from "./Compose";
import { InviteCard } from "./InviteCard"; import { InviteCard } from "./InviteCard";
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody"; import { BodyMissing, BodySkeleton } from "./MessageBody";
import { MessageContent, MessageImageBanner } from "./MessageContent";
import { MoreMenu } from "./MoreMenu"; import { MoreMenu } from "./MoreMenu";
import { SnoozePicker, snoozeAnchor } from "./SnoozePicker"; import { SnoozePicker, snoozeAnchor } from "./SnoozePicker";
import * as triage from "./triage"; import * as triage from "./triage";
@@ -135,17 +136,9 @@ interface VerbSpec {
icon: string; icon: string;
} }
/**
* The bar of verbs, in the order the hand reaches for them: the four that move a thread on the
* left, and Archive on the right where it cannot be hit by accident.
*
* Every one of them goes through the command registry rather than calling anything directly, which
* is what keeps the button and the key one code path. Reply all and Forward are not in the bar and
* are not missing: `a` and `f` open the same box this button does with different recipients in it,
* and three buttons for one verb is a bar you have to read.
*/
const LEAD: VerbSpec[] = [ const LEAD: VerbSpec[] = [
{ command: "reply", label: "Reply", icon: icons.REPLY }, { command: "reply", label: "Reply", icon: icons.REPLY },
{ command: "reply-all", label: "Reply all", icon: icons.REPLY },
{ command: "reply-later", label: "Reply later", icon: icons.CLOCK }, { command: "reply-later", label: "Reply later", icon: icons.CLOCK },
{ command: "set-aside", label: "Set aside", icon: icons.SET_ASIDE }, { command: "set-aside", label: "Set aside", icon: icons.SET_ASIDE },
{ command: "snooze", label: "Snooze", icon: icons.SNOOZE }, { command: "snooze", label: "Snooze", icon: icons.SNOOZE },
@@ -158,6 +151,8 @@ function PaneBar({ notify }: { notify: boolean }) {
const place = useMail((s) => s.place); const place = useMail((s) => s.place);
const lead = place === "paper-trail" ? TRAIL_LEAD : LEAD; const lead = place === "paper-trail" ? TRAIL_LEAD : LEAD;
const [more, setMore] = useState(false); const [more, setMore] = useState(false);
const [trashing, setTrashing] = useState(false);
const openKey = useMail((s) => s.openKey);
const moreButton = useRef<HTMLButtonElement | null>(null); const moreButton = useRef<HTMLButtonElement | null>(null);
// `.` and the button are one verb, so the key is registered by the bar that draws the button and // `.` and the button are one verb, so the key is registered by the bar that draws the button and
@@ -169,6 +164,10 @@ function PaneBar({ notify }: { notify: boolean }) {
{lead.map((verb) => ( {lead.map((verb) => (
<Verb key={verb.command} {...verb} /> <Verb key={verb.command} {...verb} />
))} ))}
<Button variant="ghost" icon={icons.TRASH} onClick={() => setTrashing(true)}>Trash</Button>
<Sheet open={trashing} size="mini" title="Move to trash" onClose={() => setTrashing(false)}>
<Confirm title="Trash this email?" body={<p>This conversation will move to Trash.</p>} confirmLabel="Trash" onCancel={() => setTrashing(false)} onConfirm={() => { setTrashing(false); if (openKey) triage.trash([openKey]); }} />
</Sheet>
<span className="pane-gap" /> <span className="pane-gap" />
{place === "paper-trail" ? ( {place === "paper-trail" ? (
<Verb command="move" label="Move to Inbox" icon={icons.INBOX} /> <Verb command="move" label="Move to Inbox" icon={icons.INBOX} />
@@ -266,15 +265,6 @@ export function ReadingPane() {
const [expanded, setExpanded] = useState<string[]>([]); const [expanded, setExpanded] = useState<string[]>([]);
const [quoted, setQuoted] = useState<string[]>([]); const [quoted, setQuoted] = useState<string[]>([]);
const [focus, setFocus] = useState<number | null>(null); const [focus, setFocus] = useState<number | null>(null);
/** Messages re-rendered with their images fetched, which replaces the one the thread came with. */
const [shown, setShown] = useState<Record<string, MessageView>>({});
/**
* Where the request for the pictures is. Rust fetches them and that is a network round trip per
* host, so the button has to say it is working: pressed and unchanged for three seconds is the
* one thing this pane must never look like.
*/
const [images, setImages] = useState<"idle" | "fetching">("idle");
const imagesRequest = useRef(0);
/** Same for the merge banner's verb, which is three round trips with nothing else on screen. */ /** Same for the merge banner's verb, which is three round trips with nothing else on screen. */
const [unmerging, setUnmerging] = useState<"idle" | "working">("idle"); const [unmerging, setUnmerging] = useState<"idle" | "working">("idle");
const [picker, setPicker] = useState<PickerMode | null>(null); const [picker, setPicker] = useState<PickerMode | null>(null);
@@ -285,12 +275,13 @@ export function ReadingPane() {
const openKey = useMail((s) => s.openKey); const openKey = useMail((s) => s.openKey);
const reply = useCompose((s) => s.reply); const reply = useCompose((s) => s.reply);
const replyKey = useCompose((s) => s.replyKey); const replyKey = useCompose((s) => s.replyKey);
const drafts = useCompose((s) => s.drafts);
const isMe = useIsMe(); const isMe = useIsMe();
const messages = useMemo( const messages = useMemo(
() => (thread?.messages ?? []).map((m) => shown[m.id] ?? m), () => thread?.messages ?? [],
[thread, shown], [thread],
); );
// A thread opens on its latest message with everything before it collapsed to a line, which is // A thread opens on its latest message with everything before it collapsed to a line, which is
@@ -300,9 +291,6 @@ export function ReadingPane() {
setExpanded(last ? [last.id] : []); setExpanded(last ? [last.id] : []);
setQuoted([]); setQuoted([]);
setFocus(null); setFocus(null);
setShown({});
setImages("idle");
imagesRequest.current += 1;
setUnmerging("idle"); setUnmerging("idle");
// The failed slots were the last thread's, and nothing in `open` knows about them. // The failed slots were the last thread's, and nothing in `open` knows about them.
useMail.getState().clearBodyPhase(); useMail.getState().clearBodyPhase();
@@ -331,16 +319,16 @@ export function ReadingPane() {
if (!thread) return; if (!thread) return;
const answer = (kind: ComposerKind, all: boolean) => { const answer = (kind: ComposerKind, all: boolean) => {
const view = useMail.getState().thread; const view = useMail.getState().thread;
const last = view?.messages.at(-1); const last = view?.messages.filter((m) => !m.draft).at(-1);
if (!view || !last) return; if (!view || !last) return;
useCompose.getState().answer(view, last, kind, all); useCompose.getState().answer(view, last, kind, all);
}; };
return registerCommands({ return registerCommands({
reply: () => answer("reply", useSettings.getState().settings?.replyAllDefault ?? false), reply: () => answer("reply", false),
"reply-all": () => { "reply-all": () => {
const compose = useCompose.getState(); const compose = useCompose.getState();
const open = compose.replyKey === thread.key ? compose.reply : null; const open = compose.replyKey === thread.key ? compose.reply : null;
if (open && open.kind !== "forward") compose.setAll(!open.all); if (open && open.kind !== "forward") compose.setAll(true);
else answer("reply", true); else answer("reply", true);
}, },
forward: () => answer("forward", false), forward: () => answer("forward", false),
@@ -420,32 +408,6 @@ export function ReadingPane() {
); );
} }
const trackers = messages.flatMap((m) => m.trackers);
const blocked = messages.filter((m) => m.blockedImages > 0);
// Asked for and still not there: a host that would not answer. The banner says so rather than
// offering the same button again as if nothing had happened.
const missing = blocked.reduce((n, m) => (m.imagesLoaded ? n + m.blockedImages : n), 0);
const vendors = [...new Set(trackers.map((t) => t.vendor))];
const showImages = async () => {
if (images === "fetching") return;
const mine = ++imagesRequest.current;
setImages("fetching");
const results = await Promise.allSettled(blocked.map((m) => messageShowImages(m.id)));
const next: Record<string, MessageView> = {};
let failure: unknown = null;
results.forEach((result, index) => {
if (result.status === "fulfilled") next[blocked[index].id] = result.value;
else failure ??= result.reason;
});
setShown((was) => ({ ...was, ...next }));
// Another thread has been opened since, and its banner is not this request's to settle.
if (imagesRequest.current !== mine) return;
setImages("idle");
// A press that came to nothing has to say so, or the button is the one that looked stuck.
if (failure !== null) notify(`Could not load the images: ${String(failure)}`);
};
const unmerge = async () => { const unmerge = async () => {
if (unmerging === "working") return; if (unmerging === "working") return;
setUnmerging("working"); setUnmerging("working");
@@ -544,42 +506,6 @@ export function ReadingPane() {
</div> </div>
) : null} ) : null}
{trackers.length > 0 || blocked.length > 0 ? (
<div className="thread-banner">
<Banner
icon={icons.SHIELD}
action={
blocked.length > 0
? {
label: missing > 0 ? "Try again" : "Show images",
busy: images === "fetching",
busyLabel: "Loading images…",
onClick: () => void showImages(),
}
: undefined
}
>
{/* The count is only spoken when there is one. A message with no trackers and a
remote image says the thing that is true about it, because a banner reading
"Blocked 0 trackers" is the app taking credit for doing nothing. */}
<>
{trackers.length > 0 ? (
<>
{"Blocked "}
<b>{`${trackers.length} tracker${trackers.length === 1 ? "" : "s"}`}</b>
{vendors.length > 0 ? ` from ${vendors.join(", ")}. ` : ". "}
</>
) : null}
{blocked.length > 0
? missing > 0
? `${missing} image${missing === 1 ? "" : "s"} did not load.`
: "Remote images are off for this sender."
: null}
</>
</Banner>
</div>
) : null}
{messages.map((message, index) => ( {messages.map((message, index) => (
<Message <Message
key={message.id} key={message.id}
@@ -595,6 +521,7 @@ export function ReadingPane() {
setFocus(index); setFocus(index);
toggle(message.id); toggle(message.id);
}} }}
onReply={() => useCompose.getState().answer(thread, message, "reply", false)}
onQuoted={() => onQuoted={() =>
setQuoted((was) => setQuoted((was) =>
was.includes(message.id) was.includes(message.id)
@@ -612,6 +539,7 @@ export function ReadingPane() {
<Note key={note.id} body={note.body} /> <Note key={note.id} body={note.body} />
))} ))}
{drafts.filter((d) => d.accountId === thread.accountId && d.threadKey === thread.key && !messages.some((m) => m.draft)).map((draft) => <DraftCard key={draft.id} draft={draft} />)}
<SendingLine threadKey={thread.key} /> <SendingLine threadKey={thread.key} />
{/* The box a reply is written in, under what it answers. `r`, `a` and `f` open it and {/* The box a reply is written in, under what it answers. `r`, `a` and `f` open it and
@@ -812,6 +740,7 @@ interface MessageProps {
toYou: boolean; toYou: boolean;
onToggle: () => void; onToggle: () => void;
onQuoted: () => void; onQuoted: () => void;
onReply: () => void;
notes: { id: string; body: string }[]; notes: { id: string; body: string }[];
} }
@@ -826,6 +755,7 @@ function Message({
toYou, toYou,
onToggle, onToggle,
onQuoted, onQuoted,
onReply,
notes, notes,
}: MessageProps) { }: MessageProps) {
const who = me ? "You" : displayName(message.from); const who = me ? "You" : displayName(message.from);
@@ -857,89 +787,98 @@ function Message({
setOverride(next); setOverride(next);
}; };
return ( return (
<article <>
className="msg" {open && !message.draft && !message.bodyPending ? <MessageImageBanner accountId={accountId} message={message} /> : null}
data-message={message.id} <article
data-collapsed={open ? undefined : ""} className="msg"
data-focus={focused ? "" : undefined} data-message={message.id}
> data-collapsed={open ? undefined : ""}
<div className="msg-head" onClick={open ? undefined : onToggle}> data-focus={focused ? "" : undefined}
{/* The face is the sender's, whatever the line beside it calls them: a thread of your own >
replies is a column of your initials, not a column of the word You. */} <div className="msg-head" onClick={open ? undefined : onToggle}>
<Avatar {/* The face is the sender's, whatever the line beside it calls them: a thread of your own
name={displayName(message.from)} replies is a column of your initials, not a column of the word You. */}
address={message.from.address} <Avatar
brand={isBrand(message.from)} name={displayName(message.from)}
/> address={message.from.address}
<div className="msg-who"> brand={isBrand(message.from)}
<div className="msg-name"> />
{who} <div className="msg-who">
{open && !me ? <span className="addr">{message.from.address}</span> : null} <div className="msg-name">
</div> {message.draft ? "Draft" : who}
{open ? ( {open && !me ? <span className="addr">{message.from.address}</span> : null}
<div className="msg-to">{toYou ? "to you" : `to ${message.to.map(displayName).join(", ")}`}</div> </div>
) : ( {open ? (
<div className="msg-preview">{previewOf(message.html)}</div> <div className="msg-to">{toYou ? "to you" : `to ${message.to.map(displayName).join(", ")}`}</div>
)}
</div>
<div className="msg-aside">
<span className="msg-time">{messageTime(message.dateMs)}</span>
{open && theme === "dark" && message.isHtml ? (
<SurfaceToggle surface={surface} onFlip={flip} />
) : null}
</div>
</div>
{open ? (
<div className="msg-body">
{message.bodyPending ? (
pending === "error" ? (
<BodyMissing onRetry={() => void useMail.getState().hydrateThread()} />
) : ( ) : (
<BodySkeleton /> <div className="msg-preview">{previewOf(message.html)}</div>
) )}
) : ( </div>
<MessageBody html={message.html} plain={plain} surface={surface} /> <div className="msg-aside">
)} <span className="msg-time">{messageTime(message.dateMs)}</span>
{open && theme === "dark" && message.isHtml ? (
{message.quotedHtml ? ( <SurfaceToggle surface={surface} onFlip={flip} />
<div className="msg-quoted"> ) : null}
<Pill tone="quiet" onClick={onQuoted}> </div>
{quoted ? "Hide quoted text" : "··· Show quoted text"}
</Pill>
{quoted ? <MessageBody html={message.quotedHtml} plain={plain} surface={surface} /> : null}
</div>
) : null}
{message.invite ? (
<InviteCard invite={message.invite} messageId={message.id} accountId={accountId} />
) : null}
{message.attachments.length > 0 ? (
<div className="attachments">
{message.attachments.map((file) => (
<button
type="button"
className="attachment"
key={file.id}
data-phase={files[file.id] ?? "idle"}
disabled={files[file.id] === "opening"}
onClick={() => void openFile(file.id)}
>
<span className="ext">{fileKind(file.filename, file.mimeType)}</span>
{file.filename}
<span className="size">{fileSize(file.size)}</span>
</button>
))}
</div>
) : null}
</div> </div>
) : null}
{notes.map((note) => ( {open ? (
<Note key={note.id} body={note.body} /> <div className="msg-body">
))} {message.draft ? (
</article> <div className="draft-card">
<b>Draft</b>
<Button disabled={message.bodyPending} onClick={() => void useCompose.getState().openDraft(accountId, message.id)}>Resume draft</Button>
<Button variant="ghost" disabled={message.bodyPending} onClick={() => void draftImport(accountId, message.id).then(async (draft) => { if (draft.id) await useCompose.getState().deleteDraft(draft.id); }).catch((e) => notify(`Could not discard that draft: ${e}`))}>Discard draft</Button>
</div>
) : message.bodyPending ? (
pending === "error" ? (
<BodyMissing onRetry={() => void useMail.getState().hydrateThread()} />
) : (
<BodySkeleton />
)
) : (
<MessageContent accountId={accountId} message={message} plain={plain} surface={surface} quoted={quoted} />
)}
{message.quotedHtml ? (
<div className="msg-quoted">
<Pill tone="quiet" onClick={onQuoted}>
{quoted ? "Hide quoted text" : "··· Show quoted text"}
</Pill>
<Pill tone="quiet" onClick={onReply}>Quick reply</Pill>
</div>
) : null}
{message.invite ? (
<InviteCard invite={message.invite} messageId={message.id} accountId={accountId} />
) : null}
{message.attachments.length > 0 ? (
<div className="attachments">
{message.attachments.map((file) => (
<button
type="button"
className="attachment"
key={file.id}
data-phase={files[file.id] ?? "idle"}
disabled={files[file.id] === "opening"}
onClick={() => void openFile(file.id)}
>
<span className="ext">{fileKind(file.filename, file.mimeType)}</span>
{file.filename}
<span className="size">{fileSize(file.size)}</span>
</button>
))}
</div>
) : null}
</div>
) : null}
{notes.map((note) => (
<Note key={note.id} body={note.body} />
))}
</article>
</>
); );
} }
+11 -4
View File
@@ -1,3 +1,4 @@
import { MessageContent, MessageImageBanner } from "./MessageContent";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Avatar, Button, Confirm, EmptyState, Pill, Sheet, Toggle } from "../ui"; import { Avatar, Button, Confirm, EmptyState, Pill, Sheet, Toggle } from "../ui";
import { registerCommands, runCommand } from "../keys/commands"; import { registerCommands, runCommand } from "../keys/commands";
@@ -12,7 +13,7 @@ import {
useScreener, useScreener,
} from "../store/useScreener"; } from "../store/useScreener";
import { cap, displayName, isBrand } from "./format"; import { cap, displayName, isBrand } from "./format";
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody"; import { BodyMissing, BodySkeleton } from "./MessageBody";
import "./screener.css"; import "./screener.css";
/** /**
@@ -140,6 +141,7 @@ export function Screener() {
onFocus={() => focus(card.threadKey)} onFocus={() => focus(card.threadKey)}
onDecide={(destination) => void decide(card.threadKey, destination, false)} onDecide={(destination) => void decide(card.threadKey, destination, false)}
onElsewhere={() => setPicking(card.threadKey)} onElsewhere={() => setPicking(card.threadKey)}
onExpand={() => toggleExpanded(card.threadKey)}
/> />
))} ))}
@@ -195,15 +197,16 @@ interface CardProps {
onFocus: () => void; onFocus: () => void;
onDecide: (destination: Destination) => void; onDecide: (destination: Destination) => void;
onElsewhere: () => void; onElsewhere: () => void;
onExpand: () => void;
} }
function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }: CardProps) { function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere, onExpand }: CardProps) {
const view = useScreener((s) => s.views[card.threadKey]); const view = useScreener((s) => s.views[card.threadKey]);
const viewPhase = useScreener((s) => s.viewPhase[card.threadKey]); const viewPhase = useScreener((s) => s.viewPhase[card.threadKey]);
const retryView = useScreener((s) => s.retryView); const retryView = useScreener((s) => s.retryView);
const name = displayName(card.sender); const name = displayName(card.sender);
const suggested = destinationName(card.suggestion); const suggested = destinationName(card.suggestion);
const message = view?.messages.at(-1); const message = view?.messages.filter((m) => !m.draft).at(-1);
return ( return (
<article <article
@@ -226,6 +229,7 @@ function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }:
</div> </div>
<div className="screen-actions"> <div className="screen-actions">
<Button variant="ghost" onClick={onExpand}>{open ? "Collapse email" : "View entire email"}</Button>
<Button <Button
variant="primary" variant="primary"
keycap={cap("screen-yes")} keycap={cap("screen-yes")}
@@ -247,10 +251,13 @@ function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }:
</Button> </Button>
</div> </div>
{open && message ? <MessageImageBanner accountId={card.accountId} message={message} /> : null}
{open ? ( {open ? (
<div className="screen-message"> <div className="screen-message">
{message ? ( {message ? (
<MessageBody html={message.html} surface={message.surface} /> <>
<MessageContent accountId={card.accountId} message={message} quoted />
</>
) : viewPhase === "error" ? ( ) : viewPhase === "error" ? (
<BodyMissing onRetry={() => retryView(card.threadKey)} /> <BodyMissing onRetry={() => retryView(card.threadKey)} />
) : ( ) : (
+3
View File
@@ -403,3 +403,6 @@
:root[data-phone] .compose-subject { :root[data-phone] .compose-subject {
font-size: var(--t-5); font-size: var(--t-5);
} }
.draft-card { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--line); border-radius: 6px; margin: 8px 0; }
.draft-card > span { flex: 1; min-width: 0; }
+4 -1
View File
@@ -42,7 +42,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
height: 42px; min-height: 42px;
flex-wrap: wrap;
padding: 0 12px 0 14px; padding: 0 12px 0 14px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
@@ -400,3 +401,5 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.msg-quoted > .pill + .pill { margin-left: 8px; }
+98 -51
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { undoToken } from "../api/undo"; import { undoToken } from "../api/undo";
import { draftDelete, draftSave, outboxList, send, sendNow } from "../api/write"; import { draftDelete, draftGet, draftImport, draftList, draftSave, outboxList, send, sendNow } from "../api/write";
import { import {
live, live,
type Draft, type Draft,
@@ -84,6 +84,11 @@ interface Holding {
} }
interface ComposeState { interface ComposeState {
drafts: Draft[];
loadDrafts: () => Promise<void>;
resumeDraft: (draft: Draft) => void;
openDraft: (accountId: string, messageId: string) => Promise<void>;
deleteDraft: (id: string) => Promise<boolean>;
card: Composer | null; card: Composer | null;
/** The card, closed but not thrown away. */ /** The card, closed but not thrown away. */
parked: Composer | null; parked: Composer | null;
@@ -101,14 +106,14 @@ interface ComposeState {
/** `c`. The parked draft when there is one, a blank message when there is not. */ /** `c`. The parked draft when there is one, a blank message when there is not. */
compose: () => void; compose: () => void;
closeCard: () => void; closeCard: () => Promise<void>;
toggleExpanded: () => void; toggleExpanded: () => void;
/** `r`, `a` and `f`, from the pane. */ /** `r`, `a` and `f`, from the pane. */
answer: (thread: ThreadView, message: MessageView, kind: ComposerKind, all: boolean) => void; answer: (thread: ThreadView, message: MessageView, kind: ComposerKind, all: boolean) => void;
/** `a` again, on a box that is already open. */ /** `a` again, on a box that is already open. */
setAll: (all: boolean) => void; setAll: (all: boolean) => void;
closeReply: () => void; closeReply: () => Promise<void>;
edit: (at: ComposerAt, changes: Partial<Draft>) => void; edit: (at: ComposerAt, changes: Partial<Draft>) => void;
setShowCc: (at: ComposerAt, show: boolean) => void; setShowCc: (at: ComposerAt, show: boolean) => void;
@@ -218,6 +223,7 @@ const escapeHtml = (text: string): string =>
// ------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------
const saveTimers: Record<ComposerAt, number> = { card: 0, reply: 0 }; const saveTimers: Record<ComposerAt, number> = { card: 0, reply: 0 };
const saveRequests: Partial<Record<ComposerAt, Promise<void>>> = {};
function scheduleSave(at: ComposerAt): void { function scheduleSave(at: ComposerAt): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -285,6 +291,44 @@ async function newestOutgoing(): Promise<string | null> {
} }
export const useCompose = create<ComposeState>((set, get) => ({ export const useCompose = create<ComposeState>((set, get) => ({
drafts: [],
loadDrafts: async () => {
if (!live()) return;
try { set({ drafts: await draftList() }); } catch (e) { notify(`Could not load drafts: ${e}`); }
},
resumeDraft: (draft) => {
const current = get().card;
if (current?.draft.id === draft.id) return;
if (current) {
notify("Close the current composer before opening another draft");
return;
}
if (get().reply?.draft.id === draft.id) return;
set({ card: blank("card", "new", "Draft", draft), parked: null, expanded: false });
if (get().parkedReply?.draft.id === draft.id) set({ parkedReply: null, parkedReplyKey: null });
},
openDraft: async (accountId, messageId) => {
try {
const draft = await draftImport(accountId, messageId);
await get().loadDrafts();
get().resumeDraft(await draftGet(draft.id!));
} catch (e) { notify(`Could not open the draft: ${e}`); }
},
deleteDraft: async (id) => {
await Promise.all(Object.values(saveRequests));
try {
await draftDelete(id);
const state = get();
if (state.card?.draft.id === id) set({ card: null, expanded: false });
if (state.reply?.draft.id === id) set({ reply: null, replyKey: null });
if (state.parked?.draft.id === id) set({ parked: null });
if (state.parkedReply?.draft.id === id) set({ parkedReply: null, parkedReplyKey: null });
await get().loadDrafts();
void useMail.getState().load();
void useMail.getState().refreshThread();
return true;
} catch (e) { notify(`Could not discard that draft: ${e}`); return false; }
},
card: null, card: null,
parked: null, parked: null,
expanded: false, expanded: false,
@@ -321,17 +365,24 @@ export const useCompose = create<ComposeState>((set, get) => ({
}); });
}, },
closeCard: () => { closeCard: async () => {
await get().save("card");
const card = get().card; const card = get().card;
if (!card) return; if (!card || card.phase === "error") return;
cancelSave("card"); cancelSave("card");
set({ card: null, parked: card, expanded: false }); set({ card: null, parked: card, expanded: false });
void get().save("card").catch(() => {});
}, },
toggleExpanded: () => set((s) => (s.card ? { expanded: !s.expanded } : {})), toggleExpanded: () => set((s) => (s.card ? { expanded: !s.expanded } : {})),
answer: (thread, message, kind, all) => { answer: (thread, message, kind, all) => {
const active = get().reply;
if (active) {
if (get().replyKey === thread.key && active.kind === kind) { get().setAll(all); return; }
notify("Close the current reply before starting another reply or forward");
return;
}
if (!useSettings.getState().settings) void useSettings.getState().load(); if (!useSettings.getState().settings) void useSettings.getState().load();
// A box that was closed rather than sent comes back with what was in it, the same way the card // A box that was closed rather than sent comes back with what was in it, the same way the card
@@ -414,12 +465,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
}; };
}), }),
closeReply: () => { closeReply: async () => {
await get().save("reply");
const reply = get().reply; const reply = get().reply;
if (!reply) return; if (!reply || reply.phase === "error") return;
cancelSave("reply"); cancelSave("reply");
set({ reply: null, replyKey: null, parkedReply: reply, parkedReplyKey: get().replyKey }); set({ reply: null, replyKey: null, parkedReply: reply, parkedReplyKey: get().replyKey });
void get().save("reply").catch(() => {});
}, },
edit: (at, changes) => { edit: (at, changes) => {
@@ -505,58 +557,52 @@ export const useCompose = create<ComposeState>((set, get) => ({
remind: (at, atMs) => get().edit(at, { remindAtMs: atMs }), remind: (at, atMs) => get().edit(at, { remindAtMs: atMs }),
save: async (at) => { save: async (at) => {
const start = held(get(), at); const work = async () => {
if (!start || !live()) return; const start = held(get(), at);
cancelSave(at); if (!start || !live()) return;
const draft = start.composer.draft; cancelSave(at);
// Nothing has been written yet, so there is nothing to keep. A draft saved on open is a draft const draft = start.composer.draft;
// that shows up in the list the moment you press `c` and stays there when you change your mind. if (start.composer.pristine) return;
if (!draft.id && start.composer.pristine) return; set(start.write({ ...start.composer, phase: "saving" }));
set(start.write({ ...start.composer, phase: "saving" })); try {
try { const saved = await draftSave(draft);
const saved = await draftSave(draft); void get().loadDrafts();
const after = held(get(), at); const after = held(get(), at);
if (!after) return; if (!after || after.composer.draft.id !== draft.id || after.composer.draft.accountId !== draft.accountId || after.composer.draft.threadKey !== draft.threadKey) return;
// Something may have been typed while the save was in the air, and what came back is about const same = after.composer.draft === draft;
// the draft that went out. Only the id belongs to both. set(
const same = after.composer.draft === draft; after.write({
set( ...after.composer,
after.write({ phase: "idle",
...after.composer, draft: { ...after.composer.draft, id: saved.id },
phase: "idle", ...(same ? { encodedSize: saved.encodedSize, overLimit: saved.overLimit } : {}),
draft: { ...after.composer.draft, id: saved.id }, }),
...(same ? { encodedSize: saved.encodedSize, overLimit: saved.overLimit } : {}), );
}), } catch (e) {
); const after = held(get(), at);
} catch (e) { if (after) set(after.write({ ...after.composer, phase: "error" }));
const after = held(get(), at); notify(`Could not save the draft: ${e}`);
if (after) set(after.write({ ...after.composer, phase: "error" })); }
notify(`Could not save the draft: ${e}`); };
} const pending = (saveRequests[at] ?? Promise.resolve()).then(work);
saveRequests[at] = pending;
await pending;
if (saveRequests[at] === pending) delete saveRequests[at];
}, },
discard: async (at) => { discard: async (at) => {
cancelSave(at);
await saveRequests[at];
const composer = composerAt(get(), at); const composer = composerAt(get(), at);
if (!composer) return; if (!composer) return;
cancelSave(at); if (composer.draft.id) { await get().deleteDraft(composer.draft.id); return; }
if (at === "card") set({ card: null, parked: null, expanded: false }); if (at === "card") set({ card: null, parked: null, expanded: false });
else set({ reply: null, replyKey: null, parkedReply: null, parkedReplyKey: null }); else set({ reply: null, replyKey: null, parkedReply: null, parkedReplyKey: null });
const id = composer.draft.id;
if (!id) return;
// Rust asks the provider to forget the draft too before it answers, and the list's marker
// should not wait on Gmail for something that has already gone from this machine.
const threadKey = composer.draft.threadKey ?? null;
if (threadKey) useMail.getState().patch([threadKey], { hasDraft: false });
try {
await draftDelete(id);
void useMail.getState().load();
} catch (e) {
if (threadKey) useMail.getState().patch([threadKey], { hasDraft: true });
notify(`Could not discard that draft: ${e}`);
}
}, },
post: async (at, now) => { post: async (at, now) => {
cancelSave(at);
await saveRequests[at];
const composer = composerAt(get(), at); const composer = composerAt(get(), at);
if (!composer) return; if (!composer) return;
if (composer.draft.to.length === 0) { if (composer.draft.to.length === 0) {
@@ -585,6 +631,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
let undo: Undo; let undo: Undo;
try { try {
undo = await send(draft); undo = await send(draft);
void get().loadDrafts();
} catch (e) { } catch (e) {
if (threadKey) useMail.getState().patch([threadKey], { sending: false, hasDraft: true }); if (threadKey) useMail.getState().patch([threadKey], { sending: false, hasDraft: true });
// Back on screen, not parked: after "That did not go through" the message should be right // Back on screen, not parked: after "That did not go through" the message should be right
+94
View File
@@ -0,0 +1,94 @@
import { create } from "zustand";
import { messageImagesSet, messageShowImages } from "../api/messages";
import type { MessageView } from "../ipc";
import { notify } from "./useToast";
type ImageContent = Pick<MessageView, "html" | "quotedHtml" | "trackers" | "blockedImages">;
interface ImageEntry {
allowed: boolean;
phase: "idle" | "loading" | "error";
html: string;
quotedHtml: string | null;
content: ImageContent | null;
}
interface ImageState {
entries: Record<string, ImageEntry>;
ensure: (accountId: string, message: MessageView) => void;
load: (accountId: string, message: MessageView) => Promise<void>;
allow: (accountId: string, message: MessageView, allowed: boolean) => Promise<void>;
}
export const imageKey = (accountId: string, messageId: string): string => `${accountId}:${messageId}`;
const preferenceWrites = new Map<string, Promise<unknown>>();
export const useImages = create<ImageState>((set, get) => ({
entries: {},
ensure: (accountId, message) => {
const key = imageKey(accountId, message.id);
const prior = get().entries[key];
if (prior?.html === message.html && prior.quotedHtml === message.quotedHtml) return;
const entry: ImageEntry = {
allowed: prior?.allowed ?? message.imagesAllowed ?? false,
phase: "idle",
html: message.html,
quotedHtml: message.quotedHtml,
content: null,
};
set((s) => ({ entries: { ...s.entries, [key]: entry } }));
if (entry.allowed && !message.bodyPending) void get().load(accountId, message);
},
load: async (accountId, message) => {
const key = imageKey(accountId, message.id);
const pending = preferenceWrites.get(key);
if (pending) {
try {
await pending;
} catch {
return;
}
if (preferenceWrites.has(key) && preferenceWrites.get(key) !== pending) return;
}
const entry = get().entries[key];
if (!entry || !entry.allowed || entry.phase === "loading" || message.bodyPending) return;
const loading = { ...entry, phase: "loading" as const };
set((s) => ({ entries: { ...s.entries, [key]: loading } }));
try {
const view = await messageShowImages(accountId, message.id);
if (get().entries[key] !== loading) return;
const content = { html: view.html, quotedHtml: view.quotedHtml, trackers: view.trackers, blockedImages: view.blockedImages };
set((s) => ({ entries: { ...s.entries, [key]: { ...loading, phase: "idle", content } } }));
} catch (e) {
if (get().entries[key] !== loading) return;
set((s) => ({ entries: { ...s.entries, [key]: { ...loading, phase: "error" } } }));
notify(`Could not load images: ${e}`);
}
},
allow: async (accountId, message, allowed) => {
const key = imageKey(accountId, message.id);
const prior = get().entries[key] ?? {
allowed: message.imagesAllowed ?? false,
phase: "idle" as const,
html: message.html,
quotedHtml: message.quotedHtml,
content: null,
};
const next = { ...prior, allowed, phase: "idle" as const };
set((s) => ({ entries: { ...s.entries, [key]: next } }));
const write = (preferenceWrites.get(key) ?? Promise.resolve())
.catch(() => {})
.then(() => messageImagesSet(accountId, message.id, allowed));
preferenceWrites.set(key, write);
try {
await write;
if (get().entries[key] !== next) return;
if (allowed && !next.content) await get().load(accountId, message);
} catch (e) {
if (get().entries[key] === next) set((s) => ({ entries: { ...s.entries, [key]: { ...prior, phase: prior.allowed && !prior.content ? "error" : "idle" } } }));
notify(`Could not save the image preference: ${e}`);
} finally {
if (preferenceWrites.get(key) === write) preferenceWrites.delete(key);
}
},
}));
+3
View File
@@ -4,6 +4,7 @@ import { threadHydrate, threadView } from "../api/threads";
import { undoLast } from "../api/undo"; import { undoLast } from "../api/undo";
import { live, type Destination, type ScreenerCard, type ThreadView } from "../ipc"; import { live, type Destination, type ScreenerCard, type ThreadView } from "../ipc";
import { acknowledge } from "../screens/triage"; import { acknowledge } from "../screens/triage";
import { useMail } from "./useMail";
import { notify } from "./useToast"; import { notify } from "./useToast";
/** /**
@@ -202,6 +203,7 @@ export const useScreener = create<ScreenerState>((set, get) => ({
expanded: s.expanded === key ? null : s.expanded, expanded: s.expanded === key ? null : s.expanded,
}; };
}); });
if (get().cards.length === 0 && useMail.getState().place === "screener") useMail.getState().goTo("inbox");
acknowledge(undo, () => void get().load(card.accountId)); acknowledge(undo, () => void get().load(card.accountId));
} catch (e) { } catch (e) {
set((s) => ({ deciding: s.deciding.filter((k) => k !== key) })); set((s) => ({ deciding: s.deciding.filter((k) => k !== key) }));
@@ -215,6 +217,7 @@ export const useScreener = create<ScreenerState>((set, get) => ({
set({ cards: [], focused: null, expanded: null }); set({ cards: [], focused: null, expanded: null });
try { try {
acknowledge(await screenerClearAll(accountId), () => void get().load(accountId)); acknowledge(await screenerClearAll(accountId), () => void get().load(accountId));
if (useMail.getState().place === "screener") useMail.getState().goTo("inbox");
} catch (e) { } catch (e) {
set({ cards }); set({ cards });
notify(`That did not go through: ${e}`); notify(`That did not go through: ${e}`);