docs: rewrite the author-facing copy in plain language

The copy was written for someone who had read the spec. It named UTF-8, RFC
3339, canonical form, code points and frontmatter fields, and the one place it
gave instructions it gave them as fumi command lines, which say nothing to
anyone using carrier or gsmol.

Now it uses the words the clients put on screen: "header", with the "+ header"
button both of them have, and "Requests folder", which is what both call it.
One word per idea throughout — post, feed, header, Requests folder — and
reasons are whole sentences that say what happened and what to do, so the
reply no longer opens "Reason:" followed by a lowercase fragment.

The first-contact mail is the one a stranger reads before anything else, so it
now covers accepting this address: a recommendation, not the mechanism behind
it. The mechanism would also have aged badly, since a client that delivers its
token once makes accepting cost a single refused post rather than all of them.

A refused post gets a detail paragraph where one line cannot hold the fix. It
tells the author their token arrived and to send again, and what to do if it
is refused a second time, which is what happens on a client that attaches the
token to everything. A first post on an empty feed says so differently: the
author is watching their first attempt fail and needs to hear the feed is
fine.

Seven admin strings were built with format! and never reached the catalogue,
so an ack opened its body in lowercase and could not be translated. They are
entries now, and say what blocking does rather than naming the mechanism.

The cap warning said "90% of its 1048576 byte limit". It says 1 MB.

Four rig assertions matched the old wording and now match the new. They have
to read rendered mail, so they stay coupled to it in a way the unit tests,
which assert on Reason variants, do not.
This commit is contained in:
randogoth 2026-10-15 09:06:54 +03:00
parent 9c9dce57e7
commit 72c353cb5a
5 changed files with 271 additions and 128 deletions

View file

@ -40,6 +40,16 @@ enum Outcome {
Deferred,
}
/// A byte count as something a reader can picture. Deliberately coarse: the
/// exact figure helps nobody deciding whether to prune their feed.
fn human_size(bytes: usize) -> String {
match bytes {
b if b >= 1 << 20 => format!("{} MB", b / (1 << 20)),
b if b >= 1 << 10 => format!("{} KB", b / (1 << 10)),
b => format!("{b} bytes"),
}
}
/// Whether a request is one only an admin may run. Register and Help are not:
/// an admin sending either means it literally, and routing them through the
/// admin branch would answer with silence.
@ -605,27 +615,50 @@ impl Daemon {
crate::i18n::t1("welcome-body", "url", url.clone()),
));
}
format!("admitted {target}: {url}")
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
args.set("url", url);
crate::i18n::t_args("admin-admitted", Some(&args))
}
Err(e) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
args.set("detail", e);
crate::i18n::t_args("admin-admit-failed", Some(&args))
}
Err(e) => format!("could not admit {target}: {e}"),
}
}
Request::Block(target) => match self.store.block(target) {
Ok(true) => {
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout) {
Ok(_) => format!("blocked {target}"),
Err(e) => format!("blocked {target} locally, but the server still holds their token: {e}"),
Request::Block(target) => {
let said = |id: &str, detail: Option<String>| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
if let Some(detail) = detail {
args.set("detail", detail);
}
crate::i18n::t_args(id, Some(&args))
};
match self.store.block(target) {
Ok(true) => {
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout)
{
Ok(_) => said("admin-blocked", None),
Err(e) => said("admin-block-half-done", Some(e.to_string())),
}
}
Ok(false) => said("admin-block-unknown", None),
Err(e) => said("admin-block-failed", Some(e.to_string())),
}
Ok(false) => format!("{target} was never accepted"),
Err(e) => format!("could not block {target}: {e}"),
},
}
Request::Registration(mode) => {
state.set_registration(*mode);
state
.save(&self.cfg.data.join("state.json"))
.map_err(|e| format!("state: {e}"))?;
format!("registration is now {mode:?}").to_lowercase()
crate::i18n::t(match mode {
Mode::Open => "admin-registration-open",
Mode::Closed => "admin-registration-closed",
Mode::Silent => "admin-registration-silent",
})
}
// Not an admin command; the caller routes these elsewhere.
Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped),
@ -781,6 +814,14 @@ impl Daemon {
}
Ok(Action::Ignore) => false,
Err(reason) => {
// Someone watching their first post be refused needs to hear
// that their feed is fine, not just what went wrong.
let reason = match reason {
Reason::AcceptFieldOnPost if feed.entries().is_empty() => {
Reason::AcceptFieldOnFirstPost
}
other => other,
};
self.queue_reply(state, incoming, &address, &reason, replies);
false
}
@ -817,11 +858,7 @@ impl Daemon {
replies.push((
address.to_owned(),
crate::i18n::t("cap-warning-subject"),
crate::i18n::t1(
"cap-warning-body",
"cap",
self.cfg.cap_bytes.to_string(),
),
crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)),
));
}
Ok(true)
@ -857,14 +894,10 @@ impl Daemon {
let text = std::str::from_utf8(&incoming.body)
.map(|t| t.chars().take(200).collect::<String>())
.unwrap_or_default();
let sentence = reason.text();
let mut args = fluent_bundle::FluentArgs::new();
args.set("reason", sentence.clone());
args.set("quoted", text);
replies.push((
address.to_owned(),
crate::i18n::t1("reply-subject", "reason", sentence),
crate::i18n::t_args("reply-body", Some(&args)),
crate::i18n::t1("reply-subject", "reason", reason.text()),
reason.reply_body(&text),
));
}
@ -950,6 +983,15 @@ mod tests {
}
}
#[test]
fn sizes_read_as_sizes() {
assert_eq!(human_size(1_048_576), "1 MB");
assert_eq!(human_size(10 * 1_048_576), "10 MB");
assert_eq!(human_size(2048), "2 KB");
assert_eq!(human_size(1023), "1023 bytes");
assert_eq!(human_size(0), "0 bytes");
}
#[test]
fn posts_publish_and_verify() {
let mut feed = smolboid_core::Feed::new();