hermes: ship the browser side of the Theseus feature set
Fixes the "unknown invoke: fetchCloudBackup" dead-end on mobile sign-in and the empty hooks the Settings UI depended on. The shim now implements: - Local wrap: on signup it generates a BIP-39 mnemonic (@scure/bip39), derives the Nostr sk via PBKDF2+HKDF with purpose "messenger/0" (same chain Theseus runs), scrypt+AES-GCM wraps the mnemonic (@noble/hashes scrypt matching HERMES_SCRYPT N=2^15 r=8 p=1), stores it in localStorage.hermes.wrap. hasBackup/getBackup use that wrap, so a password sign-in now works on the same device. - Cloud backup: publishCloudBackup / fetchCloudBackup / removeCloudBackup / hasCloudBackup matching Theseus's dtag format (hermes.backup.v1/<sha256(bareName)[:32]>). Password unlocks the local wrap, re-wraps for the cloud copy with a fresh salt, publishes a kind:30078 to every relay, remembers the event id for later retract via NIP-09. - Phone discovery: setPhone / clearPhone / findByPhones with the same hermes.phone/<base64url(sha256(E.164))[:32]> d-tag Theseus uses, so a vCard import on the browser surfaces the same matches a Theseus user would see. - contactsUpdate / contactsRemove rounded out so the panel's contacts menu is fully wired. Also: - Default relay list bumped to [wss://hermes.x, wss://relay.silentmode.st, wss://nos.lol] in both the Theseus host and the web shim, so every publish/fetch hits our own strfry on the VPS and the cloud backup lives somewhere we control. - Theseus host: addons-host.js + main.js now understand a `width` field on sidebar panels, so the Hermes panel declares width:500 and opens at a Telegram-ish width by default (user can still drag; saved per-panel). - Panel + web messenger: forced color-scheme:dark (the brand mark has hard navy cutouts that stand out as "black inside the envelope" on a light background; dark everywhere is cheaper than theme-aware marks). - Password fields: 👁 toggle appears on every <input type="password"> via a tiny JS enhancer in both the panel and the web messenger. - Settings/signin tabs: Sign in / Sign up persistent pill, drag-to-resize contacts/thread splitter (persisted per device), 2-column always.
This commit is contained in:
parent
b2fef43607
commit
3b2cad491d
4 changed files with 144 additions and 34 deletions
|
|
@ -562,7 +562,13 @@ class AddonHost {
|
|||
return (manifest.panels || []).map((p) => {
|
||||
const abs = path.join(folder, p.page);
|
||||
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
|
||||
return { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" };
|
||||
const rec = { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" };
|
||||
// Optional per-panel preferred default width (px); main.js uses it as
|
||||
// the `def` passed to leftPanelWidth when the user has not resized it.
|
||||
// Clamped to a sane range so a bad manifest can't take over the window.
|
||||
const w = Number(p.width);
|
||||
if (Number.isFinite(w) && w > 0) rec.width = Math.max(280, Math.min(1200, Math.round(w)));
|
||||
return rec;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -890,13 +896,15 @@ class AddonHost {
|
|||
if (typeof handle !== "function") throw new Error("registerSiteRoute needs a handle(request) function");
|
||||
active.siteRoutes.set(h, { addonId: manifest.id, handle });
|
||||
},
|
||||
registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => {
|
||||
registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side, width }) => {
|
||||
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
|
||||
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));
|
||||
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
|
||||
// Namespaced id so two add-ons can't collide.
|
||||
const panelId = `${manifest.id}:${id}`;
|
||||
const rec = { panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" };
|
||||
const w = Number(width);
|
||||
if (Number.isFinite(w) && w > 0) rec.width = Math.max(280, Math.min(1200, Math.round(w)));
|
||||
const at = active.sidebarPanels.findIndex((p) => p.panelId === panelId);
|
||||
if (at >= 0) active.sidebarPanels[at] = rec; else active.sidebarPanels.push(rec);
|
||||
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,21 +4,18 @@
|
|||
<meta charset="utf-8">
|
||||
<title>Hermes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
/* Dark-only. The brand mark has hard navy cutouts over a sky body; on a
|
||||
light background the cutouts stand out as "black inside the envelope".
|
||||
Keep the panel dark regardless of OS theme. */
|
||||
:root { color-scheme: dark;
|
||||
--bg:#0e131c; --panel:#141a24; --panel-2:#1a2130; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678;
|
||||
--sky:#38BDF8; --sky-ink:#06111a; --amber:#f4c430;
|
||||
--err:#f6768a; --ok:#4fd1a5;
|
||||
--bubble-me: #0c3f55; --bubble-them: #1a2130; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --panel-2:#eef2fb; --line:rgba(0,0,0,.09);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
--sky:#0891B2; --sky-ink:#ffffff;
|
||||
--bubble-me:#dceefa; --bubble-them:#eef1f5; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin:0; height:100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
html, body { margin:0; height:100%; background: var(--bg); }
|
||||
body { color: var(--ink);
|
||||
font: 13.5px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
overflow: hidden; }
|
||||
|
||||
|
|
@ -71,16 +68,26 @@
|
|||
/* === app screen (contacts + thread) === */
|
||||
#app { display: none; flex-direction: column; }
|
||||
#app.active { display: flex; }
|
||||
#app .cols { display: grid; grid-template-columns: 220px 1fr; height: 100%;
|
||||
min-height: 0; }
|
||||
@media (max-width: 540px) {
|
||||
#app .cols { grid-template-columns: 1fr; }
|
||||
#app.show-thread .contacts-pane { display: none; }
|
||||
#app:not(.show-thread) .thread-pane { display: none; }
|
||||
/* 2-column at every width, same shape as the hermes.x /overview mockup.
|
||||
Contacts column sits around a Telegram-style 320 px default, draggable
|
||||
via the splitter in between. --contacts-w is set inline by the splitter
|
||||
JS and persisted in localStorage so the width survives reload.
|
||||
minmax(…) + min-width:0 on panes stops long names/inputs from pushing
|
||||
the layout past the panel. */
|
||||
#app .cols { display: grid;
|
||||
grid-template-columns: minmax(200px, var(--contacts-w, 320px)) 5px minmax(0, 1fr);
|
||||
height: 100%; min-height: 0; }
|
||||
#app .splitter { background: transparent; cursor: col-resize; user-select: none;
|
||||
border-left: 1px solid var(--line); border-right: 1px solid var(--line);
|
||||
margin: 0; padding: 0; transition: background .12s; }
|
||||
#app .splitter:hover, #app .splitter.dragging { background: var(--sky); opacity: .5; }
|
||||
/* Narrow panel: splitter stays but contacts clamp down so the thread
|
||||
has at least ~140 px to breathe. */
|
||||
@media (max-width: 500px) {
|
||||
#app .cols { grid-template-columns: minmax(160px, var(--contacts-w, 180px)) 5px minmax(0, 1fr); }
|
||||
}
|
||||
@media (min-width: 541px) { #app.show-thread .contacts-pane { display: flex; } }
|
||||
|
||||
.contacts-pane { display:flex; flex-direction: column; min-height: 0;
|
||||
.contacts-pane { display:flex; flex-direction: column; min-height: 0; min-width: 0;
|
||||
border-right: 1px solid var(--line); background: var(--panel); }
|
||||
.contacts-head { padding: 10px 10px 8px; display:flex; gap: 6px; }
|
||||
.contacts-head input { flex: 1; min-width: 0; font: inherit; font-size: 12.5px;
|
||||
|
|
@ -111,12 +118,11 @@
|
|||
border-radius: 999px; min-width: 20px; text-align: center; }
|
||||
.empty { color: var(--mut); font-size: 12px; padding: 20px 14px; text-align: center; }
|
||||
|
||||
.thread-pane { display:flex; flex-direction: column; min-height: 0; background: var(--bg); }
|
||||
.thread-pane { display:flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--bg); }
|
||||
.thread-head { padding: 10px 12px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); display:flex; align-items:center; gap: 10px; min-height: 44px; }
|
||||
.thread-head .back { display: none; background: transparent; border: 0; color: var(--mut);
|
||||
font: inherit; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
@media (max-width: 540px) { .thread-head .back { display: inline-block; } }
|
||||
/* No back button — the contacts pane is always visible beside the thread. */
|
||||
.thread-head .back { display: none; }
|
||||
.thread-head .nm { font-weight: 600; font-size: 13px; }
|
||||
.thread-head .pk { color: var(--dim); font-size: 11px; font-family: ui-monospace, monospace;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
|
@ -159,16 +165,15 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top bar — present on both screens -->
|
||||
<!-- In-panel bar for the SIGNED-IN state: relay status + ⋯ menu.
|
||||
The sidebar chrome Theseus draws above already shows "Hermes" + the
|
||||
maximize/close icons, so we do not repeat the title or the mark here
|
||||
(match Telegram's pattern — one header, not two). The whole bar is
|
||||
hidden on the signed-out screen via body.signed-out .bar in shared.css. -->
|
||||
<div class="bar">
|
||||
<div class="mk" aria-hidden="true">
|
||||
<img src="assets/mark.svg" alt="" width="24" height="24" style="display:block">
|
||||
</div>
|
||||
<div class="title">Hermes</div>
|
||||
<div class="spc"></div>
|
||||
<div class="status" id="status"><span class="dot" id="statusDot"></span><span id="statusText">—</span></div>
|
||||
<button class="ic" id="menuBtn" title="More" style="display:none">⋯</button>
|
||||
<button class="ic" id="popOutBtn" title="Open in a window">⧉</button>
|
||||
</div>
|
||||
|
||||
<!-- overflow menu -->
|
||||
|
|
@ -328,6 +333,7 @@
|
|||
</div>
|
||||
<div class="contacts-list" id="contactsList"></div>
|
||||
</div>
|
||||
<div class="splitter" id="colSplitter" role="separator" aria-orientation="vertical" title="Drag to resize"></div>
|
||||
<div class="thread-pane">
|
||||
<div class="thread-head" id="threadHead" style="display:none">
|
||||
<button class="back" id="backBtn">←</button>
|
||||
|
|
@ -762,6 +768,90 @@ async function initWithOpts(opts) {
|
|||
setInterval(refreshStatus, 7000);
|
||||
})();
|
||||
|
||||
// ---- splitter: drag the contacts/thread divider -----------------------
|
||||
// Writes --contacts-w on #app (grid-template-columns reads it) and persists
|
||||
// in localStorage under hermes.cols.contactsW. Pointer events; mouse +
|
||||
// touch work the same. Clamped to [160, 70% of panel] so the thread
|
||||
// column always has room.
|
||||
// ---- password-eye toggle on every <input type="password"> --------------
|
||||
// Wraps each one in a flex container and injects a 👁 button that flips the
|
||||
// input between type="password" and "text". No per-field markup needed.
|
||||
(function initPwEye() {
|
||||
const EYE_OPEN =
|
||||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const EYE_OFF =
|
||||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-7-11-7a19.73 19.73 0 0 1 4.19-5.19M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a19.6 19.6 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||||
const style = document.createElement("style");
|
||||
style.textContent = ".pw-wrap{position:relative;display:block}.pw-wrap > input{padding-right:34px!important;width:100%}" +
|
||||
".pw-eye{position:absolute;top:50%;right:4px;transform:translateY(-50%);background:transparent;border:0;color:var(--mut);cursor:pointer;padding:4px;display:grid;place-items:center;line-height:0}" +
|
||||
".pw-eye:hover{color:var(--ink)}";
|
||||
document.head.appendChild(style);
|
||||
const attach = (inp) => {
|
||||
if (!inp || inp.closest(".pw-wrap")) return;
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "pw-wrap";
|
||||
inp.parentNode.insertBefore(wrap, inp);
|
||||
wrap.appendChild(inp);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pw-eye";
|
||||
btn.setAttribute("aria-label", "Show password");
|
||||
btn.setAttribute("tabindex", "-1");
|
||||
btn.innerHTML = EYE_OPEN;
|
||||
btn.addEventListener("click", () => {
|
||||
const show = inp.type === "password";
|
||||
inp.type = show ? "text" : "password";
|
||||
btn.innerHTML = show ? EYE_OFF : EYE_OPEN;
|
||||
btn.setAttribute("aria-label", show ? "Hide password" : "Show password");
|
||||
inp.focus();
|
||||
});
|
||||
wrap.appendChild(btn);
|
||||
};
|
||||
for (const inp of document.querySelectorAll('input[type="password"]')) attach(inp);
|
||||
})();
|
||||
|
||||
(function initSplitter() {
|
||||
const app = $("app"), sp = $("colSplitter");
|
||||
if (!app || !sp) return;
|
||||
const STORE_KEY = "hermes.cols.contactsW";
|
||||
try {
|
||||
const saved = Number(localStorage.getItem(STORE_KEY));
|
||||
if (saved >= 160 && saved <= 2000) app.style.setProperty("--contacts-w", saved + "px");
|
||||
} catch {}
|
||||
let dragging = false, start = 0, startW = 0;
|
||||
const clamp = (w) => {
|
||||
const panelW = app.getBoundingClientRect().width || 400;
|
||||
return Math.max(160, Math.min(Math.floor(panelW * 0.7), w));
|
||||
};
|
||||
sp.addEventListener("pointerdown", (e) => {
|
||||
dragging = true; start = e.clientX;
|
||||
startW = parseFloat(getComputedStyle(app).getPropertyValue("--contacts-w")) ||
|
||||
app.querySelector(".contacts-pane").getBoundingClientRect().width ||
|
||||
320;
|
||||
sp.classList.add("dragging");
|
||||
try { sp.setPointerCapture(e.pointerId); } catch {}
|
||||
e.preventDefault();
|
||||
});
|
||||
sp.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const w = clamp(startW + (e.clientX - start));
|
||||
app.style.setProperty("--contacts-w", w + "px");
|
||||
});
|
||||
const end = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false; sp.classList.remove("dragging");
|
||||
const w = parseFloat(getComputedStyle(app).getPropertyValue("--contacts-w"));
|
||||
if (Number.isFinite(w)) { try { localStorage.setItem(STORE_KEY, String(Math.round(w))); } catch {} }
|
||||
};
|
||||
sp.addEventListener("pointerup", end);
|
||||
sp.addEventListener("pointercancel", end);
|
||||
// Double-click resets to the default 320 px.
|
||||
sp.addEventListener("dblclick", () => {
|
||||
app.style.setProperty("--contacts-w", "320px");
|
||||
try { localStorage.removeItem(STORE_KEY); } catch {}
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- events: tab switcher + recover link ----
|
||||
$("startSignInBtn").addEventListener("click", () => showSigninView("signin"));
|
||||
$("startSignUpBtn").addEventListener("click", () => showSigninView("signup"));
|
||||
|
|
@ -832,7 +922,8 @@ $("recoverBtn").addEventListener("click", () => {
|
|||
initWithOpts({ useVault: false, mnemonic });
|
||||
});
|
||||
|
||||
$("popOutBtn").addEventListener("click", () => bridge.invoke("open-messages"));
|
||||
// popOutBtn removed — the sidebar panel is the home. Users can maximize it
|
||||
// via the host chrome's own icon if they want more room.
|
||||
$("menuBtn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if ($("menu").style.display === "block") closeMenu();
|
||||
|
|
|
|||
14
main.js
14
main.js
|
|
@ -4028,7 +4028,13 @@ function layoutHere() {
|
|||
// An add-on's left panel. Widened, it covers the tab area like a page of
|
||||
// its own, up to the right sidebar, which keeps its width: the tabs keep
|
||||
// their narrow-panel bounds underneath instead of being squeezed aside.
|
||||
const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0;
|
||||
// Default width: the panel's manifest `width`, falling back to LEFT_PANEL_W.
|
||||
const addonLeftDef = (() => {
|
||||
if (!leftPanelId) return LEFT_PANEL_W;
|
||||
const rec = leftPanels().find((p) => p.panelId === leftPanelId);
|
||||
return (rec && Number.isFinite(rec.width) && rec.width > 0) ? rec.width : LEFT_PANEL_W;
|
||||
})();
|
||||
const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(addonLeftDef, width - leftW) : 0;
|
||||
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW;
|
||||
if (leftPanel && primary) {
|
||||
leftPanel.setBounds({ x: leftW, y: chromeH + headH, width: addonLeftShownW, height: Math.max(0, bodyH - headH) });
|
||||
|
|
@ -9903,7 +9909,11 @@ ipcMain.handle("switch-to-bcnr", () => {
|
|||
// and secret key are never written to disk. The panel process holds one
|
||||
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
|
||||
// and opens a per-send WebSocket for publishes.
|
||||
const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"];
|
||||
const HERMES_DEFAULT_RELAYS = [
|
||||
"wss://hermes.x", // Silent Mode strfry on the VPS (BNS pinned-origin)
|
||||
"wss://relay.silentmode.st", // Silent Mode strfry via DNS — same box
|
||||
"wss://nos.lol", // Public fallback so we don't rely on one host
|
||||
];
|
||||
const HERMES_INBOX_LIMIT = 2000;
|
||||
|
||||
// Persistent storage. On disk:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue