feat: restyle the ui in gmail's design language

This commit is contained in:
randogoth 2026-09-26 16:52:40 +03:00
parent a6b972fec9
commit 9842302c50
3 changed files with 288 additions and 132 deletions

View file

@ -12,14 +12,28 @@ import { connectStream } from "./transport.js";
const $ = (id) => document.getElementById(id);
// The status line is a toast: click to dismiss, empty means hidden.
function status(text, warn = false) {
$("status").textContent = text;
$("status").className = warn ? "status warn" : "status";
const toast = $("status");
toast.textContent = text;
toast.hidden = !text;
toast.className = "toast" + (warn ? " warn" : "");
}
$("status").onclick = () => status("");
const fmtTime = (seconds) =>
new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
// Gmail-style row stamp: time for today, date within this year, full date older.
const fmtRowTime = (seconds) => {
const date = new Date(seconds * 1000), today = new Date();
if (date.toDateString() === today.toDateString())
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (date.getFullYear() === today.getFullYear())
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
};
// The stored account as an address object, default port included when custom.
function accountAddress() {
const account = store.account();
@ -86,8 +100,9 @@ function renderAccount() {
const me = store.identity();
const account = store.account();
$("account").textContent = me
? `${account ? account.user + "@" + account.host : "(not registered)"} · ${fingerprint(me.publicKey)}`
? (account ? `${account.user}@${account.host}` : "not registered")
: "";
$("avatar").textContent = account ? account.user[0].toUpperCase() : "·";
}
// --- folders and list ---------------------------------------------------------
@ -106,30 +121,47 @@ for (const button of $("folders").querySelectorAll("button"))
async function renderFolder() {
const list = $("list");
if (folder === "contacts") return renderContacts(list);
const rows = await store.listMessages(folder);
const rows = [...await store.listMessages(folder)].reverse(); // newest first
list.replaceChildren();
let unread = 0;
for (const row of rows) {
const opened = describe(row);
const isUnread = !store.isRead(row.id);
if (folder === "inbox" && isUnread) unread++;
const item = document.createElement("div");
item.className = "item" + (row.id === selectedId ? " selected" : "");
item.className = `item${row.id === selectedId ? " selected" : ""}${isUnread ? " unread" : ""}`;
const who = document.createElement("div");
who.className = "who" + (store.isRead(row.id) ? "" : " unread");
who.className = "who";
who.textContent = folder === "sent" ? row.recipient : (opened.from ?? "?");
const subject = document.createElement("div");
const mid = document.createElement("div");
mid.className = "mid";
const subject = document.createElement("span");
subject.className = "subject";
subject.textContent = opened.error ? `<unreadable: ${opened.error}>`
: (opened.subject || "(no subject)");
const when = document.createElement("span");
const snippet = document.createElement("span");
snippet.className = "snippet";
snippet.textContent = opened.error ? "" : ` — ${opened.snippet}`;
mid.append(subject, snippet);
const when = document.createElement("div");
when.className = "when";
when.textContent = fmtTime(folder === "sent" ? row.sentAt : opened.time ?? row.receivedAt);
item.append(who, subject, when);
when.textContent = fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt));
item.append(who, mid, when);
item.onclick = () => openMessage(row.id);
list.append(item);
}
$("count-inbox").textContent = folder === "inbox" && unread ? String(unread) : "";
if (!rows.length) list.append(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" }));
}
// The header search filters the rendered list client-side; nothing leaves the page.
$("search").oninput = () => {
const query = $("search").value.trim().toLowerCase();
for (const item of $("list").querySelectorAll(".item"))
item.hidden = Boolean(query) && !item.textContent.toLowerCase().includes(query);
};
// Open an envelope and split its body; anything unreadable shows as such
// rather than throwing, so one bad message cannot blank the list. The wire
// carries no sender name, so an unknown key can only be shown as its
@ -154,6 +186,7 @@ function describe(row) {
...opened,
fields, body,
subject: fields.Subject || "",
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140),
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
knownSender: Boolean(known),
replyAddress,
@ -174,21 +207,26 @@ async function openMessage(id) {
const opened = describe(row);
const view = $("view");
if (opened.error) {
view.innerHTML = `<p class="placeholder warn">${opened.error}</p>`;
view.replaceChildren();
const note = document.createElement("p");
note.className = "placeholder warn";
note.textContent = opened.error;
view.append(note);
return;
}
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
view.innerHTML = `<h2></h2><div class="meta"></div><div class="fields"></div>` +
`<div class="verified">signature verified</div><pre></pre>`;
`<div class="verified">signature verified</div><pre></pre><div class="actions"></div>`;
view.querySelector("h2").textContent = opened.subject || "(no subject)";
view.querySelector(".meta").textContent = folder === "sent"
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
: `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
view.querySelector("pre").textContent = opened.body;
const actions = view.querySelector(".actions");
if (folder === "inbox") {
if (opened.knownSender) {
view.append(replyButton(opened, row));
actions.append(replyButton(opened, row));
} 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).
@ -196,6 +234,7 @@ async function openMessage(id) {
note.className = "placeholder";
note.textContent =
`this message carries the sender's signed address: ${opened.replyAddress.short}`;
actions.before(note);
const save = document.createElement("button");
save.textContent = "save sender & reply";
save.onclick = () => {
@ -211,7 +250,7 @@ async function openMessage(id) {
status(err.message, true);
}
};
view.append(note, save);
actions.append(save);
} else {
// 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.
@ -244,7 +283,7 @@ async function openMessage(id) {
name.disabled = false;
}
};
view.append(note, form);
actions.append(note, form);
}
}
}
@ -436,13 +475,13 @@ function renderContacts(list) {
const who = document.createElement("div");
who.className = "who";
who.textContent = address;
const fp = document.createElement("div");
fp.className = "subject";
fp.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
const mid = document.createElement("div");
mid.className = "mid";
mid.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
const refresh = document.createElement("button");
refresh.textContent = "refresh";
refresh.onclick = () => refreshContact(address);
item.append(who, fp, refresh);
item.append(who, mid, refresh);
list.append(item);
}
if (!contacts.length) list.append(Object.assign(document.createElement("p"),
@ -497,14 +536,28 @@ function renderSettings() {
const me = store.identity();
const account = store.account();
const card = $("identity-card");
if (!me) { card.textContent = ""; return; }
const share = account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)";
card.innerHTML = `<div>fingerprint: ${fingerprint(me.publicKey)}</div>` +
`<div>address: ${account ? account.user + "@" + account.host : "(not registered)"}</div>` +
`<div>share: ${share}</div>` +
`<div class="seed">${hex(me.seed)}</div>` +
`<div class="warn">back this seed up; it is the only secret</div>` +
`<div>retired keys: ${store.identities().length - 1} (kept to read old mail)</div>`;
card.replaceChildren();
if (!me) return;
const lines = [
`fingerprint: ${fingerprint(me.publicKey)}`,
`address: ${account ? account.user + "@" + account.host : "(not registered)"}`,
`share: ${account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)"}`,
"",
];
for (const text of lines) {
const div = document.createElement("div");
div.textContent = text;
card.append(div);
}
const seed = document.createElement("div");
seed.className = "seed";
seed.textContent = hex(me.seed);
const warning = document.createElement("div");
warning.className = "warn";
warning.textContent = "back this seed up; it is the only secret";
const retired = document.createElement("div");
retired.textContent = `retired keys: ${store.identities().length - 1} (kept to read old mail)`;
card.append(seed, warning, retired);
const pins = $("pins");
pins.replaceChildren();