Connect-Desk-Project/ui/ui.js

437 lines
13 KiB
JavaScript

const $alerts = document.getElementById("alerts");
const $accounts = document.getElementById("accounts");
const $instance = document.getElementById("instance");
const $toast = document.getElementById("toast");
const $incomingPop = document.getElementById("incoming-pop");
const $incomingPopName = document.getElementById("incoming-pop-name");
const $incomingPopGo = document.getElementById("incoming-pop-go");
// Electron has no window.prompt(), so renames happen through an inline input.
// `editing` survives re-renders so the field isn't wiped mid-typing.
let editing = null;
let lastState = { accounts: [], alerts: [] };
// Belt & braces alongside overflow-x:clip — nothing may ever scroll the
// sidebar sideways (a focused input once did, which clipped all content).
function resetScroll() {
for (const el of [document.scrollingElement, document.body, $accounts]) {
if (el && el.scrollLeft) el.scrollLeft = 0;
}
}
/* ---------------- alerts ---------------- */
function renderAlerts(alerts) {
$alerts.innerHTML = "";
if (!alerts.length) {
const e = document.createElement("div");
e.className = "empty";
e.textContent = "No waiting chats";
$alerts.append(e);
return;
}
for (const a of alerts) {
const el = document.createElement("div");
el.className = "alert";
const b = document.createElement("b");
b.textContent = a.label;
const s = document.createElement("span");
s.textContent = a.kind === "reply" ? a.text || "New message" : a.queue || "waiting";
el.append(b, s);
el.onclick = () => api.focusAlert(a.accountId);
$alerts.append(el);
}
}
/* ---------------- rename editor ---------------- */
function startEdit(id) {
editing = id;
render(lastState);
}
function commitEdit(id, value) {
if (editing !== id) return;
editing = null;
api.renameAccount(id, value);
}
// Native popups (Chromium autofill, macOS spelling/substitution) anchor to text
// inputs and float OVER the window — an orphaned one is what once appeared as a
// white box across the footer. Disable everything that can summon one.
function plainInput() {
const input = document.createElement("input");
input.autocomplete = "off";
input.spellcheck = false;
input.setAttribute("autocapitalize", "off");
input.setAttribute("autocorrect", "off");
return input;
}
function buildEditor(a) {
const input = plainInput();
input.className = "rename";
input.value = a.label || a.agentName || "";
input.placeholder = a.agentName || "Account name";
input.onclick = (e) => e.stopPropagation();
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit(a.id, input.value);
if (e.key === "Escape") {
editing = null;
render(lastState);
}
};
input.onblur = () => commitEdit(a.id, input.value);
setTimeout(() => {
input.focus({ preventScroll: true }); // focus scrolling is what broke alignment
input.select();
resetScroll();
}, 0);
return input;
}
/* ---------------- account rows ---------------- */
function dotClass(a) {
if (a.loading) return "dot loading";
if (a.stayOut && a.signedIn !== true) return "dot off"; // deliberate sign-out, not an error
if (a.signedIn === false) return "dot err";
if (a.stateType === "routable") return "dot ready";
if (a.stateType === "offline") return "dot off";
if (a.agentName) return "dot ready";
return "dot";
}
function stateText(a) {
if (a.stayOut && a.signedIn !== true) return "signed out";
if (a.signedIn === false) return "sign in";
return a.stateName || "";
}
function buildRow(a, activeId) {
const el = document.createElement("div");
el.className = "acct" + (a.id === activeId ? " active" : "");
el.onclick = () => api.activate(a.id);
el.oncontextmenu = (e) => {
e.preventDefault();
api.showAccountMenu(a.id);
};
const dot = document.createElement("span");
dot.className = dotClass(a);
el.append(dot);
if (editing === a.id) {
el.append(buildEditor(a));
return el;
}
const label = document.createElement("span");
label.className = "label";
label.textContent = a.display;
if (!a.label && !a.agentName) label.classList.add("placeholder");
label.title =
(a.agentName ? `Signed in as ${a.agentName}` : "Not signed in yet") +
`${a.instanceLabel || ""}`;
label.ondblclick = (e) => {
e.stopPropagation();
startEdit(a.id);
};
el.append(label);
// Which Connect instance this account lives on (US / EU).
if (a.instanceShort) {
const inst = document.createElement("span");
inst.className = "inst";
inst.textContent = a.instanceShort;
inst.title = a.instanceLabel;
el.append(inst);
}
const st = stateText(a);
if (st) {
const chip = document.createElement("span");
chip.className = "state";
chip.textContent = st;
el.append(chip);
}
if (a.waiting) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = a.waiting;
el.append(badge);
}
const tools = document.createElement("span");
tools.className = "tools";
for (const [glyph, title, fn] of [
["✎", "Rename", () => startEdit(a.id)],
["⟳", "Reload", () => api.reload(a.id)],
["✕", "Remove account", () => api.removeAccount(a.id)],
]) {
const b = document.createElement("button");
b.textContent = glyph;
b.title = title;
b.onclick = (e) => {
e.stopPropagation();
fn();
};
tools.append(b);
}
el.append(tools);
return el;
}
/* ---------------- render ---------------- */
function renderIncomingPop(alerts) {
const waiting = (alerts || []).filter((a) => a.kind !== "reply");
if (!waiting.length) {
$incomingPop.hidden = true;
$incomingPopGo.onclick = null;
return;
}
$incomingPop.hidden = false;
$incomingPopName.textContent = waiting.map((a) => a.label).join(" · ");
$incomingPopGo.onclick = (e) => {
e.stopPropagation();
api.focusAlert(waiting[0].accountId);
};
}
function render(state) {
lastState = state;
$instance.textContent = (state.instances || []).map((i) => i.label).join(" · ");
renderAlerts(state.alerts);
renderIncomingPop(state.alerts);
$accounts.innerHTML = "";
for (const a of state.accounts) $accounts.append(buildRow(a, state.activeId));
resetScroll();
}
api.onState(render);
api.onError(({ message }) => console.warn("account error:", message));
api.onMenuAction((a) => {
if (a.type === "rename") startEdit(a.id);
if (a.type === "creds") openCreds();
});
/* ---------------- toast ---------------- */
let toastTimer;
api.onToast(({ text }) => {
$toast.textContent = text;
$toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.hidden = true), 6000);
});
/* ---------------- top-level controls ---------------- */
// Every account belongs to one Connect instance, so adding starts by picking
// which one — then straight into naming, as before.
const $add = document.getElementById("add");
$add.onclick = async (e) => {
e.stopPropagation();
const instances = lastState.instances || [];
const create = async (instId) => {
const id = await api.addAccount(instId);
if (id) startEdit(id);
};
if (instances.length <= 1) return void create(instances[0]?.id);
const instId = await api.showInstanceMenu();
if (instId) create(instId);
};
document.getElementById("all-available").onclick = () => api.setStatusAll("routable");
document.getElementById("all-offline").onclick = () => api.setStatusAll("offline");
document.getElementById("all-signin").onclick = () => api.signInAll();
document.getElementById("all-signout").onclick = () => api.signOutAll();
/* ---------------- saved sign-ins panel ---------------- */
const $creds = document.getElementById("creds");
function closeCreds() {
$creds.hidden = true;
$creds.innerHTML = ""; // never leave typed passwords sitting in the DOM
}
function showToast(text, ms = 6000) {
$toast.textContent = text;
$toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.hidden = true), ms);
}
async function openCreds() {
const rows = await api.credsList();
$creds.innerHTML = "";
const head = document.createElement("div");
head.className = "creds-head";
head.textContent = "Saved sign-ins";
const note = document.createElement("div");
note.className = "creds-note";
note.textContent =
"Stored encrypted on this computer only (OS keychain). Filled only on the official " +
"Amazon Connect sign-in page for each account's instance. Leave a password blank to keep " +
"the one already saved.";
$creds.append(head, note);
const cleared = new Set();
const inputs = new Map(); // id -> {u, p, status}
// Collects one row (or every row) into the shape creds:save expects.
const entryFor = (id) => {
const { u, p } = inputs.get(id);
return { id, username: u.value, password: p.value || null, clear: cleared.has(id) };
};
const allEntries = () => [...inputs.keys()].map(entryFor);
const flash = (id, text) => {
const s = inputs.get(id)?.status;
if (!s) return;
s.textContent = text;
clearTimeout(s._t);
s._t = setTimeout(() => (s.textContent = ""), 4000);
};
// After saving, the password box is empty again but a secret now exists —
// reflect that so the row doesn't look unsaved.
const markSaved = (id) => {
const { p } = inputs.get(id);
p.value = "";
p.placeholder = "•••••• saved — blank keeps it";
};
const saveOne = async (id) => {
const res = await api.credsSave([entryFor(id)]);
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
if (cleared.has(id)) {
cleared.delete(id);
inputs.get(id).row.classList.remove("cleared");
inputs.get(id).p.placeholder = "password";
flash(id, "forgotten");
} else {
markSaved(id);
flash(id, "saved ✓");
}
return true;
};
for (const r of rows) {
const row = document.createElement("div");
row.className = "creds-row";
const name = document.createElement("div");
name.className = "creds-name";
name.textContent = `${r.display} · ${r.instanceShort}`;
const u = plainInput();
u.className = "rename";
u.placeholder = "login username / email";
u.value = r.username;
const p = plainInput();
p.className = "rename";
p.type = "password";
p.placeholder = r.hasPassword ? "•••••• saved — blank keeps it" : "password";
const bar = document.createElement("div");
bar.className = "creds-rowbar";
const bSave = document.createElement("button");
bSave.type = "button";
bSave.textContent = "Save";
bSave.title = "Save this account's sign-in";
bSave.onclick = () => saveOne(r.id);
const bSignIn = document.createElement("button");
bSignIn.type = "button";
bSignIn.className = "primary";
bSignIn.textContent = "Sign in";
bSignIn.title = "Save, then sign this account in with these credentials";
bSignIn.onclick = async () => {
if (!(await saveOne(r.id))) return;
flash(r.id, "signing in…");
api.signIn(r.id);
};
const forget = document.createElement("button");
forget.type = "button";
forget.className = "creds-forget";
forget.textContent = "forget";
forget.title = "Mark this saved sign-in for deletion, then Save";
forget.onclick = () => {
if (cleared.has(r.id)) {
cleared.delete(r.id);
row.classList.remove("cleared");
} else {
cleared.add(r.id);
row.classList.add("cleared");
}
};
const status = document.createElement("span");
status.className = "creds-status";
bar.append(bSave, bSignIn, status, forget);
inputs.set(r.id, { u, p, status, row });
const line = document.createElement("div");
line.className = "creds-line";
line.append(u, p);
row.append(name, line, bar);
$creds.append(row);
}
const actions = document.createElement("div");
actions.className = "creds-actions";
const saveAll = document.createElement("button");
saveAll.type = "button";
saveAll.textContent = "Save all";
saveAll.onclick = async () => {
const res = await api.credsSave(allEntries());
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
for (const id of inputs.keys()) {
if (cleared.has(id)) continue;
markSaved(id);
}
cleared.clear();
for (const { row } of inputs.values()) row.classList.remove("cleared");
showToast(`Saved sign-ins for ${res.saved} account(s).`);
};
const signInAll = document.createElement("button");
signInAll.type = "button";
signInAll.className = "primary";
signInAll.textContent = "Sign in all";
signInAll.onclick = async () => {
const res = await api.credsSave(allEntries());
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
closeCreds();
api.signInAll();
};
const close = document.createElement("button");
close.type = "button";
close.textContent = "Close";
close.onclick = closeCreds;
actions.append(saveAll, signInAll, close);
$creds.append(actions);
$creds.hidden = false;
}
document.getElementById("signins").onclick = openCreds;
addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$creds.hidden) closeCreds();
});
api.getState();