437 lines
13 KiB
JavaScript
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();
|