feat: restyle the ui in gmail's design language
This commit is contained in:
parent
a6b972fec9
commit
9842302c50
3 changed files with 288 additions and 132 deletions
107
web/js/app.js
107
web/js/app.js
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue