mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-04 20:17:05 +00:00
some fixes
This commit is contained in:
1 parent
660aef323b
commit
7274a54a6e
29 files changed
+860
-315
No files matched your search
@@ -125,3 +125,17 @@ hr {
|
||||
border: 0;
|
||||
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;
|
||||
}
|
||||
@@ -392,28 +392,22 @@ fn free_path(dir: &Path, filename: &str) -> PathBuf {
|
||||
// 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]
|
||||
pub async fn message_show_images(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
message_id: String,
|
||||
) -> Result<MessageView, String> {
|
||||
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 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)?
|
||||
.ok_or("that message has not been fetched yet")?;
|
||||
Ok((raw, sync::hydrate::render_options(conn)?))
|
||||
Ok((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| {
|
||||
read::message_view(conn, &account_id, &message_id, &options.own_addresses)
|
||||
})?;
|
||||
if !view.images_allowed {
|
||||
return Ok(view);
|
||||
}
|
||||
view.html = rendered.html;
|
||||
view.quoted_html = rendered.quoted_html;
|
||||
view.trackers = rendered.trackers;
|
||||
@@ -430,6 +427,29 @@ pub async fn message_show_images(
|
||||
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
|
||||
/// answer to a hundred megabyte video is a sentence rather than a spinner.
|
||||
#[tauri::command]
|
||||
|
||||
+192
-29
@@ -20,10 +20,11 @@
|
||||
// the queue is the `drafts` table, which already has the two columns it needs.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::decisions::db_of;
|
||||
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))? {
|
||||
let subject = held.draft.subject.clone();
|
||||
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 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.
|
||||
fn uploading() -> &'static Mutex<HashSet<String>> {
|
||||
static UPLOADING: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
UPLOADING.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
fn uploading() -> &'static tokio::sync::Mutex<()> {
|
||||
static UPLOADING: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
UPLOADING.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// open the composer again.
|
||||
pub async fn upload_pending(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
|
||||
if !uploading()
|
||||
.lock()
|
||||
.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
|
||||
let _guard = uploading().lock().await;
|
||||
upload_now(app, account_id).await
|
||||
}
|
||||
|
||||
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;
|
||||
let _ = upload_pending(&handle, &account_id).await;
|
||||
});
|
||||
crate::emit_store_changed(&app, "drafts");
|
||||
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)]
|
||||
pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
|
||||
let db = db_of(&app)?;
|
||||
@@ -443,23 +573,56 @@ pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn draft_delete(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
let _guard = uploading().lock().await;
|
||||
let db = db_of(&app)?;
|
||||
for (account_id, _) in sync::accounts(db.inner(), None) {
|
||||
let held = db.with(&account_id, |conn| stored(conn, &id))?;
|
||||
if held.is_none() {
|
||||
let Some((_, provider_id, _)) = db.with(&account_id, |conn| stored(conn, &id))? else {
|
||||
continue;
|
||||
}
|
||||
let provider_draft_id = db.with(&account_id, |conn| delete(conn, &id))?;
|
||||
if let Some(provider_draft_id) = provider_draft_id {
|
||||
// The local row has gone either way. A provider that will not take the delete leaves a
|
||||
// draft in the mailbox's own Drafts, which is visible and fixable, and refusing the
|
||||
// command over it would leave the one on this machine that the person asked to be rid
|
||||
// of.
|
||||
if let Some(provider) = sync::remote_for(&account_id) {
|
||||
let _ = provider.draft_delete(&provider_draft_id).await;
|
||||
};
|
||||
let mut message_id = None;
|
||||
if let Some(provider_id) = provider_id {
|
||||
if crate::accounts::list(&app)?
|
||||
.iter()
|
||||
.any(|a| a.id == account_id && a.kind == crate::dto::AccountKind::Google)
|
||||
{
|
||||
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;
|
||||
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(());
|
||||
}
|
||||
Err("that draft is not on this device".to_string())
|
||||
|
||||
@@ -441,6 +441,8 @@ pub struct MessageView {
|
||||
pub trackers: Vec<Tracker>,
|
||||
/// Ordinary remote images that were blocked, which is a different count from the trackers.
|
||||
pub blocked_images: u32,
|
||||
#[serde(default)]
|
||||
pub images_allowed: bool,
|
||||
/// Whether the body currently rendered has remote images loaded.
|
||||
pub images_loaded: bool,
|
||||
pub seen: bool,
|
||||
|
||||
@@ -126,6 +126,7 @@ pub enum Call {
|
||||
BatchModify,
|
||||
MessagesModify,
|
||||
MessagesSend,
|
||||
DraftsList,
|
||||
DraftsCreate,
|
||||
DraftsUpdate,
|
||||
DraftsDelete,
|
||||
@@ -143,7 +144,7 @@ impl Call {
|
||||
Call::MessagesGet | Call::AttachmentsGet => 20,
|
||||
Call::DraftsUpdate => 15,
|
||||
Call::DraftsCreate | Call::DraftsDelete => 10,
|
||||
Call::MessagesList | Call::MessagesModify => 5,
|
||||
Call::MessagesList | Call::MessagesModify | Call::DraftsList => 5,
|
||||
Call::HistoryList => 2,
|
||||
Call::LabelsList | Call::SendAsList | Call::GetProfile => 1,
|
||||
}
|
||||
@@ -166,6 +167,7 @@ impl Call {
|
||||
Call::BatchModify => "Gmail label change",
|
||||
Call::MessagesModify => "Gmail label change",
|
||||
Call::MessagesSend => "Gmail send",
|
||||
Call::DraftsList => "Gmail draft list",
|
||||
Call::DraftsCreate => "Gmail draft create",
|
||||
Call::DraftsUpdate => "Gmail draft update",
|
||||
Call::DraftsDelete => "Gmail draft delete",
|
||||
@@ -1183,6 +1185,30 @@ pub async fn messages_send(
|
||||
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"), ¶ms))
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
read_json(resp, Call::DraftsList.name(), Call::DraftsList.scope()).await
|
||||
}
|
||||
|
||||
pub async fn drafts_create(
|
||||
access_token: &str,
|
||||
raw: &[u8],
|
||||
|
||||
@@ -403,6 +403,7 @@ pub fn run() {
|
||||
imap::imap_trust_cert,
|
||||
imap::imap_forget_cert,
|
||||
attachments::message_show_images,
|
||||
attachments::message_images_set,
|
||||
attachments::attachment_data_url,
|
||||
attachments::attachment_save,
|
||||
attachments::attachment_open,
|
||||
@@ -451,6 +452,8 @@ pub fn run() {
|
||||
// Writing
|
||||
drafts::draft_save,
|
||||
drafts::draft_get,
|
||||
drafts::draft_list,
|
||||
drafts::draft_import,
|
||||
drafts::draft_delete,
|
||||
send::send,
|
||||
send::send_now,
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::dto::{Invite, Person, Surface, Tracker, Unsubscribe};
|
||||
|
||||
pub const RENDER_VERSION: i32 = 2;
|
||||
pub const RENDER_VERSION: i32 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RenderedAttachment {
|
||||
|
||||
@@ -43,6 +43,15 @@ pub fn render(raw: &[u8], options: &RenderOptions) -> Result<Rendered, String> {
|
||||
let inline_parts = inline_parts(&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 {
|
||||
quoted::split_html(&source)
|
||||
} else {
|
||||
|
||||
@@ -153,7 +153,7 @@ fn place_clause(
|
||||
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)"
|
||||
.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::Spam => "t.spam = 1".to_string(),
|
||||
Place::Trash => "t.trashed = 1".to_string(),
|
||||
@@ -305,7 +305,7 @@ const COLUMNS: &str = "\
|
||||
t.trashed AS trashed,
|
||||
t.spam AS spam,
|
||||
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,
|
||||
COALESCE(sn.return_at, rt.due_ms) AS snoozed_until,
|
||||
COALESCE(tf.ignored, 0) AS ignored,
|
||||
@@ -557,8 +557,8 @@ const VIEW_SQL: &str = "\
|
||||
COALESCE(b.blocked_images, 0) AS blocked_images,
|
||||
(SELECT json_group_array(json_object(
|
||||
'id', a.id, 'messageId', a.message_id, 'filename', a.filename,
|
||||
'mimeType', a.mime_type, 'size', a.size, 'inline', a.inline = 1,
|
||||
'contentId', a.content_id, 'cached', a.cached_path IS NOT NULL))
|
||||
'mimeType', a.mime_type, 'size', a.size, 'inline', json(CASE WHEN a.inline = 1 THEN 'true' ELSE 'false' END),
|
||||
'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,
|
||||
(SELECT name FROM state.renames WHERE thread_key = ?) AS rename,
|
||||
(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")?)
|
||||
.unwrap_or_default(),
|
||||
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
|
||||
// images already inlined by the caller that asked for them.
|
||||
images_loaded: false,
|
||||
@@ -677,7 +678,11 @@ pub fn thread_view(
|
||||
let mut participants: Vec<Person> = Vec::new();
|
||||
let mut labels: Vec<String> = 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() {
|
||||
if !labels.contains(&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())
|
||||
}
|
||||
|
||||
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
|
||||
/// statement is what keeps a message looking the same whichever command handed it over.
|
||||
pub fn message_view(
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::dto::Tracker;
|
||||
const STYLE_PROPERTIES: &[&str] = &[
|
||||
"color",
|
||||
"background-color",
|
||||
"background",
|
||||
"font",
|
||||
"font-family",
|
||||
"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.
|
||||
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])] = &[
|
||||
("table", &["bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]),
|
||||
("table", &["background", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]),
|
||||
("thead", &["bgcolor", "valign"]),
|
||||
("tbody", &["bgcolor", "valign"]),
|
||||
("tfoot", &["align", "bgcolor", "valign"]),
|
||||
("tr", &["bgcolor", "height", "valign"]),
|
||||
("td", &["bgcolor", "height", "nowrap", "valign", "width"]),
|
||||
("th", &["bgcolor", "height", "nowrap", "valign", "width"]),
|
||||
("td", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
|
||||
("th", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
|
||||
("font", &["color", "face", "size"]),
|
||||
("img", &["border", "hspace", "vspace"]),
|
||||
("a", &["name"]),
|
||||
@@ -174,7 +173,8 @@ pub fn clean(
|
||||
remote_images: &HashMap<String, Vec<u8>>,
|
||||
policy: Policy,
|
||||
) -> 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 filter_log = Arc::clone(&log);
|
||||
@@ -194,7 +194,7 @@ pub fn clean(
|
||||
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
|
||||
.lock()
|
||||
@@ -234,7 +234,7 @@ fn filter<'u>(
|
||||
};
|
||||
|
||||
match (element, attribute) {
|
||||
("img", "src") => match plans.get(value.trim()) {
|
||||
("img", "src") | (_, "background") => match plans.get(value.trim()) {
|
||||
Some(ImagePlan::Inline {
|
||||
data_url,
|
||||
content_id,
|
||||
@@ -282,10 +282,19 @@ fn plan_images(
|
||||
) -> HashMap<String, ImagePlan> {
|
||||
let mut plans = HashMap::new();
|
||||
for tag in scan(source) {
|
||||
if tag.closing || tag.name != "img" {
|
||||
if tag.closing {
|
||||
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;
|
||||
};
|
||||
if let Some(plan) = plan_one(src, &tag, inline_parts, remote_images, policy) {
|
||||
@@ -295,6 +304,54 @@ fn plan_images(
|
||||
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(
|
||||
src: &str,
|
||||
tag: &Tag,
|
||||
@@ -304,8 +361,8 @@ fn plan_one(
|
||||
) -> Option<ImagePlan> {
|
||||
let lowercase = src.to_ascii_lowercase();
|
||||
|
||||
if let Some(reference) = lowercase.strip_prefix("cid:") {
|
||||
let key = decode_entities(reference)
|
||||
if lowercase.starts_with("cid:") {
|
||||
let key = decode_entities(&src[4..])
|
||||
.trim_matches(|character| character == '<' || character == '>')
|
||||
.to_string();
|
||||
let part = inline_parts.get(&key)?;
|
||||
|
||||
@@ -289,7 +289,7 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
|
||||
"style" => {
|
||||
let style = declarations(value)
|
||||
.filter(|(property, _)| match *property {
|
||||
"background-color" => !drop_background,
|
||||
"background-color" | "background" => !drop_background,
|
||||
"color" => !drop_foreground,
|
||||
_ => true,
|
||||
})
|
||||
@@ -323,6 +323,11 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
|
||||
|
||||
fn background_of(tag: &Tag) -> Option<Rgba> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -441,7 +446,10 @@ const NAMED: &[(&str, u32)] = &[
|
||||
];
|
||||
|
||||
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() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
FROM messages m
|
||||
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",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useEscapeLayer } from "./escape";
|
||||
import { registerCommands } from "./keys/commands";
|
||||
import { setAccountSwitch, useKeyContext, useKeymap } from "./keys/keymap";
|
||||
import { handleMenuAction } from "./keys/menu";
|
||||
import { useCompose } from "./store/useCompose";
|
||||
import { useAccounts } from "./store/useAccounts";
|
||||
import { useMail } from "./store/useMail";
|
||||
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
|
||||
// list, and joins whatever query is already pending.
|
||||
if (scopes.some((scope) => scope.startsWith("thread:"))) reloadList();
|
||||
if (scopes.includes("drafts")) void useCompose.getState().loadDrafts();
|
||||
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 list loaded and it has to be asked for by name.
|
||||
|
||||
+5
-2
@@ -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
|
||||
* own and the sender learns nothing but that somebody asked once.
|
||||
*/
|
||||
export const messageShowImages = (messageId: string) =>
|
||||
call<MessageView>("message_show_images", { messageId });
|
||||
export const messageShowImages = (accountId: string, messageId: string) =>
|
||||
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. */
|
||||
export const attachmentDataUrl = (attachmentId: string) =>
|
||||
|
||||
@@ -36,3 +36,6 @@ export const unsubscribe = (
|
||||
alsoTrash: boolean,
|
||||
alsoScreenOut: boolean,
|
||||
) => 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 });
|
||||
@@ -291,6 +291,7 @@ export interface MessageView {
|
||||
trackers: Tracker[];
|
||||
blockedImages: number;
|
||||
imagesLoaded: boolean;
|
||||
imagesAllowed?: boolean;
|
||||
seen: boolean;
|
||||
draft: boolean;
|
||||
sentByMe: boolean;
|
||||
|
||||
+42
-7
@@ -10,12 +10,12 @@ import {
|
||||
} from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
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 { registerCommands } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
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 { useCompose, type Composer, type ComposerAt } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
@@ -179,11 +179,14 @@ export function Compose() {
|
||||
const compose = useCompose((s) => s.compose);
|
||||
const closeCard = useCompose((s) => s.closeCard);
|
||||
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:
|
||||
// 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.
|
||||
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
|
||||
// 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() });
|
||||
}, [holding]);
|
||||
|
||||
useEscapeLayer(card !== null, closeCard);
|
||||
useEscapeLayer(card !== null && !closing, requestClose);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloseDraft at="card" open={closing} onClose={() => setClosing(false)} />
|
||||
<ComposeCard
|
||||
composer={card}
|
||||
expanded={expanded}
|
||||
onClose={closeCard}
|
||||
onClose={requestClose}
|
||||
onExpand={toggleExpanded}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,14 +238,14 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
|
||||
className="compose"
|
||||
data-expanded={expanded ? "" : undefined}
|
||||
role="dialog"
|
||||
aria-label={CARD_TITLE}
|
||||
aria-label={draft.id ? "Draft" : CARD_TITLE}
|
||||
ref={frame.ref}
|
||||
onDragOver={allowDrop}
|
||||
onDrop={(e) => dropped(e, "card")}
|
||||
onPaste={(e) => pasted(e, "card")}
|
||||
>
|
||||
<div className="compose-head">
|
||||
<h2>{CARD_TITLE}</h2>
|
||||
<h2>{draft.id ? "Draft" : CARD_TITLE}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
@@ -695,6 +701,7 @@ export function ComposeFoot({ at, composer, onSend, extra }: ComposeFootProps) {
|
||||
label="Attach a file"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -717,6 +724,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
||||
const setAll = useCompose((s) => s.setAll);
|
||||
const setShowCc = useCompose((s) => s.setShowCc);
|
||||
const closeReply = useCompose((s) => s.closeReply);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const editor = useRef<TiptapEditor | null>(null);
|
||||
const draft = composer.draft;
|
||||
|
||||
@@ -737,6 +745,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
||||
onDrop={(e) => dropped(e, "reply")}
|
||||
onPaste={(e) => pasted(e, "reply")}
|
||||
>
|
||||
<CloseDraft at="reply" open={closing} onClose={() => setClosing(false)} />
|
||||
<div className="reply-head">
|
||||
<span className="reply-who">
|
||||
{forwarding ? "Forward" : "Reply to "}
|
||||
@@ -758,7 +767,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
||||
className="reply-close"
|
||||
title="Close, keeping the draft"
|
||||
aria-label="Close, keeping the draft"
|
||||
onClick={closeReply}
|
||||
onClick={() => composer.pristine ? closeReply() : setClosing(true)}
|
||||
>
|
||||
<Icon d={icons.CLOSE} size={12} />
|
||||
</button>
|
||||
@@ -839,3 +848,29 @@ export function SendingLine({ threadKey }: { threadKey: string }) {
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageContent, MessageImageBanner } from "./MessageContent";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Banner, Button, EmptyState, icons } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
@@ -8,7 +9,7 @@ import { useContacts } from "../store/useContacts";
|
||||
import { useFeed } from "../store/useFeed";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { displayName, isBrand, messageTime } from "./format";
|
||||
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody";
|
||||
import { BodyMissing, BodySkeleton } from "./MessageBody";
|
||||
import * as triage from "./triage";
|
||||
import "./feed.css";
|
||||
|
||||
@@ -261,6 +262,7 @@ function Card({ thread, view, focused, onFocus, onToggle, hold }: CardProps) {
|
||||
data-selected={focused ? "" : undefined}
|
||||
onClick={onFocus}
|
||||
>
|
||||
{message ? <MessageImageBanner accountId={thread.accountId} message={message} /> : null}
|
||||
<div className="feed-head">
|
||||
<Avatar
|
||||
name={displayName(thread.from)}
|
||||
@@ -285,7 +287,7 @@ function Card({ thread, view, focused, onFocus, onToggle, hold }: CardProps) {
|
||||
data-open={expanded ? "" : 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}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useCompose } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { notify } from "../store/useToast";
|
||||
import { useStage } from "../store/useStage";
|
||||
import { cap, displayName, isBrand, messageTime } from "./format";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
import { MessageContent, MessageImageBanner } from "./MessageContent";
|
||||
import "./focus.css";
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,7 @@ export function FocusReply() {
|
||||
if (!view || !last || !body) return;
|
||||
|
||||
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.edit("reply", {
|
||||
bodyHtml: body
|
||||
@@ -105,7 +107,7 @@ export function FocusReply() {
|
||||
.map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
|
||||
.join(""),
|
||||
});
|
||||
const to = compose.reply?.draft.to[0];
|
||||
const to = useCompose.getState().reply?.draft.to[0];
|
||||
void compose.post("reply", now);
|
||||
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
|
||||
step(1);
|
||||
@@ -160,6 +162,15 @@ export function FocusReply() {
|
||||
if (key) boxes.current.get(key)?.focus();
|
||||
}, [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) {
|
||||
return (
|
||||
<main className="stage focus">
|
||||
@@ -279,6 +290,7 @@ function Item({
|
||||
<article className="focus-item" data-active={active ? "" : undefined} onClick={onFocus}>
|
||||
<div className="focus-message">
|
||||
<h2 className="focus-subject">{thread.subject}</h2>
|
||||
{message ? <MessageImageBanner accountId={thread.accountId} message={message} /> : null}
|
||||
<div className="focus-from">
|
||||
<Avatar name={name} address={from.address} brand={isBrand(from)} />
|
||||
<div className="focus-who">
|
||||
@@ -292,7 +304,7 @@ function Item({
|
||||
</div>
|
||||
<div className="focus-body">
|
||||
{message ? (
|
||||
<MessageBody html={message.html} surface={message.surface} />
|
||||
<MessageContent accountId={thread.accountId} message={message} />
|
||||
) : (
|
||||
<p className="focus-snippet">{thread.snippet}</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, EmptyState, GroupHead, icons, Pill, Row } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { GROUPS, type Place, type ThreadSummary } from "../ipc";
|
||||
import { useCompose } from "../store/useCompose";
|
||||
import { DraftCard } from "./Compose";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
@@ -187,6 +189,8 @@ export function ListColumn() {
|
||||
const waiting = useScreener((s) => s.cards.length);
|
||||
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 searching = place === "search";
|
||||
const searchOpen = searchPhase !== "off";
|
||||
@@ -390,7 +394,7 @@ export function ListColumn() {
|
||||
address={entry.thread.from.address}
|
||||
brand={isBrand(entry.thread.from)}
|
||||
time={timeOf(entry.thread, place)}
|
||||
subject={entry.thread.subject}
|
||||
subject={entry.thread.hasDraft ? `Draft · ${entry.thread.subject}` : entry.thread.subject}
|
||||
snippet={entry.thread.snippet}
|
||||
count={entry.thread.messageCount}
|
||||
note={entry.thread.note ?? undefined}
|
||||
@@ -438,12 +442,13 @@ export function ListColumn() {
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="list list-blank">
|
||||
{phase === "loading" ? null : fill && !searching ? (
|
||||
<Filling fill={fill} />
|
||||
) : (
|
||||
<EmptyState>{EMPTY[place] ?? "Nothing here"}</EmptyState>
|
||||
<EmptyState>{place === "drafts" && drafts.length > 0 ? "" : EMPTY[place] ?? "Nothing here"}</EmptyState>
|
||||
)}
|
||||
{searching ? searchFoot() : null}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { isTauri, type Surface } from "../ipc";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { notify } from "../store/useToast";
|
||||
import { Pill } from "../ui";
|
||||
import { Button, Pill, Sheet } from "../ui";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}}`;
|
||||
}
|
||||
|
||||
export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps) {
|
||||
export function MessageBody({ html, plain, surface = "theme", imagesAllowed }: MessageBodyProps) {
|
||||
const frame = useRef<HTMLIFrameElement | null>(null);
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const allowImages = imagesAllowed ?? false;
|
||||
const theme = useTheme((s) => s.theme);
|
||||
const [css, setCss] = useState<string | null>(sheetText);
|
||||
|
||||
@@ -177,8 +180,8 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
|
||||
() =>
|
||||
css === null
|
||||
? ""
|
||||
: `<!doctype html><html><head><meta charset="utf-8"><style>${tokens}\n${css}</style></head><body${plain ? " data-plain" : ""}>${html}</body></html>`,
|
||||
[css, html, plain, tokens],
|
||||
: `<!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, surface, allowImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,8 +199,7 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
if (isTauri) void openUrl(href).catch((e) => notify(`Could not open that link: ${String(e)}`));
|
||||
else window.open(href, "_blank", "noopener,noreferrer");
|
||||
setLink(href);
|
||||
};
|
||||
|
||||
/** Whether there was a document there to take. */
|
||||
@@ -256,6 +258,17 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
|
||||
}, [srcdoc]);
|
||||
|
||||
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
|
||||
className="msg-frame"
|
||||
ref={frame}
|
||||
@@ -266,6 +279,7 @@ export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={srcdoc}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
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 { attachmentOpen, messageShowImages } from "../api/messages";
|
||||
import { attachmentOpen } from "../api/messages";
|
||||
import { draftImport } from "../api/write";
|
||||
import { noteAdd } from "../api/notes";
|
||||
import { threadMerge, threadRename, threadUnmerge } from "../api/threads";
|
||||
import { undoToken } from "../api/undo";
|
||||
@@ -12,14 +13,14 @@ import { useCompose, type ComposerKind } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useSelection } from "../store/useSelection";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { useSnooze } from "../store/useSnooze";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { notify } from "../store/useToast";
|
||||
import { LabelPicker, type PickerMode } from "./ActionBar";
|
||||
import { ReplyBox, SendingLine } from "./Compose";
|
||||
import { DraftCard, ReplyBox, SendingLine } from "./Compose";
|
||||
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 { SnoozePicker, snoozeAnchor } from "./SnoozePicker";
|
||||
import * as triage from "./triage";
|
||||
@@ -135,17 +136,9 @@ interface VerbSpec {
|
||||
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[] = [
|
||||
{ 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: "set-aside", label: "Set aside", icon: icons.SET_ASIDE },
|
||||
{ command: "snooze", label: "Snooze", icon: icons.SNOOZE },
|
||||
@@ -158,6 +151,8 @@ function PaneBar({ notify }: { notify: boolean }) {
|
||||
const place = useMail((s) => s.place);
|
||||
const lead = place === "paper-trail" ? TRAIL_LEAD : LEAD;
|
||||
const [more, setMore] = useState(false);
|
||||
const [trashing, setTrashing] = useState(false);
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
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
|
||||
@@ -169,6 +164,10 @@ function PaneBar({ notify }: { notify: boolean }) {
|
||||
{lead.map((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" />
|
||||
{place === "paper-trail" ? (
|
||||
<Verb command="move" label="Move to Inbox" icon={icons.INBOX} />
|
||||
@@ -266,15 +265,6 @@ export function ReadingPane() {
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const [quoted, setQuoted] = useState<string[]>([]);
|
||||
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. */
|
||||
const [unmerging, setUnmerging] = useState<"idle" | "working">("idle");
|
||||
const [picker, setPicker] = useState<PickerMode | null>(null);
|
||||
@@ -285,12 +275,13 @@ export function ReadingPane() {
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const replyKey = useCompose((s) => s.replyKey);
|
||||
const drafts = useCompose((s) => s.drafts);
|
||||
|
||||
const isMe = useIsMe();
|
||||
|
||||
const messages = useMemo(
|
||||
() => (thread?.messages ?? []).map((m) => shown[m.id] ?? m),
|
||||
[thread, shown],
|
||||
() => thread?.messages ?? [],
|
||||
[thread],
|
||||
);
|
||||
|
||||
// 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] : []);
|
||||
setQuoted([]);
|
||||
setFocus(null);
|
||||
setShown({});
|
||||
setImages("idle");
|
||||
imagesRequest.current += 1;
|
||||
setUnmerging("idle");
|
||||
// The failed slots were the last thread's, and nothing in `open` knows about them.
|
||||
useMail.getState().clearBodyPhase();
|
||||
@@ -331,16 +319,16 @@ export function ReadingPane() {
|
||||
if (!thread) return;
|
||||
const answer = (kind: ComposerKind, all: boolean) => {
|
||||
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;
|
||||
useCompose.getState().answer(view, last, kind, all);
|
||||
};
|
||||
return registerCommands({
|
||||
reply: () => answer("reply", useSettings.getState().settings?.replyAllDefault ?? false),
|
||||
reply: () => answer("reply", false),
|
||||
"reply-all": () => {
|
||||
const compose = useCompose.getState();
|
||||
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);
|
||||
},
|
||||
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 () => {
|
||||
if (unmerging === "working") return;
|
||||
setUnmerging("working");
|
||||
@@ -544,42 +506,6 @@ export function ReadingPane() {
|
||||
</div>
|
||||
) : 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) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
@@ -595,6 +521,7 @@ export function ReadingPane() {
|
||||
setFocus(index);
|
||||
toggle(message.id);
|
||||
}}
|
||||
onReply={() => useCompose.getState().answer(thread, message, "reply", false)}
|
||||
onQuoted={() =>
|
||||
setQuoted((was) =>
|
||||
was.includes(message.id)
|
||||
@@ -612,6 +539,7 @@ export function ReadingPane() {
|
||||
<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} />
|
||||
|
||||
{/* 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;
|
||||
onToggle: () => void;
|
||||
onQuoted: () => void;
|
||||
onReply: () => void;
|
||||
notes: { id: string; body: string }[];
|
||||
}
|
||||
|
||||
@@ -826,6 +755,7 @@ function Message({
|
||||
toYou,
|
||||
onToggle,
|
||||
onQuoted,
|
||||
onReply,
|
||||
notes,
|
||||
}: MessageProps) {
|
||||
const who = me ? "You" : displayName(message.from);
|
||||
@@ -857,89 +787,98 @@ function Message({
|
||||
setOverride(next);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className="msg"
|
||||
data-message={message.id}
|
||||
data-collapsed={open ? undefined : ""}
|
||||
data-focus={focused ? "" : undefined}
|
||||
>
|
||||
<div className="msg-head" onClick={open ? undefined : onToggle}>
|
||||
{/* 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. */}
|
||||
<Avatar
|
||||
name={displayName(message.from)}
|
||||
address={message.from.address}
|
||||
brand={isBrand(message.from)}
|
||||
/>
|
||||
<div className="msg-who">
|
||||
<div className="msg-name">
|
||||
{who}
|
||||
{open && !me ? <span className="addr">{message.from.address}</span> : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="msg-to">{toYou ? "to you" : `to ${message.to.map(displayName).join(", ")}`}</div>
|
||||
) : (
|
||||
<div className="msg-preview">{previewOf(message.html)}</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()} />
|
||||
<>
|
||||
{open && !message.draft && !message.bodyPending ? <MessageImageBanner accountId={accountId} message={message} /> : null}
|
||||
<article
|
||||
className="msg"
|
||||
data-message={message.id}
|
||||
data-collapsed={open ? undefined : ""}
|
||||
data-focus={focused ? "" : undefined}
|
||||
>
|
||||
<div className="msg-head" onClick={open ? undefined : onToggle}>
|
||||
{/* 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. */}
|
||||
<Avatar
|
||||
name={displayName(message.from)}
|
||||
address={message.from.address}
|
||||
brand={isBrand(message.from)}
|
||||
/>
|
||||
<div className="msg-who">
|
||||
<div className="msg-name">
|
||||
{message.draft ? "Draft" : who}
|
||||
{open && !me ? <span className="addr">{message.from.address}</span> : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="msg-to">{toYou ? "to you" : `to ${message.to.map(displayName).join(", ")}`}</div>
|
||||
) : (
|
||||
<BodySkeleton />
|
||||
)
|
||||
) : (
|
||||
<MessageBody html={message.html} plain={plain} surface={surface} />
|
||||
)}
|
||||
|
||||
{message.quotedHtml ? (
|
||||
<div className="msg-quoted">
|
||||
<Pill tone="quiet" onClick={onQuoted}>
|
||||
{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 className="msg-preview">{previewOf(message.html)}</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>
|
||||
) : null}
|
||||
|
||||
{notes.map((note) => (
|
||||
<Note key={note.id} body={note.body} />
|
||||
))}
|
||||
</article>
|
||||
{open ? (
|
||||
<div className="msg-body">
|
||||
{message.draft ? (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageContent, MessageImageBanner } from "./MessageContent";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Avatar, Button, Confirm, EmptyState, Pill, Sheet, Toggle } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
useScreener,
|
||||
} from "../store/useScreener";
|
||||
import { cap, displayName, isBrand } from "./format";
|
||||
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody";
|
||||
import { BodyMissing, BodySkeleton } from "./MessageBody";
|
||||
import "./screener.css";
|
||||
|
||||
/**
|
||||
@@ -140,6 +141,7 @@ export function Screener() {
|
||||
onFocus={() => focus(card.threadKey)}
|
||||
onDecide={(destination) => void decide(card.threadKey, destination, false)}
|
||||
onElsewhere={() => setPicking(card.threadKey)}
|
||||
onExpand={() => toggleExpanded(card.threadKey)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -195,15 +197,16 @@ interface CardProps {
|
||||
onFocus: () => void;
|
||||
onDecide: (destination: Destination) => 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 viewPhase = useScreener((s) => s.viewPhase[card.threadKey]);
|
||||
const retryView = useScreener((s) => s.retryView);
|
||||
const name = displayName(card.sender);
|
||||
const suggested = destinationName(card.suggestion);
|
||||
const message = view?.messages.at(-1);
|
||||
const message = view?.messages.filter((m) => !m.draft).at(-1);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -226,6 +229,7 @@ function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }:
|
||||
</div>
|
||||
|
||||
<div className="screen-actions">
|
||||
<Button variant="ghost" onClick={onExpand}>{open ? "Collapse email" : "View entire email"}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
keycap={cap("screen-yes")}
|
||||
@@ -247,10 +251,13 @@ function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }:
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && message ? <MessageImageBanner accountId={card.accountId} message={message} /> : null}
|
||||
{open ? (
|
||||
<div className="screen-message">
|
||||
{message ? (
|
||||
<MessageBody html={message.html} surface={message.surface} />
|
||||
<>
|
||||
<MessageContent accountId={card.accountId} message={message} quoted />
|
||||
</>
|
||||
) : viewPhase === "error" ? (
|
||||
<BodyMissing onRetry={() => retryView(card.threadKey)} />
|
||||
) : (
|
||||
|
||||
@@ -403,3 +403,6 @@
|
||||
:root[data-phone] .compose-subject {
|
||||
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; }
|
||||
@@ -42,7 +42,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 12px 0 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@@ -400,3 +401,5 @@
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.msg-quoted > .pill + .pill { margin-left: 8px; }
|
||||
+98
-51
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
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 {
|
||||
live,
|
||||
type Draft,
|
||||
@@ -84,6 +84,11 @@ interface Holding {
|
||||
}
|
||||
|
||||
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;
|
||||
/** The card, closed but not thrown away. */
|
||||
parked: Composer | null;
|
||||
@@ -101,14 +106,14 @@ interface ComposeState {
|
||||
|
||||
/** `c`. The parked draft when there is one, a blank message when there is not. */
|
||||
compose: () => void;
|
||||
closeCard: () => void;
|
||||
closeCard: () => Promise<void>;
|
||||
toggleExpanded: () => void;
|
||||
|
||||
/** `r`, `a` and `f`, from the pane. */
|
||||
answer: (thread: ThreadView, message: MessageView, kind: ComposerKind, all: boolean) => void;
|
||||
/** `a` again, on a box that is already open. */
|
||||
setAll: (all: boolean) => void;
|
||||
closeReply: () => void;
|
||||
closeReply: () => Promise<void>;
|
||||
|
||||
edit: (at: ComposerAt, changes: Partial<Draft>) => 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 saveRequests: Partial<Record<ComposerAt, Promise<void>>> = {};
|
||||
|
||||
function scheduleSave(at: ComposerAt): void {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -285,6 +291,44 @@ async function newestOutgoing(): Promise<string | null> {
|
||||
}
|
||||
|
||||
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,
|
||||
parked: null,
|
||||
expanded: false,
|
||||
@@ -321,17 +365,24 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
closeCard: () => {
|
||||
closeCard: async () => {
|
||||
await get().save("card");
|
||||
const card = get().card;
|
||||
if (!card) return;
|
||||
if (!card || card.phase === "error") return;
|
||||
cancelSave("card");
|
||||
set({ card: null, parked: card, expanded: false });
|
||||
void get().save("card").catch(() => {});
|
||||
|
||||
},
|
||||
|
||||
toggleExpanded: () => set((s) => (s.card ? { expanded: !s.expanded } : {})),
|
||||
|
||||
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();
|
||||
|
||||
// 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;
|
||||
if (!reply) return;
|
||||
if (!reply || reply.phase === "error") return;
|
||||
cancelSave("reply");
|
||||
set({ reply: null, replyKey: null, parkedReply: reply, parkedReplyKey: get().replyKey });
|
||||
void get().save("reply").catch(() => {});
|
||||
|
||||
},
|
||||
|
||||
edit: (at, changes) => {
|
||||
@@ -505,58 +557,52 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
remind: (at, atMs) => get().edit(at, { remindAtMs: atMs }),
|
||||
|
||||
save: async (at) => {
|
||||
const start = held(get(), at);
|
||||
if (!start || !live()) return;
|
||||
cancelSave(at);
|
||||
const draft = start.composer.draft;
|
||||
// Nothing has been written yet, so there is nothing to keep. A draft saved on open is a draft
|
||||
// that shows up in the list the moment you press `c` and stays there when you change your mind.
|
||||
if (!draft.id && start.composer.pristine) return;
|
||||
set(start.write({ ...start.composer, phase: "saving" }));
|
||||
try {
|
||||
const saved = await draftSave(draft);
|
||||
const after = held(get(), at);
|
||||
if (!after) return;
|
||||
// Something may have been typed while the save was in the air, and what came back is about
|
||||
// the draft that went out. Only the id belongs to both.
|
||||
const same = after.composer.draft === draft;
|
||||
set(
|
||||
after.write({
|
||||
...after.composer,
|
||||
phase: "idle",
|
||||
draft: { ...after.composer.draft, id: saved.id },
|
||||
...(same ? { encodedSize: saved.encodedSize, overLimit: saved.overLimit } : {}),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
const after = held(get(), at);
|
||||
if (after) set(after.write({ ...after.composer, phase: "error" }));
|
||||
notify(`Could not save the draft: ${e}`);
|
||||
}
|
||||
const work = async () => {
|
||||
const start = held(get(), at);
|
||||
if (!start || !live()) return;
|
||||
cancelSave(at);
|
||||
const draft = start.composer.draft;
|
||||
if (start.composer.pristine) return;
|
||||
set(start.write({ ...start.composer, phase: "saving" }));
|
||||
try {
|
||||
const saved = await draftSave(draft);
|
||||
void get().loadDrafts();
|
||||
const after = held(get(), at);
|
||||
if (!after || after.composer.draft.id !== draft.id || after.composer.draft.accountId !== draft.accountId || after.composer.draft.threadKey !== draft.threadKey) return;
|
||||
const same = after.composer.draft === draft;
|
||||
set(
|
||||
after.write({
|
||||
...after.composer,
|
||||
phase: "idle",
|
||||
draft: { ...after.composer.draft, id: saved.id },
|
||||
...(same ? { encodedSize: saved.encodedSize, overLimit: saved.overLimit } : {}),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
const after = held(get(), at);
|
||||
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) => {
|
||||
cancelSave(at);
|
||||
await saveRequests[at];
|
||||
const composer = composerAt(get(), at);
|
||||
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 });
|
||||
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) => {
|
||||
cancelSave(at);
|
||||
await saveRequests[at];
|
||||
const composer = composerAt(get(), at);
|
||||
if (!composer) return;
|
||||
if (composer.draft.to.length === 0) {
|
||||
@@ -585,6 +631,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
let undo: Undo;
|
||||
try {
|
||||
undo = await send(draft);
|
||||
void get().loadDrafts();
|
||||
} catch (e) {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -4,6 +4,7 @@ import { threadHydrate, threadView } from "../api/threads";
|
||||
import { undoLast } from "../api/undo";
|
||||
import { live, type Destination, type ScreenerCard, type ThreadView } from "../ipc";
|
||||
import { acknowledge } from "../screens/triage";
|
||||
import { useMail } from "./useMail";
|
||||
import { notify } from "./useToast";
|
||||
|
||||
/**
|
||||
@@ -202,6 +203,7 @@ export const useScreener = create<ScreenerState>((set, get) => ({
|
||||
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));
|
||||
} catch (e) {
|
||||
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 });
|
||||
try {
|
||||
acknowledge(await screenerClearAll(accountId), () => void get().load(accountId));
|
||||
if (useMail.getState().place === "screener") useMail.getState().goTo("inbox");
|
||||
} catch (e) {
|
||||
set({ cards });
|
||||
notify(`That did not go through: ${e}`);
|
||||
|
||||
Reference in new issue
Block a user