feat: base UI strings on carrier's shared Fluent source
Vendors carrier's locales/en-US/main.ftl (extended to cover gsmol's remaining strings) as the single source both clients draw wording from, adds a dependency-free .ftl-to-JSON build step (devbox run locale) since the file only ever uses Fluent's flat subset, and replaces every hardcoded string in index.html/app.js with a lookup into the generated dictionary. Along the way: removes connect()'s requirePin check, which turned out to be unreachable under the app's own invariants (every call site already guarantees its target host is pinned before calling it), and fixes a leftover call to store.setMasterSaved — removed in the earlier settings redesign — that was silently breaking onboarding's "I saved it" button.
This commit is contained in:
parent
80aca8a29f
commit
70087fa470
7 changed files with 1080 additions and 258 deletions
333
web/js/app.js
333
web/js/app.js
|
|
@ -11,9 +11,16 @@ import {
|
|||
import * as store from "./store.js";
|
||||
import { connectStream } from "./transport.js";
|
||||
import { PRESET_SERVER } from "./config.js";
|
||||
import { applyStaticLocale, loadLocale, t } from "./i18n.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// Blocks the rest of this module's top-level code (including every eager
|
||||
// render below) until the dictionary is in, so nothing races t() before it
|
||||
// has anything to look up.
|
||||
await loadLocale();
|
||||
applyStaticLocale();
|
||||
|
||||
// Everything is built as nodes rather than markup, so no message content can
|
||||
// ever reach innerHTML. Null children are dropped, which keeps conditional
|
||||
// pieces inline at the call site.
|
||||
|
|
@ -83,15 +90,16 @@ const fmtRowTime = (seconds) => {
|
|||
// Flashes its own label rather than a toast — settings' rows need feedback
|
||||
// that doesn't float away from the row that caused it.
|
||||
function copyButton(text, what) {
|
||||
return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy",
|
||||
return el("button", { type: "button", className: "copy", title: t("property-row-copy-tooltip", { field: what }),
|
||||
textContent: t("copy-button-idle"),
|
||||
onclick: async (event) => {
|
||||
const button = event.currentTarget;
|
||||
const resting = button.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "copied";
|
||||
button.textContent = t("copy-button-success");
|
||||
} catch {
|
||||
button.textContent = "failed";
|
||||
button.textContent = t("copy-button-failed");
|
||||
}
|
||||
setTimeout(() => { button.textContent = resting; }, 1500);
|
||||
} });
|
||||
|
|
@ -111,43 +119,45 @@ function accountAddress() {
|
|||
// absence of one. The CSS does the rest: color-scheme decides which half of
|
||||
// every light-dark() token applies, so nothing here touches a colour.
|
||||
const THEMES = [
|
||||
{ value: null, icon: "auto", label: "theme: following the system" },
|
||||
{ value: "light", icon: "sun", label: "theme: light" },
|
||||
{ value: "dark", icon: "moon", label: "theme: dark" },
|
||||
{ value: null, icon: "auto", labelId: "theme-auto-label" },
|
||||
{ value: "light", icon: "sun", labelId: "theme-light-label" },
|
||||
{ value: "dark", icon: "moon", labelId: "theme-dark-label" },
|
||||
];
|
||||
|
||||
function applyTheme() {
|
||||
const mode = THEMES.find(t => t.value === store.theme()) ?? THEMES[0];
|
||||
const mode = THEMES.find(entry => entry.value === store.theme()) ?? THEMES[0];
|
||||
if (mode.value) document.documentElement.dataset.theme = mode.value;
|
||||
else delete document.documentElement.dataset.theme;
|
||||
const button = $("theme");
|
||||
button.querySelector("use").setAttribute("href", `#i-${mode.icon}`);
|
||||
button.title = mode.label;
|
||||
button.setAttribute("aria-label", mode.label);
|
||||
const label = t(mode.labelId);
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
$("theme").onclick = () => {
|
||||
const current = THEMES.findIndex(t => t.value === store.theme());
|
||||
const current = THEMES.findIndex(entry => entry.value === store.theme());
|
||||
store.setTheme(THEMES[(current + 1) % THEMES.length].value);
|
||||
applyTheme();
|
||||
status(THEMES.find(t => t.value === store.theme()).label);
|
||||
status(t(THEMES.find(entry => entry.value === store.theme()).labelId));
|
||||
};
|
||||
|
||||
// --- connection --------------------------------------------------------------
|
||||
|
||||
// §4: registration and fetching demand a pinned key; sending to a recipient
|
||||
// whose key we already hold tolerates an unpinned server.
|
||||
async function connect(addr, { requirePin }) {
|
||||
// Every caller that needs its own account's server reachable here already
|
||||
// checked for one before calling (see e.g. fetch's and rotateIdentity's own
|
||||
// `!addr` guards) — and that host is always pinned by the time it exists,
|
||||
// since onboarding pins before it ever binds an account. A correspondent's
|
||||
// server, by contrast, legitimately proceeds unpinned (the UNVERIFIED
|
||||
// warning below is the only consequence); there is no live case left where
|
||||
// this needs to refuse a connection outright, so it no longer tries to.
|
||||
async function connect(addr) {
|
||||
const pinned = store.serverPin(addr.host);
|
||||
if (requirePin && !pinned)
|
||||
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
|
||||
`trusted channel, then pin it under settings → server.`);
|
||||
const timeoutMs = store.timeoutSeconds() * 1000;
|
||||
const stream = await connectStream(addr.host, addr.port, timeoutMs);
|
||||
const opened = await openSession(stream, addr.host, pinned, timeoutMs);
|
||||
if (!pinned)
|
||||
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
||||
`Lookups from this session are UNVERIFIED.`, true);
|
||||
status(t("connect-unpinned-warning", { host: addr.host, key: b32encode(opened.serverStatic) }), true);
|
||||
return opened;
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +224,6 @@ $("create-identity").onclick = () => {
|
|||
$("show-restore").onclick = () => showSetupStage("seed-restore");
|
||||
|
||||
$("seed-saved").onclick = () => {
|
||||
store.setMasterSaved(true);
|
||||
enterServerSetup();
|
||||
};
|
||||
$("seed-back").onclick = () => discardSetupIdentity();
|
||||
|
|
@ -228,7 +237,7 @@ function parseSeedText(text) {
|
|||
const bytes = b32decode(text);
|
||||
if (bytes.length === 32) return bytes;
|
||||
} catch { /* fall through to the error below */ }
|
||||
throw new SmolError("not a valid secret — paste the 64 hex characters or the base32 form");
|
||||
throw new SmolError(t("seed-wrong-length"));
|
||||
}
|
||||
|
||||
$("restore-form").onsubmit = (event) => {
|
||||
|
|
@ -272,12 +281,11 @@ function renderServerScreen() {
|
|||
area.replaceChildren();
|
||||
if (key) {
|
||||
area.append(
|
||||
field("fingerprint", fingerprint(key)),
|
||||
field("key", b32encode(key)),
|
||||
el("p", { className: "hint", textContent: "If the server operator published a key or " +
|
||||
"fingerprint, make sure it matches before trusting it." }));
|
||||
field(t("detail-fingerprint-label"), fingerprint(key)),
|
||||
field(t("detail-key-label"), b32encode(key)),
|
||||
el("p", { className: "hint", textContent: t("trust-fingerprint-hint") }));
|
||||
}
|
||||
$("server-action").textContent = key ? "continue" : "fetch the server's key";
|
||||
$("server-action").textContent = key ? t("signup-continue-button") : t("signup-fetch-key-button");
|
||||
$("server-error").textContent = "";
|
||||
}
|
||||
|
||||
|
|
@ -334,7 +342,7 @@ function setAccountMode(mode) {
|
|||
for (const button of document.querySelectorAll("#account-mode button"))
|
||||
button.classList.toggle("active", button.dataset.mode === mode);
|
||||
$("account-token-label").hidden = mode !== "new";
|
||||
$("account-action").textContent = mode === "new" ? "register" : "restore access";
|
||||
$("account-action").textContent = mode === "new" ? t("signup-register-button") : t("signup-recall-button");
|
||||
$("account-error").textContent = "";
|
||||
}
|
||||
|
||||
|
|
@ -361,10 +369,10 @@ $("account-form").onsubmit = async (event) => {
|
|||
return;
|
||||
}
|
||||
setAccountBusy(true);
|
||||
button.textContent = accountMode === "new" ? "registering…" : "restoring…";
|
||||
button.textContent = accountMode === "new" ? t("status-busy-registering") : t("status-busy-restoring");
|
||||
try {
|
||||
if (accountMode === "new") {
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
await registerOp(opened.session, opened.serverStatic,
|
||||
{ username: addr.user, identity: me, token: $("account-token").value.trim() });
|
||||
|
|
@ -372,11 +380,13 @@ $("account-form").onsubmit = async (event) => {
|
|||
opened.session.stream.close();
|
||||
}
|
||||
store.setAccount(addr);
|
||||
showApp();
|
||||
status(t("status-registered-note"));
|
||||
} else {
|
||||
await recallAndDiscoverRotation(store.master(), addr.short);
|
||||
showApp();
|
||||
status(t("status-restored-note", { index: store.rotations() }));
|
||||
}
|
||||
showApp();
|
||||
status(`welcome, ${addr.short}`);
|
||||
} catch (err) {
|
||||
$("account-error").textContent = err.message;
|
||||
} finally {
|
||||
|
|
@ -393,7 +403,7 @@ function renderAccount() {
|
|||
const me = store.identity();
|
||||
const account = store.account();
|
||||
$("account").textContent = me
|
||||
? (account ? `${account.user}@${account.host}` : "not registered")
|
||||
? (account ? `${account.user}@${account.host}` : t("account-not-registered-value"))
|
||||
: "";
|
||||
$("avatar").textContent = account ? account.user[0].toUpperCase() : "·";
|
||||
}
|
||||
|
|
@ -428,8 +438,8 @@ for (const button of $("folders").querySelectorAll("button"))
|
|||
function resetView() {
|
||||
$("app").classList.remove("reading");
|
||||
$("view").replaceChildren(placeholder(folder === "contacts"
|
||||
? "contacts and their keys are listed alongside"
|
||||
: "select a message"));
|
||||
? t("mail-contacts-reader-hint")
|
||||
: t("mail-empty-title")));
|
||||
}
|
||||
|
||||
// Counted from ids and read markers alone, independent of the visible folder —
|
||||
|
|
@ -447,16 +457,17 @@ async function renderFolder() {
|
|||
$("mail-tools").hidden = contacts;
|
||||
$("import-form").hidden = !contacts;
|
||||
// The filter never reaches past the open folder, so the field says which one.
|
||||
$("search").placeholder = `search ${folder}`;
|
||||
$("search").setAttribute("aria-label", `search ${folder}`);
|
||||
const searchLabel = t("mail-search-placeholder", { folder });
|
||||
$("search").placeholder = searchLabel;
|
||||
$("search").setAttribute("aria-label", searchLabel);
|
||||
if (contacts) return renderContacts(list);
|
||||
const token = ++renderToken;
|
||||
const rows = await store.listMessages(folder); // newest first
|
||||
if (token !== renderToken) return;
|
||||
list.replaceChildren();
|
||||
if (!rows.length) {
|
||||
list.append(placeholder(folder === "inbox" ? "no mail — fetch"
|
||||
: folder === "requests" ? "no unsolicited mail" : "nothing sent"));
|
||||
list.append(placeholder(folder === "inbox" ? t("mail-inbox-empty-title")
|
||||
: folder === "requests" ? t("mail-requests-empty-title") : t("mail-sent-empty-title")));
|
||||
return void updateListStatus();
|
||||
}
|
||||
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
||||
|
|
@ -482,11 +493,11 @@ function messageRow(row) {
|
|||
const who = el("div", { className: "who",
|
||||
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
||||
if (inbound && !opened.error && !opened.knownSender)
|
||||
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
|
||||
who.append(el("span", { className: "dot", title: t("mail-sender-unbound-tooltip") }));
|
||||
item.append(who,
|
||||
el("div", { className: "mid" },
|
||||
el("span", { className: "subject", textContent: opened.error
|
||||
? `<unreadable: ${opened.error}>` : (opened.subject || "(no subject)") }),
|
||||
? t("mail-row-unreadable-error", { error: opened.error }) : (opened.subject || t("mail-no-subject")) }),
|
||||
el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })),
|
||||
el("div", { className: "when",
|
||||
textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) }));
|
||||
|
|
@ -514,8 +525,9 @@ function updateListStatus() {
|
|||
const items = [...$("list").querySelectorAll(".item")];
|
||||
const shown = items.filter(item => !item.hidden).length;
|
||||
$("list-status").textContent = !items.length ? ""
|
||||
: shown === items.length ? `${items.length} message${items.length === 1 ? "" : "s"}`
|
||||
: `${shown} of ${items.length}`;
|
||||
: shown === items.length
|
||||
? t(items.length === 1 ? "mail-list-count-one" : "mail-list-count-other", { count: items.length })
|
||||
: t("mail-list-shown-of-total", { shown, total: items.length });
|
||||
}
|
||||
|
||||
$("search").oninput = () => {
|
||||
|
|
@ -595,7 +607,7 @@ async function openMessage(id) {
|
|||
// Under the narrow breakpoint the reader covers the list, so it needs a way back.
|
||||
$("app").classList.add("reading");
|
||||
const back = el("button", { type: "button", className: "back",
|
||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
|
||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("mail-back-to-list-button"));
|
||||
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
||||
|
||||
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
||||
|
|
@ -603,10 +615,10 @@ async function openMessage(id) {
|
|||
const actions = el("div", { className: "actions" });
|
||||
view.replaceChildren(...[
|
||||
back,
|
||||
el("h2", { textContent: opened.subject || "(no subject)" }),
|
||||
el("h2", { textContent: opened.subject || t("mail-no-subject") }),
|
||||
el("div", { className: "meta", textContent: folder === "sent"
|
||||
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
|
||||
: `from: ${opened.from}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}` }),
|
||||
? t("mail-meta-sent", { recipient: row.recipient, id: row.id, time: fmtTime(row.sentAt) })
|
||||
: t("mail-meta-received", { from: opened.from, id: row.id, time: fmtTime(opened.time) }) }),
|
||||
trustRow(opened),
|
||||
fields.length ? el("div", { className: "fields",
|
||||
textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null,
|
||||
|
|
@ -619,39 +631,37 @@ async function openMessage(id) {
|
|||
actions.append(el("button", { type: "button", onclick: async () => {
|
||||
try {
|
||||
await deleteMessage(folder, row);
|
||||
status("message deleted");
|
||||
status(t("status-deleted-note"));
|
||||
selectedId = null;
|
||||
resetView();
|
||||
renderFolder();
|
||||
} catch (err) {
|
||||
status(err.message, true); // the local copy is kept if a needed server delete failed
|
||||
}
|
||||
} }, "delete"));
|
||||
} }, t("action-delete")));
|
||||
|
||||
if (folder !== "inbox" && folder !== "requests") return;
|
||||
|
||||
if (folder === "requests" && opened.knownSender) {
|
||||
const known = store.addressForKey(opened.sender);
|
||||
if (!store.accepted(known)?.active) {
|
||||
actions.before(placeholder(
|
||||
`unsolicited mail from ${known}, not yet accepted into your main tier`));
|
||||
actions.before(placeholder(t("mail-request-unaccepted-hint", { address: known })));
|
||||
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
||||
"accept — future mail lands in your main tier"));
|
||||
t("mail-accept-button")));
|
||||
}
|
||||
}
|
||||
|
||||
if (opened.knownSender) {
|
||||
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
||||
icon("reply"), "reply"));
|
||||
icon("reply"), t("action-reply")));
|
||||
} else if (opened.replyAddress) {
|
||||
// The sender signed their own smol:// address into the payload, so binding
|
||||
// it is one click. A pinned conflicting key is refused (§8).
|
||||
actions.before(placeholder(
|
||||
`this message carries the sender's signed address: ${opened.replyAddress.short}`));
|
||||
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
|
||||
actions.before(placeholder(t("mail-signed-reply-hint", { address: opened.replyAddress.short })));
|
||||
actions.append(el("button", { type: "button", textContent: t("mail-save-sender-reply-button"), onclick: () => {
|
||||
try {
|
||||
bindReplyAddress(opened.replyAddress, opened.sender);
|
||||
status(`${opened.replyAddress.short} saved for this sender (verified key)`);
|
||||
status(t("contacts-sender-saved-note-verified", { address: opened.replyAddress.short }));
|
||||
renderFolder();
|
||||
openMessage(row.id);
|
||||
startReply(row);
|
||||
|
|
@ -663,7 +673,7 @@ async function openMessage(id) {
|
|||
// The wire gives only a signed key; a reply needs the sender's address,
|
||||
// which the user supplies once and we bind to that exact key.
|
||||
const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false });
|
||||
const submit = el("button", { textContent: "name sender & reply" });
|
||||
const submit = el("button", { textContent: t("mail-name-sender-button") });
|
||||
const form = el("form", { className: "name-sender" }, input, submit);
|
||||
form.onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -678,8 +688,7 @@ async function openMessage(id) {
|
|||
submit.disabled = false;
|
||||
}
|
||||
};
|
||||
actions.before(placeholder("The wire carries no sender name, only this signed key. " +
|
||||
"To reply, give the sender's address — it is checked against this key:"));
|
||||
actions.before(placeholder(t("mail-unknown-sender-hint")));
|
||||
actions.append(form);
|
||||
}
|
||||
}
|
||||
|
|
@ -694,8 +703,8 @@ async function deleteMessage(folder, row) {
|
|||
if (folder !== "sent" && row.keptOnServer) {
|
||||
const me = store.identity();
|
||||
const addr = accountAddress();
|
||||
if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message");
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
if (!me || !addr) throw new SmolError(t("mail-delete-not-registered-error"));
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
await deleteOp(opened.session, [unhex(row.id)]);
|
||||
|
|
@ -712,10 +721,10 @@ async function deleteMessage(folder, row) {
|
|||
function trustRow(opened) {
|
||||
if (folder === "sent")
|
||||
return el("div", { className: "trust" },
|
||||
el("span", { className: "badge", textContent: "your own sealed copy" }));
|
||||
const chip = opened.verifiedSender ? ["badge ok", "verified sender key"]
|
||||
: opened.knownSender ? ["badge", "key pinned on first use"]
|
||||
: ["badge alert", "key bound to no address"];
|
||||
el("span", { className: "badge", textContent: t("mail-trust-own-copy") }));
|
||||
const chip = opened.verifiedSender ? ["badge ok", t("mail-trust-verified-sender")]
|
||||
: opened.knownSender ? ["badge", t("mail-trust-tofu-sender")]
|
||||
: ["badge alert", t("mail-trust-unbound-sender")];
|
||||
return el("div", { className: "trust" },
|
||||
el("span", { className: chip[0], textContent: chip[1] }),
|
||||
el("div", { className: "keyblock" },
|
||||
|
|
@ -724,7 +733,7 @@ function trustRow(opened) {
|
|||
}
|
||||
|
||||
const replySubject = (subject) =>
|
||||
!subject ? "" : subject.startsWith("Re:") ? subject : `Re: ${subject}`;
|
||||
!subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { subject });
|
||||
|
||||
// One path for all three ways a reply can start, so the Re: rule lives once.
|
||||
function startReply(row) {
|
||||
|
|
@ -746,14 +755,13 @@ async function saveContactChecked(address, keyBytes, verified) {
|
|||
const existing = store.contact(address);
|
||||
if (existing && !timingSafeEqual(existing.key, keyBytes)) {
|
||||
const ok = await confirmDialog({
|
||||
title: `${address} already has a different key`,
|
||||
body: `existing fingerprint: ${fingerprint(existing.key)}\n` +
|
||||
`new fingerprint: ${fingerprint(keyBytes)}\n\n` +
|
||||
`Overwriting trusts the new key for this address from now on.`,
|
||||
confirmLabel: "use the new key",
|
||||
title: t("key-change-dialog-title"),
|
||||
body: t("key-change-dialog-body", { address,
|
||||
known_fingerprint: fingerprint(existing.key), offered_fingerprint: fingerprint(keyBytes) }),
|
||||
confirmLabel: t("key-change-dialog-replace-button"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) throw new SmolError(`kept the existing key for ${address}`);
|
||||
if (!ok) throw new SmolError(t("contacts-key-kept-note", { address }));
|
||||
}
|
||||
store.saveContact(address, keyBytes, verified);
|
||||
}
|
||||
|
|
@ -767,7 +775,7 @@ async function nameSender(text, senderKey) {
|
|||
const addr = parseAddress(text.trim());
|
||||
let key = addr.identity, verified = true;
|
||||
if (!key) {
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
({ identity: key } = await resolveOp(opened.session, addr.user));
|
||||
} finally {
|
||||
|
|
@ -776,9 +784,10 @@ async function nameSender(text, senderKey) {
|
|||
verified = false; // trust on first use, as with any RESOLVE
|
||||
}
|
||||
if (!timingSafeEqual(key, senderKey))
|
||||
throw new SmolError("that address carries a different key than this message's sender");
|
||||
throw new SmolError(t("contacts-sender-key-mismatch-error"));
|
||||
await saveContactChecked(addr.short, senderKey, verified);
|
||||
status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`);
|
||||
status(t(verified ? "contacts-sender-saved-note-verified" : "contacts-sender-saved-note-unverified",
|
||||
{ address: addr.short }));
|
||||
renderFolder();
|
||||
}
|
||||
|
||||
|
|
@ -791,8 +800,7 @@ async function nameSender(text, senderKey) {
|
|||
function bindReplyAddress(addr, senderKey) {
|
||||
const existing = store.contact(addr.short);
|
||||
if (existing && !timingSafeEqual(existing.key, senderKey))
|
||||
throw new SmolError(`${addr.short} is already known with a different key — ` +
|
||||
`verify out of band before replying`);
|
||||
throw new SmolError(t("contacts-reply-key-conflict-error", { address: addr.short }));
|
||||
store.saveContact(addr.short, senderKey, true);
|
||||
}
|
||||
|
||||
|
|
@ -838,7 +846,8 @@ function showUnpinnedPrompt(addr) {
|
|||
try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; }
|
||||
if (live !== addr.short) return;
|
||||
unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null };
|
||||
$("compose-unpinned-host").textContent = addr.host;
|
||||
$("compose-unpinned-title").textContent = t("compose-pin-title", { host: addr.host });
|
||||
$("compose-unpinned-body").textContent = t("compose-pin-body", { address: addr.short });
|
||||
$("compose-unpinned").hidden = false;
|
||||
}
|
||||
|
||||
|
|
@ -898,10 +907,9 @@ $("compose-unpinned-fetch").onclick = async () => {
|
|||
unpinnedPrompt.fetchedKey = opened.serverStatic;
|
||||
$("compose-unpinned-key-area").hidden = false;
|
||||
$("compose-unpinned-key-area").replaceChildren(
|
||||
field("fingerprint", fingerprint(opened.serverStatic)),
|
||||
field("key", b32encode(opened.serverStatic)),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Compare this against what the operator published through a trusted channel." }));
|
||||
field(t("detail-fingerprint-label"), fingerprint(opened.serverStatic)),
|
||||
field(t("detail-key-label"), b32encode(opened.serverStatic)),
|
||||
el("p", { className: "hint", textContent: t("trust-fingerprint-hint") }));
|
||||
$("compose-unpinned-fetch").hidden = true;
|
||||
$("compose-unpinned-pin").hidden = false;
|
||||
} catch (err) {
|
||||
|
|
@ -930,7 +938,7 @@ $("compose-unpinned-pin").onclick = async () => {
|
|||
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||
const me = store.identity();
|
||||
const master = store.master();
|
||||
if (!me || !master) throw new SmolError("no identity yet");
|
||||
if (!me || !master) throw new SmolError(t("identity-missing-error"));
|
||||
const addr = parseAddress(toText);
|
||||
const recipient = await resolveRecipient(addr);
|
||||
const account = accountAddress();
|
||||
|
|
@ -948,7 +956,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
|||
// §5.8: our token for their mailbox, if they have given us one.
|
||||
const held = store.tokenFrom(addr.short);
|
||||
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
await sendOp(opened.session, envelope, mac);
|
||||
} finally {
|
||||
|
|
@ -961,7 +969,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
|||
envelope: seal(me, me.publicKey, bodyBytes),
|
||||
sentAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`);
|
||||
status(t(mac ? "mail-sent-note-accepted" : "mail-sent-note", { address: addr.short }));
|
||||
if (folder === "sent") renderFolder();
|
||||
}
|
||||
|
||||
|
|
@ -990,7 +998,7 @@ async function resolveRecipient(addr) {
|
|||
if (known) return known.key;
|
||||
if (store.requirePinnedServer() && !store.serverPin(addr.host))
|
||||
throw new UnpinnedServerError(addr.host);
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
const opened = await connect(addr);
|
||||
let current;
|
||||
try {
|
||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||
|
|
@ -998,7 +1006,7 @@ async function resolveRecipient(addr) {
|
|||
opened.session.stream.close();
|
||||
}
|
||||
store.saveContact(addr.short, current, false);
|
||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
||||
status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short }));
|
||||
return current;
|
||||
}
|
||||
|
||||
|
|
@ -1038,13 +1046,13 @@ $("fetch").onclick = async () => {
|
|||
const me = store.identity();
|
||||
const master = store.master();
|
||||
const addr = accountAddress();
|
||||
if (!me || !master) return status("no identity yet", true);
|
||||
if (!addr) return status("not registered yet — claim an address in settings", true);
|
||||
if (!me || !master) return status(t("identity-missing-error"), true);
|
||||
if (!addr) return status(t("account-not-registered-fetch-warning"), true);
|
||||
const button = $("fetch"), label = button.querySelector(".label");
|
||||
const resting = label.textContent;
|
||||
button.disabled = true; // one session at a time; the icon spins while it is
|
||||
label.textContent = "fetching…";
|
||||
status("fetching…");
|
||||
label.textContent = t("status-busy-fetching");
|
||||
status(t("status-busy-fetching"));
|
||||
let stored = 0, verified = 0;
|
||||
const rejected = [];
|
||||
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
||||
|
|
@ -1054,7 +1062,7 @@ $("fetch").onclick = async () => {
|
|||
const leaveOnServer = store.leaveOnServer();
|
||||
let [afterTime, afterId] = store.cursor();
|
||||
try {
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
const { sync, tokens } = store.tokenSet(master);
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||
|
|
@ -1068,7 +1076,7 @@ $("fetch").onclick = async () => {
|
|||
let unsealed;
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||
throw new SmolError("id does not match the envelope");
|
||||
throw new SmolError(t("mail-envelope-id-mismatch-error"));
|
||||
unsealed = unseal(store.identities(), record.envelope);
|
||||
} catch (err) {
|
||||
// Left on the server rather than destroyed, so a client-side bug
|
||||
|
|
@ -1110,10 +1118,11 @@ $("fetch").onclick = async () => {
|
|||
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
||||
// record left on the server (rejected above) simply resurfaces then.
|
||||
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
||||
status([`${stored} new, ${rejected.length} rejected`,
|
||||
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
||||
status([t("mail-fetch-summary-counts", { stored, rejected: rejected.length }),
|
||||
verified ? t(verified === 1 ? "mail-fetch-summary-verified-one" : "mail-fetch-summary-verified-other",
|
||||
{ verified }) : null,
|
||||
...rejected.map(line => ` ${line}`),
|
||||
rejected.length ? "left on the server" : null].filter(Boolean).join("\n"),
|
||||
rejected.length ? t("mail-fetch-summary-left-on-server") : null].filter(Boolean).join("\n"),
|
||||
rejected.length > 0);
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
|
|
@ -1141,16 +1150,16 @@ function renderContacts(list) {
|
|||
el("div", { className: "mid" },
|
||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||
el("span", { className: verified ? "badge ok" : "badge",
|
||||
textContent: verified ? "verified key" : "tofu" }),
|
||||
accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null,
|
||||
textContent: t(verified ? "contact-state-verified" : "contact-state-tofu") }),
|
||||
accepted?.active ? el("span", { className: "badge ok", textContent: t("contact-state-accepted") }) : null,
|
||||
history.length ? el("span", { className: "badge",
|
||||
textContent: `${history.length} previous` }) : null));
|
||||
textContent: t("contacts-history-badge", { count: history.length }) }) : null));
|
||||
item.dataset.search =
|
||||
`${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase();
|
||||
return item;
|
||||
}));
|
||||
if (!contacts.length)
|
||||
list.append(placeholder("no contacts yet — import a smol:// address above, or write to someone"));
|
||||
list.append(placeholder(t("contacts-import-empty-hint")));
|
||||
}
|
||||
|
||||
// The full key, the address to hand out, and any key this one replaced — the
|
||||
|
|
@ -1172,11 +1181,11 @@ function openContact(address) {
|
|||
} finally {
|
||||
event.target.disabled = false;
|
||||
}
|
||||
} }, icon("refresh"), "re-resolve");
|
||||
} }, icon("refresh"), t("contacts-refresh-button"));
|
||||
|
||||
const isAccepted = Boolean(store.accepted(address)?.active);
|
||||
const tokenButton = el("button", { type: "button",
|
||||
textContent: isAccepted ? "block" : "accept",
|
||||
textContent: t(isAccepted ? "action-block" : "action-accept"),
|
||||
onclick: async (event) => {
|
||||
event.target.disabled = true;
|
||||
try {
|
||||
|
|
@ -1188,32 +1197,29 @@ function openContact(address) {
|
|||
|
||||
view.replaceChildren(...[
|
||||
el("button", { type: "button", className: "back",
|
||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
|
||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("contacts-caption")),
|
||||
el("h2", { textContent: address }),
|
||||
el("div", { className: "trust" },
|
||||
el("span", { className: known.verified ? "badge ok" : "badge",
|
||||
textContent: known.verified ? "verified key" : "key pinned on first use" })),
|
||||
field("fingerprint", fingerprint(known.key)),
|
||||
field("public key", b32encode(known.key)),
|
||||
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")),
|
||||
field("server", addr.host, false,
|
||||
textContent: t(known.verified ? "contact-state-verified" : "contact-state-tofu") })),
|
||||
field(t("detail-fingerprint-label"), fingerprint(known.key)),
|
||||
field(t("detail-key-label"), b32encode(known.key)),
|
||||
field(t("detail-address-label"), addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")),
|
||||
field(t("detail-server-label"), addr.host, false,
|
||||
el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge",
|
||||
textContent: store.serverPin(addr.host) ? "pinned" : "not pinned" })),
|
||||
textContent: t(store.serverPin(addr.host) ? "contacts-server-pinned-badge" : "contacts-server-not-pinned-badge") })),
|
||||
el("div", { className: "seed-card" },
|
||||
el("h4", { textContent: "accept token" }),
|
||||
el("p", { className: "hint", textContent: isAccepted
|
||||
? "This contact's mail lands in your main tier."
|
||||
: "This contact's mail lands in requests until accepted." }),
|
||||
el("h4", { textContent: t("contacts-accept-token-title") }),
|
||||
el("p", { className: "hint", textContent: t(isAccepted ? "contacts-accepted-hint" : "contacts-unaccepted-hint") }),
|
||||
el("div", { className: "actions" }, tokenButton)),
|
||||
known.history.length ? el("section", { className: "history" },
|
||||
el("h3", { textContent: `previous keys (${known.history.length})` }),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Replaced by a signed rotation chain. Mail signed with these was sent " +
|
||||
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
|
||||
el("h3", { textContent: t("contacts-history-title", { count: known.history.length }) }),
|
||||
el("p", { className: "hint", textContent: t("contacts-history-hint") }),
|
||||
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||
el("div", { className: "full", textContent: key }),
|
||||
el("div", { className: "replaced", textContent: `replaced ${fmtTime(Math.floor(until / 1000))}` }))))
|
||||
el("div", { className: "replaced",
|
||||
textContent: t("contacts-history-replaced", { time: fmtTime(Math.floor(until / 1000)) }) }))))
|
||||
: null,
|
||||
el("div", { className: "actions" }, refresh),
|
||||
].filter(node => node != null));
|
||||
|
|
@ -1224,9 +1230,9 @@ function openContact(address) {
|
|||
async function refreshContact(address) {
|
||||
try {
|
||||
const addr = parseAddress(address);
|
||||
if (addr.identity) throw new SmolError("that address already carries a key; use import instead");
|
||||
if (addr.identity) throw new SmolError(t("contacts-refresh-embedded-key-error"));
|
||||
const known = store.contact(addr.short);
|
||||
const opened = await connect(addr, { requirePin: false });
|
||||
const opened = await connect(addr);
|
||||
let current, chain;
|
||||
try {
|
||||
({ identity: current, chain } = await resolveOp(opened.session, addr.user));
|
||||
|
|
@ -1235,15 +1241,14 @@ async function refreshContact(address) {
|
|||
}
|
||||
if (!known) {
|
||||
store.saveContact(addr.short, current, false);
|
||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
||||
status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short }));
|
||||
} else if (timingSafeEqual(known.key, current)) {
|
||||
status(`${addr.short}: key unchanged`);
|
||||
status(t("contacts-refresh-unchanged-note", { address: addr.short }));
|
||||
} else if (walkChain(addr.user, known.key, current, chain)) {
|
||||
store.saveContact(addr.short, current, known.verified);
|
||||
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
||||
status(t("contacts-refresh-rotated-note", { address: addr.short, key: b32encode(current) }), true);
|
||||
} else {
|
||||
status(`${addr.short} presents a different key with no valid rotation chain.\n` +
|
||||
`Verify out of band, then import the new smol:// address.`, true);
|
||||
status(t("contacts-refresh-no-chain-warning", { address: addr.short }), true);
|
||||
}
|
||||
} catch (err) {
|
||||
status(err.message, true);
|
||||
|
|
@ -1260,12 +1265,12 @@ async function pushTokens() {
|
|||
const me = store.identity();
|
||||
const master = store.master();
|
||||
const addr = accountAddress();
|
||||
if (!me || !master) throw new SmolError("no identity yet");
|
||||
if (!me || !master) throw new SmolError(t("identity-missing-error"));
|
||||
if (!addr) {
|
||||
status("not registered; the set will be pushed with your first fetch", true);
|
||||
status(t("account-not-registered-tokens-note"), true);
|
||||
return 0;
|
||||
}
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
const { sync, tokens } = store.tokenSet(master);
|
||||
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||
|
|
@ -1279,11 +1284,11 @@ async function pushTokens() {
|
|||
async function acceptContact(address) {
|
||||
try {
|
||||
const key = store.contact(address)?.key;
|
||||
if (!key) throw new SmolError(`no key for ${address} yet`);
|
||||
if (!key) throw new SmolError(t("contacts-no-key-error", { address }));
|
||||
store.accept(address, key);
|
||||
store.setSyncOk(true);
|
||||
const held = await pushTokens();
|
||||
status(`${address} accepted; server now holds ${held} accept token(s)`);
|
||||
status(t(held === 1 ? "contacts-accepted-note-one" : "contacts-accepted-note-other", { address, held }));
|
||||
renderFolder();
|
||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||
} catch (err) {
|
||||
|
|
@ -1297,7 +1302,7 @@ async function blockContact(address) {
|
|||
try {
|
||||
store.block(address);
|
||||
const held = await pushTokens();
|
||||
status(`${address} blocked; server now holds ${held} accept token(s)`);
|
||||
status(t(held === 1 ? "contacts-blocked-note-one" : "contacts-blocked-note-other", { address, held }));
|
||||
renderFolder();
|
||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||
} catch (err) {
|
||||
|
|
@ -1377,13 +1382,14 @@ function renderServerPanel() {
|
|||
const host = account?.host ?? null;
|
||||
const key = host ? store.serverPin(host) : null;
|
||||
$("server-connection").replaceChildren(
|
||||
field("Host", host ?? "not registered yet", !host, host ? copyButton(host, "host") : null),
|
||||
field("Public Key", key ? b32encode(key) : "not pinned yet", !key,
|
||||
field(t("settings-field-host"), host ?? t("settings-not-registered-value"), !host,
|
||||
host ? copyButton(host, "host") : null),
|
||||
field(t("settings-field-public-key"), key ? b32encode(key) : t("settings-public-key-not-pinned"), !key,
|
||||
key ? copyButton(b32encode(key), "public key") : null),
|
||||
el("div", { className: "spin-row" },
|
||||
el("div", { className: "spin-text" },
|
||||
el("span", { className: "check-title", textContent: "Timeout" }),
|
||||
el("span", { className: "hint", textContent: "Seconds before a network call gives up" })),
|
||||
el("span", { className: "check-title", textContent: t("server-timeout-title") }),
|
||||
el("span", { className: "hint", textContent: t("server-timeout-subtitle") })),
|
||||
el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(),
|
||||
onchange: (event) => {
|
||||
store.setTimeoutSeconds(event.target.value);
|
||||
|
|
@ -1400,14 +1406,15 @@ function renderIdentityPanel() {
|
|||
secret.replaceChildren();
|
||||
if (!me) return;
|
||||
|
||||
const address = account ? `${account.user}@${account.host}` : "not registered yet";
|
||||
const address = account ? `${account.user}@${account.host}` : t("settings-not-registered-value");
|
||||
const key = b32encode(me.publicKey);
|
||||
const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one";
|
||||
const uri = account ? accountAddress().uri(me.publicKey) : t("settings-uri-claim-hint");
|
||||
fields.append(
|
||||
field("Address", address, !account, account ? copyButton(address, "address") : null),
|
||||
field("Key", key, false, copyButton(key, "key")),
|
||||
field("Fingerprint", fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")),
|
||||
field("URI", uri, !account, account ? copyButton(uri, "uri") : null));
|
||||
field(t("settings-field-address"), address, !account, account ? copyButton(address, "address") : null),
|
||||
field(t("settings-field-key"), key, false, copyButton(key, "key")),
|
||||
field(t("settings-field-fingerprint"), fingerprint(me.publicKey), false,
|
||||
copyButton(fingerprint(me.publicKey), "fingerprint")),
|
||||
field(t("settings-field-uri"), uri, !account, account ? copyButton(uri, "uri") : null));
|
||||
|
||||
const masterBytes = store.master();
|
||||
if (!masterBytes) return;
|
||||
|
|
@ -1416,22 +1423,22 @@ function renderIdentityPanel() {
|
|||
const masked = "\u2022".repeat(64);
|
||||
const seedText = hex(masterBytes);
|
||||
const seed = el("div", { className: "seed", textContent: masked });
|
||||
const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret",
|
||||
const reveal = el("button", { type: "button", textContent: t("settings-secret-reveal-button"),
|
||||
title: t("settings-secret-reveal-tooltip"),
|
||||
onclick: (event) => {
|
||||
const showing = seed.textContent !== masked;
|
||||
seed.textContent = showing ? masked : seedText;
|
||||
event.currentTarget.textContent = showing ? "reveal" : "hide";
|
||||
event.currentTarget.title = showing ? "reveal the recovery secret" : "hide the recovery secret";
|
||||
event.currentTarget.textContent = t(showing ? "settings-secret-reveal-button" : "settings-secret-hide-button");
|
||||
event.currentTarget.title = t(showing ? "settings-secret-reveal-tooltip" : "settings-secret-hide-tooltip");
|
||||
} });
|
||||
|
||||
secret.append(el("div", { className: "seed-card" },
|
||||
el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")),
|
||||
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
|
||||
"it loses the address with it — there is no reset. Keep it in a password manager." })));
|
||||
el("p", { textContent: t("settings-secret-warning") })));
|
||||
}
|
||||
|
||||
function renderAdvancedPanel() {
|
||||
$("rotations-count").replaceChildren(field("Completed", String(store.rotations())));
|
||||
$("rotations-count").replaceChildren(field(t("advanced-rotations-completed"), String(store.rotations())));
|
||||
$("rotate-error").textContent = "";
|
||||
$("logout-error").textContent = "";
|
||||
}
|
||||
|
|
@ -1445,9 +1452,9 @@ function renderAdvancedPanel() {
|
|||
function cleanHost(hostText) {
|
||||
const host = hostText.trim().toLowerCase();
|
||||
if (host.includes("@"))
|
||||
throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`);
|
||||
throw new SmolError(t("signup-host-looks-like-address-error", { host }));
|
||||
if (host.includes(":"))
|
||||
throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`);
|
||||
throw new SmolError(t("signup-host-has-port-error", { host }));
|
||||
return host;
|
||||
}
|
||||
|
||||
|
|
@ -1456,10 +1463,10 @@ $("import-form").onsubmit = async (event) => {
|
|||
const form = event.target;
|
||||
try {
|
||||
const addr = parseAddress(form.uri.value.trim());
|
||||
if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key");
|
||||
if (!addr.identity) throw new SmolError(t("contacts-import-needs-key"));
|
||||
await saveContactChecked(addr.short, addr.identity, true);
|
||||
status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
|
||||
`fingerprint: ${fingerprint(addr.identity)}`);
|
||||
status(t("contacts-import-detail-note",
|
||||
{ address: addr.short, key: b32encode(addr.identity), fingerprint: fingerprint(addr.identity) }));
|
||||
renderFolder();
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
|
|
@ -1473,7 +1480,7 @@ $("import-form").onsubmit = async (event) => {
|
|||
// guess of index 0. Also binds "account" — onboarding's only path to it now.
|
||||
async function recallAndDiscoverRotation(masterBytes, text) {
|
||||
const addr = parseAddress(text.trim());
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
const opened = await connect(addr);
|
||||
let current;
|
||||
try {
|
||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||
|
|
@ -1488,8 +1495,7 @@ async function recallAndDiscoverRotation(masterBytes, text) {
|
|||
}
|
||||
}
|
||||
if (found === null)
|
||||
throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` +
|
||||
`within ${MAX_CHAIN} rotations`);
|
||||
throw new SmolError(t("recall-rotation-not-found-error", { address: addr.short, max: MAX_CHAIN }));
|
||||
store.setRotationIndex(found);
|
||||
store.setAccount(addr);
|
||||
return addr;
|
||||
|
|
@ -1508,7 +1514,7 @@ $("export-data").onclick = async () => {
|
|||
const text = JSON.stringify(data);
|
||||
if (window.showSaveFilePicker) {
|
||||
const handle = await window.showSaveFilePicker({ suggestedName: filename,
|
||||
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
|
||||
types: [{ description: t("backup-export-dialog-title"), accept: { "application/json": [".json"] } }] });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
|
|
@ -1537,7 +1543,7 @@ $("import-data-trigger").onclick = async () => {
|
|||
if (!window.showOpenFilePicker) return $("import-data").click();
|
||||
try {
|
||||
const [handle] = await window.showOpenFilePicker({
|
||||
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
|
||||
types: [{ description: t("backup-import-dialog-title"), accept: { "application/json": [".json"] } }] });
|
||||
await importBackupText(await (await handle.getFile()).text());
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") $("backup-error").textContent = err.message;
|
||||
|
|
@ -1570,21 +1576,22 @@ async function rotateIdentity() {
|
|||
const addr = accountAddress();
|
||||
$("rotate-error").textContent = "";
|
||||
if (!me || !master || !addr) {
|
||||
$("rotate-error").textContent = "rotate needs a registered account";
|
||||
$("rotate-error").textContent = t("advanced-rotate-needs-account-error");
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Rotate the identity?", body: panelText("rotations-description"), confirmLabel: "Rotate" });
|
||||
title: t("rotate-dialog-title"), body: panelText("rotations-description"),
|
||||
confirmLabel: t("rotate-dialog-confirm-button") });
|
||||
if (!confirmed) return;
|
||||
const button = $("rotate-identity");
|
||||
const resting = button.textContent;
|
||||
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||
button.textContent = "rotating…";
|
||||
button.textContent = t("status-busy-rotating");
|
||||
try {
|
||||
const freshSeed = identitySeed(master, store.rotations() + 1);
|
||||
const fresh = identityFromSeed(freshSeed);
|
||||
const cert = makeCert(addr.user, me, freshSeed);
|
||||
const opened = await connect(addr, { requirePin: true });
|
||||
const opened = await connect(addr);
|
||||
try {
|
||||
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
||||
} finally {
|
||||
|
|
@ -1608,8 +1615,8 @@ $("rotate-identity").onclick = rotateIdentity;
|
|||
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
||||
async function logout() {
|
||||
$("logout-error").textContent = "";
|
||||
const confirmed = await confirmDialog({ title: "Log out and delete all data?",
|
||||
body: panelText("logout-description"), confirmLabel: "Log Out", danger: true });
|
||||
const confirmed = await confirmDialog({ title: t("reset-dialog-title"),
|
||||
body: panelText("logout-description"), confirmLabel: t("reset-dialog-confirm-button"), danger: true });
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await store.clearAll();
|
||||
|
|
|
|||
31
web/js/i18n.js
Normal file
31
web/js/i18n.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Strings, loaded once from the flat dictionary scripts/build-locale.mjs
|
||||
// generates from locales/en-US/main.ftl. No Fluent parser ships here — the
|
||||
// build step already resolved the source file's syntax down to plain
|
||||
// `{ $var }` placeholders, which is all this needs to substitute.
|
||||
|
||||
let messages = null;
|
||||
|
||||
export async function loadLocale() {
|
||||
const response = await fetch("locales/en-US.json");
|
||||
messages = await response.json();
|
||||
}
|
||||
|
||||
// Falls back to the id itself on a miss, so a typo'd or forgotten call
|
||||
// shows up as visibly wrong text in the UI rather than disappearing.
|
||||
export function t(id, args = {}) {
|
||||
const value = messages?.[id];
|
||||
if (value === undefined) return id;
|
||||
return value.replace(/\{\s*\$(\w+)\s*\}/g, (_, name) => args[name] ?? "");
|
||||
}
|
||||
|
||||
// index.html's static markup keeps its literal English text and gains
|
||||
// data-l10n-* attributes alongside it, so the page still reads correctly
|
||||
// even if this runs late — this pass just overwrites it from the same
|
||||
// English source, proving the wiring for whenever a second locale exists.
|
||||
export function applyStaticLocale(root = document) {
|
||||
for (const el of root.querySelectorAll("[data-l10n-id]")) el.textContent = t(el.dataset.l10nId);
|
||||
for (const el of root.querySelectorAll("[data-l10n-placeholder]")) el.placeholder = t(el.dataset.l10nPlaceholder);
|
||||
for (const el of root.querySelectorAll("[data-l10n-title]")) el.title = t(el.dataset.l10nTitle);
|
||||
for (const el of root.querySelectorAll("[data-l10n-aria-label]"))
|
||||
el.setAttribute("aria-label", t(el.dataset.l10nAriaLabel));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue