From f804f5ad7fa0a1242f17f7a7b1cb202621a24fbc Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 31 Oct 2026 22:14:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E4=BF=A1=E5=89=8D=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E5=85=A8=E8=A7=92=E5=8F=91=E4=BB=B6=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/app/src/autostart.rs | 11 +++- crates/fakemail/src/lib.rs | 22 +++++--- crates/mailbox/Cargo.toml | 1 + crates/mailbox/src/error.rs | 11 ++++ crates/mailbox/src/lib.rs | 16 +++--- crates/mailbox/tests/connect.rs | 15 ++++++ crates/mailbox/tests/store.rs | 5 ++ crates/net/src/proxy.rs | 15 +++++- crates/send/src/lib.rs | 4 ++ crates/send/tests/send.rs | 80 +++++++++++++++++++++++++++- crates/task/tests/tasks.rs | 1 + docs/design.md | 2 +- ui/src/components/mailbox-dialog.tsx | 10 ++-- ui/src/pages/letter-style.test.tsx | 69 ++++++++++++++++++++++++ ui/src/pages/letter-style.tsx | 15 ++++-- 16 files changed, 249 insertions(+), 29 deletions(-) create mode 100644 ui/src/pages/letter-style.test.tsx diff --git a/Cargo.lock b/Cargo.lock index 43d4f7878..0127b6eec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3421,6 +3421,7 @@ version = "3.0.0-alpha.0" dependencies = [ "async-imap", "chrono", + "cloud-api", "db", "fakemail", "futures-util", diff --git a/crates/app/src/autostart.rs b/crates/app/src/autostart.rs index 166267a82..aa67e493d 100644 --- a/crates/app/src/autostart.rs +++ b/crates/app/src/autostart.rs @@ -23,7 +23,16 @@ pub async fn set_autostart(app: AppHandle, enabled: bool) -> std::result::Result check_location(&executable)?; enable(&app, &executable).map_err(setting_error) } else { - app.autolaunch().disable().map_err(setting_error) + app.autolaunch().disable().map_err(setting_error)?; + // On Windows, an app run as administrator turns it on for every user of the computer, + // which only an administrator can turn off again. + if app.autolaunch().is_enabled().map_err(setting_error)? { + return Err( + "开机自启是以管理员身份开启的。右键 Auto Email Sender 选“以管理员身份运行”,再关掉它。" + .into(), + ); + } + Ok(()) } } diff --git a/crates/fakemail/src/lib.rs b/crates/fakemail/src/lib.rs index 24df2da35..dcc731fc5 100644 --- a/crates/fakemail/src/lib.rs +++ b/crates/fakemail/src/lib.rs @@ -129,20 +129,23 @@ pub async fn smtp(pki: &Pki, starttls: bool) -> (u16, Inbox) { || std::future::ready(()), || std::future::ready(()), || std::future::ready(()), + || std::future::ready(()), ) .await } -/// Hooks pause recipient responses, body results, and rejection replies before they reach +/// Hooks pause login, recipient responses, body results, and rejection replies before they reach /// the client. -pub async fn smtp_with_hooks( +pub async fn smtp_with_hooks( pki: &Pki, starttls: bool, + on_auth: impl Fn() -> A + Send + Sync + 'static, on_rcpt: impl Fn() -> R + Send + Sync + 'static, on_body: impl Fn() -> B + Send + Sync + 'static, on_reject: impl Fn() -> E + Send + Sync + 'static, ) -> (u16, Inbox) where + A: Future + Send + 'static, R: Future + Send + 'static, B: Future + Send + 'static, E: Future + Send + 'static, @@ -151,6 +154,7 @@ where let inbox = Inbox::default(); let received = inbox.clone(); let acceptor = tls.clone(); + let on_auth = Arc::new(on_auth); let on_rcpt = Arc::new(on_rcpt); let on_body = Arc::new(on_body); let on_reject = Arc::new(on_reject); @@ -159,6 +163,7 @@ where io, acceptor.clone(), received.clone(), + on_auth.clone(), on_rcpt.clone(), on_body.clone(), on_reject.clone(), @@ -168,15 +173,17 @@ where (port, inbox) } -async fn smtp_session( +async fn smtp_session( mut io: BufReader>, tls: TlsAcceptor, inbox: Inbox, + on_auth: Arc A + Send + Sync>, on_rcpt: Arc R + Send + Sync>, on_body: Arc B + Send + Sync>, on_reject: Arc E + Send + Sync>, ) -> std::io::Result<()> where + A: Future + Send + 'static, R: Future + Send + 'static, B: Future + Send + 'static, E: Future + Send + 'static, @@ -198,11 +205,10 @@ where return Ok(()); } let words: Vec<&str> = line.split_whitespace().collect(); - if words - .first() - .is_some_and(|word| word.eq_ignore_ascii_case("RCPT")) - { - on_rcpt().await; + match words.first().map(|w| w.to_ascii_uppercase()).as_deref() { + Some("AUTH") => on_auth().await, + Some("RCPT") => on_rcpt().await, + _ => {} } let reply = match words.first().map(|w| w.to_ascii_uppercase()).as_deref() { Some("EHLO") if encrypted => "250-fake\r\n250 AUTH PLAIN LOGIN\r\n", diff --git a/crates/mailbox/Cargo.toml b/crates/mailbox/Cargo.toml index a97718a16..11afc6f3a 100644 --- a/crates/mailbox/Cargo.toml +++ b/crates/mailbox/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] async-imap = { version = "0.11", default-features = false, features = ["runtime-tokio"] } chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +cloud-api.workspace = true futures-util = "0.3" lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "rustls-platform-verifier"] } net.workspace = true diff --git a/crates/mailbox/src/error.rs b/crates/mailbox/src/error.rs index 43ffea80e..78c24ed29 100644 --- a/crates/mailbox/src/error.rs +++ b/crates/mailbox/src/error.rs @@ -1,5 +1,7 @@ use std::io; +use crate::pause::{Failure, PauseKind, classify}; + /// Why talking to a mail server failed. Displays in English for the log; `message` is for the user. #[derive(Debug)] pub enum MailError { @@ -112,6 +114,15 @@ impl MailError { "和 {host} 建立加密连接失败({source})。检查加密方式和端口是否对应:SSL/TLS \ 一般用 465、993 端口,STARTTLS 一般用 587、143 端口。" ), + // Checking the code or trying again at once would only keep the limit on. + MailError::Login { reply, .. } | MailError::Refused { reply, .. } + if matches!(classify(self), Failure::Pause(PauseKind::Throttled(_))) => + { + format!( + "服务商限制了登录或发信的频率(服务器回复:{reply})。过一段时间再试,\ + 授权码不用改;短时间里反复尝试会让限制更久。" + ) + } MailError::Login { reply, .. } => format!( "登录被拒绝(服务器回复:{reply})。检查授权码或密码有没有填错,\ 邮箱设置里有没有开启 IMAP/SMTP 服务;\ diff --git a/crates/mailbox/src/lib.rs b/crates/mailbox/src/lib.rs index d72be643c..52f77228b 100644 --- a/crates/mailbox/src/lib.rs +++ b/crates/mailbox/src/lib.rs @@ -41,7 +41,7 @@ pub struct Mailbox { pub needs_secret: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] pub struct Server { #[ts(type = "string")] pub host: Cow<'static, str>, @@ -86,11 +86,10 @@ pub struct MailboxFields { } impl MailboxForm { - /// Trims the text and checks every field; the error says what to fix. + /// Trims the text, folds a full-width address and checks every field; the error says what + /// to fix. pub fn check(self) -> Result { - let address = self - .address - .trim() + let address = cloud_api::tidy_email(self.address.trim()) .parse() .map_err(|_| "邮箱地址格式不正确")?; let sender_name = self.sender_name.trim(); @@ -141,7 +140,7 @@ pub fn checked_secret(secret: &str) -> Option { } /// What logging in to a mailbox takes. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct Account { pub address: lettre::Address, pub sender_name: String, @@ -320,7 +319,10 @@ pub async fn secret( } /// What logging in to a saved mailbox takes. -pub async fn account(db: &SqlitePool, id: i64) -> Result, sqlx::Error> { +pub async fn account( + db: impl sqlx::SqliteExecutor<'_>, + id: i64, +) -> Result, sqlx::Error> { let Some(r) = sqlx::query!( r#"SELECT address, sender_name, secret AS "secret!", smtp_host, smtp_port AS "smtp_port: u16", smtp_security AS "smtp_security: Security", diff --git a/crates/mailbox/tests/connect.rs b/crates/mailbox/tests/connect.rs index 9253d5143..852bddb07 100644 --- a/crates/mailbox/tests/connect.rs +++ b/crates/mailbox/tests/connect.rs @@ -114,3 +114,18 @@ async fn the_test_email_goes_to_the_mailbox_itself() { "{headers}" ); } + +#[tokio::test] +async fn a_throttled_login_says_to_wait_not_to_change_the_code() { + let pki = fakemail::pki(); + let net = Net::new(vec![pki.root.clone()]).unwrap(); + let (me, _) = mailbox(&pki, Security::Tls, "throttled").await; + let message = mailbox::check_sending(&net, &me) + .await + .unwrap_err() + .message(); + assert!( + message.starts_with("服务商限制了登录或发信的频率"), + "{message}" + ); +} diff --git a/crates/mailbox/tests/store.rs b/crates/mailbox/tests/store.rs index b8e95d76f..b5f2126d7 100644 --- a/crates/mailbox/tests/store.rs +++ b/crates/mailbox/tests/store.rs @@ -46,6 +46,11 @@ async fn an_address_is_added_once_in_any_letter_case(db: SqlitePool) { let again = form("me@QQ.com").check().unwrap(); let result = mailbox::create(&db, &again, "s", 0).await; assert!(matches!(result, Err(SaveError::AddressTaken)), "{result:?}"); + // As Chinese input methods type it. + let full_width = form("Me@qq.com").check().unwrap(); + assert_eq!(full_width.address.to_string(), "Me@qq.com"); + let result = mailbox::create(&db, &full_width, "s", 0).await; + assert!(matches!(result, Err(SaveError::AddressTaken)), "{result:?}"); } #[sqlx::test(migrator = "db::MIGRATOR")] diff --git a/crates/net/src/proxy.rs b/crates/net/src/proxy.rs index 4fac6a3ec..cb8eabaaf 100644 --- a/crates/net/src/proxy.rs +++ b/crates/net/src/proxy.rs @@ -13,13 +13,18 @@ pub enum Proxy { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Endpoint { + /// An IPv6 address without its brackets, as name lookups take it. pub host: String, pub port: u16, } impl fmt::Display for Endpoint { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.host, self.port) + if self.host.contains(':') { + write!(f, "[{}]:{}", self.host, self.port) + } else { + write!(f, "{}:{}", self.host, self.port) + } } } @@ -102,6 +107,10 @@ fn system() -> Option { fn windows_proxy(server: &str) -> Option { let endpoint = |s: &str| { let (host, port) = s.trim().rsplit_once(':')?; + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); Some(Endpoint { host: host.to_owned(), port: port.parse().ok()?, @@ -147,5 +156,9 @@ mod tests { ); assert_eq!(windows_proxy("ftp=127.0.0.1:1"), None); assert_eq!(windows_proxy("127.0.0.1"), None); + // Looked up without the brackets, written with them. + let ipv6 = windows_proxy("http=[::1]:7890;https=[::1]:7890").unwrap(); + assert_eq!(ipv6.endpoint().host, "::1"); + assert_eq!(ipv6.url(), "http://[::1]:7890"); } } diff --git a/crates/send/src/lib.rs b/crates/send/src/lib.rs index 62efdfe55..f3c851841 100644 --- a/crates/send/src/lib.rs +++ b/crates/send/src/lib.rs @@ -495,6 +495,10 @@ async fn mark_sending( .execute(&mut conn) .await?; let mut tx = conn.begin_with("BEGIN IMMEDIATE").await?; + // Edited since the login, the mailbox is logged in to again as it is now, next step. + if mailbox::account(&mut *tx, mailbox_id).await?.as_ref() != Some(account) { + return Ok(None); + } let at = now(); let Some(before) = sqlx::query_as!( BeforeHandover, diff --git a/crates/send/tests/send.rs b/crates/send/tests/send.rs index 5adb9f1d1..614318b9f 100644 --- a/crates/send/tests/send.rs +++ b/crates/send/tests/send.rs @@ -33,24 +33,28 @@ async fn mailbox(db: &SqlitePool, secret: &str) -> (Net, Inbox) { || std::future::ready(()), || std::future::ready(()), || std::future::ready(()), + || std::future::ready(()), ) .await } -async fn mailbox_with_hooks( +async fn mailbox_with_hooks( db: &SqlitePool, secret: &str, + on_auth: impl Fn() -> A + Send + Sync + 'static, on_rcpt: impl Fn() -> R + Send + Sync + 'static, on_body: impl Fn() -> B + Send + Sync + 'static, on_reject: impl Fn() -> E + Send + Sync + 'static, ) -> (Net, Inbox) where + A: Future + Send + 'static, R: Future + Send + 'static, B: Future + Send + 'static, E: Future + Send + 'static, { let pki = fakemail::pki(); - let (port, inbox) = fakemail::smtp_with_hooks(&pki, false, on_rcpt, on_body, on_reject).await; + let (port, inbox) = + fakemail::smtp_with_hooks(&pki, false, on_auth, on_rcpt, on_body, on_reject).await; let server = |port| Server { host: Cow::Borrowed("localhost"), port, @@ -421,6 +425,7 @@ async fn an_old_failure_does_not_pause_saved_mailbox_settings(db: SqlitePool) { secret, || std::future::ready(()), || std::future::ready(()), + || std::future::ready(()), move || { let (entered, proceed) = (entered.clone(), proceed.clone()); let first = first_failure.swap(false, Ordering::SeqCst); @@ -477,6 +482,72 @@ async fn an_old_failure_does_not_pause_saved_mailbox_settings(db: SqlitePool) { } } +#[sqlx::test(migrator = "db::MIGRATOR")] +async fn a_mailbox_edited_during_the_login_sends_as_edited(db: SqlitePool) { + let writes = db::Writes::default(); + let logging_in = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let first_login = AtomicBool::new(true); + let (entered, proceed) = (logging_in.clone(), release.clone()); + let (net, inbox) = mailbox_with_hooks( + &db, + SECRET, + move || { + let (entered, proceed) = (entered.clone(), proceed.clone()); + let first = first_login.swap(false, Ordering::SeqCst); + async move { + if first { + entered.notify_one(); + proceed.notified().await; + } + } + }, + || std::future::ready(()), + || std::future::ready(()), + || std::future::ready(()), + ) + .await; + let owner = approve(&db, "wang@example.edu.cn", &[], T).await; + let primary = mailbox::list(&db, T).await.unwrap().remove(0); + let fields = MailboxForm { + address: fakemail::BACKUP_ADDRESS.into(), + sender_name: primary.sender_name, + provider: primary.provider, + smtp: primary.smtp, + imap: primary.imap, + daily_limit: primary.daily_limit, + min_interval_s: primary.min_interval_s, + } + .check() + .unwrap(); + let changing = async { + logging_in.notified().await; + assert!( + mailbox::update(&db, primary.id, &fields, SECRET) + .await + .unwrap() + ); + release.notify_one(); + }; + // Only catches a hang; no timeout arranges the business ordering. + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(60), async { + tokio::join!(send::step(&db, &writes, &net, nowhere(), || T), changing) + }) + .await + .unwrap(); + result.unwrap(); + assert_eq!(first_email(&db, owner).await.delivery, Delivery::Approved); + assert!(inbox.lock().unwrap().is_empty()); + send::step(&db, &writes, &net, nowhere(), || T) + .await + .unwrap(); + let sent = inbox.lock().unwrap().remove(0); + assert!( + sent.contains(&format!("<{}>", fakemail::BACKUP_ADDRESS)), + "{sent}" + ); +} + async fn backup_mailbox(db: &SqlitePool) -> i64 { let primary = mailbox::list(db, T).await.unwrap().remove(0); let fields = MailboxForm { @@ -1973,6 +2044,7 @@ async fn expiry_after_durable_claim_restores_the_previous_attempt(db: SqlitePool let (net, inbox) = mailbox_with_hooks( &db, SECRET, + || std::future::ready(()), move || { if enabled.load(Ordering::SeqCst) { advancing.store(T + MINUTE + 3_600_000, Ordering::SeqCst); @@ -2046,6 +2118,7 @@ async fn review_deadline_crossed_during_envelope_must_not_upload(db: SqlitePool) let (net, inbox) = mailbox_with_hooks( &db, SECRET, + || std::future::ready(()), move || { advancing.store(T + 3_600_000, Ordering::SeqCst); std::future::ready(()) @@ -2098,6 +2171,7 @@ async fn deadline_crossed_after_body_upload_keeps_the_uncertain_attempt(db: Sqli &db, SECRET, || std::future::ready(()), + || std::future::ready(()), move || { advancing.store(T + 3_600_000, Ordering::SeqCst); std::future::ready(()) @@ -2140,6 +2214,7 @@ async fn controlled_mailbox(db: &SqlitePool) -> (Net, Inbox, Arc) { let (net, inbox) = mailbox_with_hooks( db, SECRET, + || std::future::ready(()), move || { let gate = gate.clone(); async move { @@ -3047,6 +3122,7 @@ async fn an_expired_claim_is_restored_once_storage_works_again(db: SqlitePool) { let (net, inbox) = mailbox_with_hooks( &db, SECRET, + || std::future::ready(()), move || { advancing.store(T + 3_600_000, Ordering::SeqCst); std::future::ready(()) diff --git a/crates/task/tests/tasks.rs b/crates/task/tests/tasks.rs index b5c27f145..2cf895186 100644 --- a/crates/task/tests/tasks.rs +++ b/crates/task/tests/tasks.rs @@ -1338,6 +1338,7 @@ async fn review_expiry_restore_must_respect_stopping_during_envelope(db: SqliteP let (port, inbox) = fakemail::smtp_with_hooks( &pki, false, + || std::future::ready(()), move || { let signal = signal.clone(); let proceed = proceed.clone(); diff --git a/docs/design.md b/docs/design.md index dc0858b77..cdf21aad6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -258,7 +258,7 @@ V3 的产品范围和全局决定。 - 后台运行:关闭窗口不退出,应用留在后台,照常定时发信、收信、抓取和处理回信。第一次关闭窗口时先保留窗口,弹出挂在主窗口上的说明对话框,说明关闭后仍照常定时发信和收信,可从托盘图标(macOS 为菜单栏图标)打开或退出;按“知道了”关掉对话框后隐藏窗口,并记下已提示。提示过程串行;读标记失败仍提示,读写失败记日志。 - 托盘图标(macOS 在菜单栏):Windows 上点一下托盘图标打开窗口,右键是菜单;macOS 按系统习惯,点菜单栏图标弹出菜单。菜单里是"打开 Auto Email Sender"和"退出"。macOS 的 Dock 图标照常,点它也打开窗口。 - 完全退出:托盘菜单的"退出",macOS 上还有 ⌘Q 和 Dock 菜单的"退出"。退出后后台工作停下,下次打开时接着做;退出时正在交出的邮件记为[可能已发出](data-model.md#邮件状态)。 -- 开机自启:默认关闭,在设置里打开。开机启动时不打开窗口,只在托盘里,后台工作照常;本次启动装上了恢复的备份时显示窗口。启动时自启已开启而登记命令与当前可执行文件不符时更新登录项,没变则不重写。macOS 在 App Translocation 临时路径下不允许开启,提示先移到“应用程序”文件夹再打开;不处理系统设置里禁用但仍保留文件的情况。 +- 开机自启:默认关闭,在设置里打开。开机启动时不打开窗口,只在托盘里,后台工作照常;本次启动装上了恢复的备份时显示窗口。启动时自启已开启而登记命令与当前可执行文件不符时更新登录项,没变则不重写。macOS 在 App Translocation 临时路径下不允许开启,提示先移到“应用程序”文件夹再打开。Windows 上以管理员身份运行时开启的,登记在整台电脑上,普通身份关不掉:关闭后仍登记着就报错,说明要以管理员身份运行再关。不处理系统设置里禁用但仍保留文件的情况。 - 已经在运行时再打开应用,就把窗口拿到前面。 - 自动更新:启动时检查一次,之后在后台每 6 小时检查一次;有新版本时侧边栏出现提示,点开看版本号和更新说明,"下载并安装"在后台下载,下好后"重启并更新"。重启和完全退出一样,正在交出的邮件记为可能已发出。自动检查失败不打扰用户,只记日志;在"关于和日志"里手动检查时写明失败原因。更新包用更新器的签名校验,校验不过不安装。检查和下载走系统代理。更新只换应用本身,数据目录不动;新版第一次打开时按需升级数据库,升级前照常先备份。 - 诊断日志:用户能导出发给开发者,单个抓取任务的日志可以单独导出。启动失败(比如数据库升级失败)时显示原因,同样能导出日志。 diff --git a/ui/src/components/mailbox-dialog.tsx b/ui/src/components/mailbox-dialog.tsx index 99f698bda..d548ad046 100644 --- a/ui/src/components/mailbox-dialog.tsx +++ b/ui/src/components/mailbox-dialog.tsx @@ -59,8 +59,9 @@ function formOf(m: Mailbox): NewMailbox { }; } -/** The provider that serves the address's domain. */ -function byDomain(providers: Provider[], address: string): Provider | undefined { +/** The provider that serves the address's domain, full-width as Chinese input methods type it or not. */ +function byDomain(providers: Provider[], typed: string): Provider | undefined { + const address = typed.normalize("NFKC"); const domain = address .slice(address.lastIndexOf("@") + 1) .trim() @@ -172,7 +173,8 @@ export function MailboxDialog({ ); } - if (!form.address.includes("@")) return null; + if (!form.address.normalize("NFKC").includes("@")) return null; const hosted = providers.filter((p) => p.domains.length === 0); return ( diff --git a/ui/src/pages/letter-style.test.tsx b/ui/src/pages/letter-style.test.tsx new file mode 100644 index 000000000..df4722942 --- /dev/null +++ b/ui/src/pages/letter-style.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import type { LetterStyle } from "@/api/types"; +import { LetterStyleSection } from "./letter-style"; + +const remote = vi.hoisted(() => { + const style: LetterStyle = { + font: null, + fontSizePt: 12, + lineHeight: 1.6, + paragraphSpacingEm: 0.4, + firstLineIndent: false, + }; + return { style, save: vi.fn<(style: LetterStyle) => Promise>() }; +}); +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children: ReactNode }) => {children}, +})); +vi.mock("@/api/call", () => ({ + call: (op: string, input: LetterStyle) => { + if (op === "updateLetterStyle") return remote.save(input); + if (op === "getLetterStyle") return structuredClone(remote.style); + throw new Error(`unexpected operation: ${op}`); + }, + errorMessage: (error: Error) => error.message, +})); + +function select(id: string) { + return document.querySelector(`#${id}`); +} + +it("shows what an Agent saved and does not save the old style back", async () => { + remote.save.mockReset().mockImplementation(async (style) => { + remote.style = style; + return null; + }); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); + client.setQueryData(["getLetterStyle"], remote.style); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + + + , + ), + ); + // An Agent saves while the page is open. + remote.style = { ...remote.style, fontSizePt: 14 }; + await act(async () => client.setQueryData(["getLetterStyle"], remote.style)); + await vi.waitFor(() => expect(select("letter-size")?.value).toBe("14")); + await act(async () => { + const spacing = select("letter-spacing"); + if (!spacing) throw new Error("no spacing choice"); + spacing.value = "0.8"; + spacing.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(remote.save).toHaveBeenLastCalledWith(expect.objectContaining({ fontSizePt: 14, paragraphSpacingEm: 0.8 })); + } finally { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } +}); diff --git a/ui/src/pages/letter-style.tsx b/ui/src/pages/letter-style.tsx index 38d4a0dc2..f38ab52e7 100644 --- a/ui/src/pages/letter-style.tsx +++ b/ui/src/pages/letter-style.tsx @@ -48,7 +48,7 @@ export function LetterStyleSection() {

每封信都用这套样式,改了马上保存;已经批准的信不受影响。

{style.error &&

信件样式没能加载:{errorMessage(style.error)}

} - {style.data && } + {style.data && }
); } @@ -57,10 +57,13 @@ const mutationKey = ["updateLetterStyle"]; /** * Saves each choice. Saves run one at a time and outlive this page, so the last choice wins even - * after leaving and coming back quickly, and one not yet saved is shown again on return. + * after leaving and coming back quickly, and one not yet saved is shown again on return. Once + * saved, the style shows as the core has it, so that a change an Agent made since is not saved back. */ -function LetterStyleFields({ saved }: { saved: LetterStyle }) { +function LetterStyleFields() { const queryClient = useQueryClient(); + // Read here, not passed in, so that a render sees the style and the saves at the same moment. + const saved = useQuery({ queryKey: ["getLetterStyle"], queryFn: () => call("getLetterStyle", {}) }).data; const save = useMutation({ mutationKey, scope: { id: "updateLetterStyle" }, @@ -75,9 +78,11 @@ function LetterStyleFields({ saved }: { saved: LetterStyle }) { filters: { mutationKey }, select: (m) => m.state, }).at(-1); - const [style, setStyle] = useState(last?.status === "success" ? saved : (last?.variables ?? saved)); + const [chosen, setChosen] = useState(last?.variables); + if (!saved) return null; + const style = last?.status === "success" ? saved : (chosen ?? saved); function edit(next: LetterStyle) { - setStyle(next); + setChosen(next); save.mutate(next); } return (