relay-companion 0.1.85 → 0.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/overlay/inbox.html +214 -0
- package/overlay/main.cjs +20 -0
- package/overlay/preload.cjs +8 -0
- package/package.json +1 -1
package/overlay/inbox.html
CHANGED
|
@@ -503,6 +503,29 @@
|
|
|
503
503
|
.cv-sub { font-size:11.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
|
|
504
504
|
.cv-chev { flex:0 0 auto; display:flex; align-items:center; }
|
|
505
505
|
.cv-empty { padding:34px 16px; text-align:center; color:var(--muted-2); font-size:12.5px; line-height:1.5; }
|
|
506
|
+
/* ---- contact groups (rosters) ---- */
|
|
507
|
+
.cvg { border-top:1px solid var(--hair-2); padding-bottom:10px; }
|
|
508
|
+
.cvg-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px 6px; }
|
|
509
|
+
.cvg-newform { display:flex; gap:7px; padding:6px 16px 10px; align-items:center; }
|
|
510
|
+
.cvg-newform.hidden { display:none; }
|
|
511
|
+
.cvg-newform .cv-input { flex:1; }
|
|
512
|
+
.cvg-error { color:#b4332a; font-size:11.5px; padding:0 16px; line-height:1.4; }
|
|
513
|
+
.cvg-error:empty { display:none; }
|
|
514
|
+
.cvg-item { display:flex; align-items:center; gap:11px; padding:9px 16px; cursor:pointer; text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair-2); background:transparent; width:100%; font-family:var(--sans); transition:background-color .14s var(--settle); }
|
|
515
|
+
.cvg-item:hover { background:rgba(31,26,23,.035); }
|
|
516
|
+
.cvg-name { font-size:13px; font-weight:600; color:var(--ink); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
517
|
+
.cvg-count { font-size:11.5px; color:var(--muted-2); }
|
|
518
|
+
.cvg-panel { padding:4px 16px 12px; border-bottom:1px solid var(--hair-2); display:flex; flex-direction:column; gap:8px; }
|
|
519
|
+
.cvg-members { display:flex; flex-wrap:wrap; gap:5px; }
|
|
520
|
+
.cvg-none { font-size:11.5px; color:var(--muted-2); }
|
|
521
|
+
.cvg-addwrap { position:relative; }
|
|
522
|
+
.cvg-sugg { display:flex; flex-direction:column; gap:3px; margin-top:4px; }
|
|
523
|
+
.cvg-sugg button { text-align:left; appearance:none; border:1px solid var(--hair-2); background:transparent; border-radius:7px; padding:5px 9px; font-size:12px; color:var(--ink); cursor:pointer; font-family:var(--sans); }
|
|
524
|
+
.cvg-sugg button:hover { background:rgba(31,26,23,.045); }
|
|
525
|
+
.cvg-actions { display:flex; gap:10px; }
|
|
526
|
+
.cvg-act { appearance:none; border:0; background:transparent; padding:0; font-size:11.5px; color:var(--muted-2); cursor:pointer; font-family:var(--sans); }
|
|
527
|
+
.cvg-act:hover { color:var(--ink); text-decoration:underline; }
|
|
528
|
+
.cvg-act.danger { color:#b4332a; }
|
|
506
529
|
.cv-form { display:flex; flex-direction:column; gap:7px; padding:8px 16px 14px; border-bottom:1px solid var(--hair-2); }
|
|
507
530
|
.cv-form.hidden { display:none; }
|
|
508
531
|
.cv-row2 { display:flex; gap:7px; }
|
|
@@ -827,6 +850,23 @@
|
|
|
827
850
|
</form>
|
|
828
851
|
<div class="cv-list" id="cvList"></div>
|
|
829
852
|
<div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
|
|
853
|
+
|
|
854
|
+
<!-- Contact groups: saved rosters. A relay to a group fans out to every
|
|
855
|
+
member under ONE shared thread; replies reach everyone. -->
|
|
856
|
+
<div class="cvg" id="cvGroups">
|
|
857
|
+
<div class="cvg-head">
|
|
858
|
+
<span class="cv-title">Groups</span>
|
|
859
|
+
<button class="cv-add" type="button" id="cvgNew">+ New group</button>
|
|
860
|
+
</div>
|
|
861
|
+
<form class="cvg-newform hidden" id="cvgNewForm">
|
|
862
|
+
<input class="cv-input" id="cvgNewName" placeholder="Group name" autocomplete="off" spellcheck="false" />
|
|
863
|
+
<button class="cv-save" type="submit">Create</button>
|
|
864
|
+
<button class="cv-cancel" type="button" id="cvgNewCancel">Cancel</button>
|
|
865
|
+
</form>
|
|
866
|
+
<div class="cvg-error" id="cvgError"></div>
|
|
867
|
+
<div id="cvgList"></div>
|
|
868
|
+
<div class="cv-empty" id="cvgEmpty">No groups yet. A group relays to everyone in it as one conversation.</div>
|
|
869
|
+
</div>
|
|
830
870
|
</section>
|
|
831
871
|
|
|
832
872
|
<!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
|
|
@@ -2425,7 +2465,181 @@
|
|
|
2425
2465
|
try { contactsList = (await window.relay.contacts()) || []; }
|
|
2426
2466
|
catch { contactsList = []; }
|
|
2427
2467
|
renderContacts();
|
|
2468
|
+
loadGroups();
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// ---- Contact groups (rosters) ----
|
|
2472
|
+
// Server-truth only: every mutation returns the updated group view and the
|
|
2473
|
+
// section re-renders from it, so nothing here can drift from the API.
|
|
2474
|
+
const cvgNewEl = document.getElementById("cvgNew");
|
|
2475
|
+
const cvgNewFormEl = document.getElementById("cvgNewForm");
|
|
2476
|
+
const cvgNewNameEl = document.getElementById("cvgNewName");
|
|
2477
|
+
const cvgNewCancelEl = document.getElementById("cvgNewCancel");
|
|
2478
|
+
const cvgListEl = document.getElementById("cvgList");
|
|
2479
|
+
const cvgEmptyEl = document.getElementById("cvgEmpty");
|
|
2480
|
+
const cvgErrorEl = document.getElementById("cvgError");
|
|
2481
|
+
let groupsList = [];
|
|
2482
|
+
let cvgExpandedId = null; // group whose member panel is open
|
|
2483
|
+
let cvgRenamingId = null;
|
|
2484
|
+
let cvgDeleteArmedId = null;
|
|
2485
|
+
let cvgSearchTimer = null;
|
|
2486
|
+
function cvgShowError(message) { cvgErrorEl.textContent = message || ""; }
|
|
2487
|
+
function cvgApply(result) {
|
|
2488
|
+
// A mutation's returned view replaces (or appends) its group; delete passes
|
|
2489
|
+
// a removal marker. Sort by name so rows don't jump around per mutation.
|
|
2490
|
+
if (result && result.removeId) groupsList = groupsList.filter((g) => g.id !== result.removeId);
|
|
2491
|
+
else if (result && result.id) {
|
|
2492
|
+
const at = groupsList.findIndex((g) => g.id === result.id);
|
|
2493
|
+
if (at === -1) groupsList.push(result);
|
|
2494
|
+
else groupsList[at] = result;
|
|
2495
|
+
}
|
|
2496
|
+
groupsList.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
2497
|
+
renderGroups();
|
|
2498
|
+
}
|
|
2499
|
+
async function cvgCall(fn, ...args) {
|
|
2500
|
+
cvgShowError("");
|
|
2501
|
+
let res = null;
|
|
2502
|
+
try { res = await fn(...args); } catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
|
|
2503
|
+
if (!res || res.ok !== true) {
|
|
2504
|
+
cvgShowError((res && res.error) || "That didn't go through — try again.");
|
|
2505
|
+
return null;
|
|
2506
|
+
}
|
|
2507
|
+
return res.result;
|
|
2508
|
+
}
|
|
2509
|
+
async function loadGroups() {
|
|
2510
|
+
const groups = await cvgCall(window.relay.groups);
|
|
2511
|
+
if (Array.isArray(groups)) {
|
|
2512
|
+
groupsList = groups.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
2513
|
+
renderGroups();
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
function renderGroups() {
|
|
2517
|
+
cvgEmptyEl.classList.toggle("gone", groupsList.length > 0);
|
|
2518
|
+
cvgListEl.innerHTML = groupsList.map((g) => {
|
|
2519
|
+
const open = g.id === cvgExpandedId;
|
|
2520
|
+
const count = Number(g.memberCount ?? (g.members || []).length) || 0;
|
|
2521
|
+
const row = cvgRenamingId === g.id
|
|
2522
|
+
? `<form class="cvg-newform" data-rename-form="${esc(g.id)}"><input class="cv-input" data-rename-input="${esc(g.id)}" value="${esc(g.name)}" autocomplete="off" spellcheck="false" /><button class="cv-save" type="submit">Save</button><button class="cv-cancel" type="button" data-rename-cancel="${esc(g.id)}">Cancel</button></form>`
|
|
2523
|
+
: `<button class="cvg-item" type="button" data-group="${esc(g.id)}">
|
|
2524
|
+
<span class="cvg-name">${esc(g.name)}</span>
|
|
2525
|
+
<span class="cvg-count">${count} member${count === 1 ? "" : "s"}</span>
|
|
2526
|
+
<span class="cv-chev">${CHEVRON_SVG}</span>
|
|
2527
|
+
</button>`;
|
|
2528
|
+
if (!open) return row;
|
|
2529
|
+
const members = (g.members || []).map((m) => {
|
|
2530
|
+
const label = m.name || m.email || m.contactId || m.id;
|
|
2531
|
+
const cid = m.contactId || m.id;
|
|
2532
|
+
return `<span class="cv-chip"><span class="e">${esc(label)}</span><button type="button" class="cv-chip-x" data-unmember="${esc(g.id)}|${esc(cid)}" aria-label="Remove ${esc(label)}">×</button></span>`;
|
|
2533
|
+
}).join("");
|
|
2534
|
+
return row + `
|
|
2535
|
+
<div class="cvg-panel" data-stop="1">
|
|
2536
|
+
<div class="cvg-members">${members || '<span class="cvg-none">No members yet.</span>'}</div>
|
|
2537
|
+
<div class="cvg-addwrap">
|
|
2538
|
+
<input class="cv-input" data-addmember="${esc(g.id)}" placeholder="Add member — type a name…" autocomplete="off" spellcheck="false" />
|
|
2539
|
+
<div class="cvg-sugg" data-sugg="${esc(g.id)}"></div>
|
|
2540
|
+
</div>
|
|
2541
|
+
<div class="cvg-actions">
|
|
2542
|
+
<button class="cvg-act" type="button" data-rename="${esc(g.id)}">Rename</button>
|
|
2543
|
+
<button class="cvg-act danger" type="button" data-delete="${esc(g.id)}">${cvgDeleteArmedId === g.id ? "Really delete? Every member drops out" : "Delete group"}</button>
|
|
2544
|
+
</div>
|
|
2545
|
+
</div>`;
|
|
2546
|
+
}).join("");
|
|
2547
|
+
wireGroupRows();
|
|
2428
2548
|
}
|
|
2549
|
+
function wireGroupRows() {
|
|
2550
|
+
for (const el of cvgListEl.querySelectorAll("[data-group]")) {
|
|
2551
|
+
el.addEventListener("click", () => {
|
|
2552
|
+
const id = el.getAttribute("data-group");
|
|
2553
|
+
cvgExpandedId = cvgExpandedId === id ? null : id;
|
|
2554
|
+
cvgDeleteArmedId = null; cvgRenamingId = null;
|
|
2555
|
+
renderGroups();
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
for (const el of cvgListEl.querySelectorAll("[data-unmember]")) {
|
|
2559
|
+
el.addEventListener("click", async (ev) => {
|
|
2560
|
+
ev.stopPropagation();
|
|
2561
|
+
const [gid, cid] = el.getAttribute("data-unmember").split("|");
|
|
2562
|
+
const view = await cvgCall(window.relay.groupRemoveMember, gid, cid);
|
|
2563
|
+
if (view) cvgApply(view);
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
for (const el of cvgListEl.querySelectorAll("[data-rename]")) {
|
|
2567
|
+
el.addEventListener("click", () => { cvgRenamingId = el.getAttribute("data-rename"); renderGroups();
|
|
2568
|
+
const input = cvgListEl.querySelector(`[data-rename-input="${CSS.escape(cvgRenamingId)}"]`);
|
|
2569
|
+
if (input) { input.focus(); input.select(); }
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
for (const el of cvgListEl.querySelectorAll("[data-rename-cancel]")) {
|
|
2573
|
+
el.addEventListener("click", () => { cvgRenamingId = null; renderGroups(); });
|
|
2574
|
+
}
|
|
2575
|
+
for (const form of cvgListEl.querySelectorAll("[data-rename-form]")) {
|
|
2576
|
+
form.addEventListener("submit", async (ev) => {
|
|
2577
|
+
ev.preventDefault();
|
|
2578
|
+
const gid = form.getAttribute("data-rename-form");
|
|
2579
|
+
const name = form.querySelector("input").value.trim();
|
|
2580
|
+
if (!name) return;
|
|
2581
|
+
const view = await cvgCall(window.relay.groupRename, gid, name);
|
|
2582
|
+
if (view) { cvgRenamingId = null; cvgApply(view); }
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
for (const el of cvgListEl.querySelectorAll("[data-delete]")) {
|
|
2586
|
+
el.addEventListener("click", async () => {
|
|
2587
|
+
const gid = el.getAttribute("data-delete");
|
|
2588
|
+
// Deleting a roster is destructive: first tap arms, second tap commits.
|
|
2589
|
+
if (cvgDeleteArmedId !== gid) { cvgDeleteArmedId = gid; renderGroups(); return; }
|
|
2590
|
+
const res = await cvgCall(window.relay.groupDelete, gid);
|
|
2591
|
+
cvgDeleteArmedId = null;
|
|
2592
|
+
if (res) { cvgExpandedId = null; cvgApply({ removeId: gid }); }
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
for (const input of cvgListEl.querySelectorAll("[data-addmember]")) {
|
|
2596
|
+
const gid = input.getAttribute("data-addmember");
|
|
2597
|
+
const suggEl = cvgListEl.querySelector(`[data-sugg="${CSS.escape(gid)}"]`);
|
|
2598
|
+
input.addEventListener("focus", () => { if (window.relay.setFocusable) window.relay.setFocusable(true); });
|
|
2599
|
+
input.addEventListener("blur", () => { if (activeView !== "contacts" && window.relay.setFocusable) window.relay.setFocusable(false); });
|
|
2600
|
+
input.addEventListener("input", () => {
|
|
2601
|
+
clearTimeout(cvgSearchTimer);
|
|
2602
|
+
const q = input.value.trim();
|
|
2603
|
+
if (q.length < 2) { suggEl.innerHTML = ""; return; }
|
|
2604
|
+
cvgSearchTimer = setTimeout(async () => {
|
|
2605
|
+
const res = await cvgCall(window.relay.contactsSearch, q);
|
|
2606
|
+
const group = groupsList.find((x) => x.id === gid);
|
|
2607
|
+
const already = new Set(((group && group.members) || []).map((m) => m.contactId || m.id));
|
|
2608
|
+
const matches = ((res && res.matches) || []).filter((m) => m.contactId && !already.has(m.contactId)).slice(0, 4);
|
|
2609
|
+
suggEl.innerHTML = matches.map((m) =>
|
|
2610
|
+
`<button type="button" data-addpick="${esc(gid)}|${esc(m.contactId)}">${esc(m.name || m.email)}${m.email && m.name ? ` — ${esc(m.email)}` : ""}</button>`,
|
|
2611
|
+
).join("");
|
|
2612
|
+
for (const b of suggEl.querySelectorAll("[data-addpick]")) {
|
|
2613
|
+
b.addEventListener("click", async () => {
|
|
2614
|
+
const [g2, cid] = b.getAttribute("data-addpick").split("|");
|
|
2615
|
+
const view = await cvgCall(window.relay.groupAddMember, g2, cid);
|
|
2616
|
+
if (view) cvgApply(view);
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
}, 250);
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
cvgNewEl.addEventListener("click", () => {
|
|
2624
|
+
cvgNewFormEl.classList.toggle("hidden");
|
|
2625
|
+
if (!cvgNewFormEl.classList.contains("hidden")) {
|
|
2626
|
+
if (window.relay.setFocusable) window.relay.setFocusable(true);
|
|
2627
|
+
cvgNewNameEl.focus();
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
cvgNewCancelEl.addEventListener("click", () => { cvgNewNameEl.value = ""; cvgNewFormEl.classList.add("hidden"); });
|
|
2631
|
+
cvgNewFormEl.addEventListener("submit", async (ev) => {
|
|
2632
|
+
ev.preventDefault();
|
|
2633
|
+
const name = cvgNewNameEl.value.trim();
|
|
2634
|
+
if (!name) return;
|
|
2635
|
+
const view = await cvgCall(window.relay.groupCreate, name);
|
|
2636
|
+
if (view) {
|
|
2637
|
+
cvgNewNameEl.value = "";
|
|
2638
|
+
cvgNewFormEl.classList.add("hidden");
|
|
2639
|
+
cvgExpandedId = view.id; // open the fresh group so adding members is the next tap
|
|
2640
|
+
cvgApply(view);
|
|
2641
|
+
}
|
|
2642
|
+
});
|
|
2429
2643
|
function cvSplitName(name) {
|
|
2430
2644
|
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
|
2431
2645
|
return { first: parts[0] || "", last: parts.slice(1).join(" ") };
|
package/overlay/main.cjs
CHANGED
|
@@ -3171,6 +3171,26 @@ ipcMain.handle("relay:contacts", () => readContacts());
|
|
|
3171
3171
|
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
3172
3172
|
ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
|
|
3173
3173
|
|
|
3174
|
+
// Contact groups (Contacts tab): thin proxies over the owner-scoped API. Every
|
|
3175
|
+
// mutation returns the server's updated group view and the renderer re-renders
|
|
3176
|
+
// from it, so there is no local cache to drift. Errors come back as
|
|
3177
|
+
// { ok:false, error } — the renderer shows them inline instead of dying.
|
|
3178
|
+
const groupCall = async (fn) => {
|
|
3179
|
+
try {
|
|
3180
|
+
const client = await relayClient();
|
|
3181
|
+
return { ok: true, result: await fn(client) };
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
ipcMain.handle("relay:groups", () => groupCall(async (c) => (await c.groups()).groups || []));
|
|
3187
|
+
ipcMain.handle("relay:groupCreate", (_e, name) => groupCall((c) => c.createGroup({ name })));
|
|
3188
|
+
ipcMain.handle("relay:groupRename", (_e, id, name) => groupCall((c) => c.renameGroup(id, { name })));
|
|
3189
|
+
ipcMain.handle("relay:groupDelete", (_e, id) => groupCall((c) => c.deleteGroup(id)));
|
|
3190
|
+
ipcMain.handle("relay:groupAddMember", (_e, id, contactId) => groupCall((c) => c.addGroupMember(id, contactId)));
|
|
3191
|
+
ipcMain.handle("relay:groupRemoveMember", (_e, id, contactId) => groupCall((c) => c.removeGroupMember(id, contactId)));
|
|
3192
|
+
ipcMain.handle("relay:contactsSearch", (_e, q) => groupCall((c) => c.searchContacts(String(q || ""))));
|
|
3193
|
+
|
|
3174
3194
|
// Settings tab: account card + the sign-out / switch-account lifecycle.
|
|
3175
3195
|
ipcMain.handle("relay:accountInfo", () => accountInfo());
|
|
3176
3196
|
ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
|
package/overlay/preload.cjs
CHANGED
|
@@ -53,6 +53,14 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
53
53
|
|
|
54
54
|
// contacts
|
|
55
55
|
contacts: () => ipcRenderer.invoke("relay:contacts"),
|
|
56
|
+
contactsSearch: (q) => ipcRenderer.invoke("relay:contactsSearch", q),
|
|
57
|
+
// contact groups (saved rosters; a group relays to every member as one thread)
|
|
58
|
+
groups: () => ipcRenderer.invoke("relay:groups"),
|
|
59
|
+
groupCreate: (name) => ipcRenderer.invoke("relay:groupCreate", name),
|
|
60
|
+
groupRename: (id, name) => ipcRenderer.invoke("relay:groupRename", id, name),
|
|
61
|
+
groupDelete: (id) => ipcRenderer.invoke("relay:groupDelete", id),
|
|
62
|
+
groupAddMember: (id, contactId) => ipcRenderer.invoke("relay:groupAddMember", id, contactId),
|
|
63
|
+
groupRemoveMember: (id, contactId) => ipcRenderer.invoke("relay:groupRemoveMember", id, contactId),
|
|
56
64
|
contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
|
|
57
65
|
contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
|
|
58
66
|
|