From 14c951de573df7f91c7be65730169abb2daf713b Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 20 Jun 2026 15:36:56 -0500 Subject: [PATCH] Add mobile email-approval bell (#6) (v0.1.0:102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin-only bell in the mobile top bar (left of the camera) surfaces the SAME pending email-capture proposals the web "Email Capture" panel and the Matrix review room decide — a third surface over the existing endpoints, no new backend. - Count badge (iPhone-style) from a 45s poll of GET /api/activity/proposals; dim when there are none. - Tap → card list of proposals → tap one → review screen (investor name, direction/date, subject, summary, editable proposed note) → Approve & log to grid (POST .../{id}/approve {note}) or Reject (POST .../{id}/dismiss). - Bidirectional sync is automatic: an app decision flips the proposal status and the bot's poll redacts the Matrix thread; a Matrix/web decision drops the proposal from the pending list the bell polls, clearing the badge. - No LLM round-trip (edit-then-approve, like the web panel). Mobile-gated (isMobile && admin) so the hidden desktop top bar never polls the endpoint. Frontend-only; no schema, migration, or dependency change. --- frontend/index.html | 128 ++++++++++++++++++++++ start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.102.ts | 27 +++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 start9/0.4/startos/versions/v0.1.0.102.ts diff --git a/frontend/index.html b/frontend/index.html index b7a4f07..14dcd56 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2586,6 +2586,31 @@ as a ~1px dot). Explicit CSS dimensions + flex:none fix it — the same reason the working .bottom-tab-icon (sized box) and .sort-pill (flex:none + text) icons render. */ .quicklog-btn svg { width: 18px; height: 18px; flex: none; } + /* #6 — email-approval bell: dim at rest, accent + a red count badge when alerts are waiting. */ + .bell-btn { position: relative; color: var(--text-subtle); } + .bell-btn.has-alerts { color: var(--accent-light); } + .bell-badge { + position: absolute; top: -3px; right: -3px; + min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; line-height: 1; + border-radius: 999px; background: var(--danger-soft); color: #fff; /* always white on the red dot — theme-stable */ + font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 700; + display: inline-flex; align-items: center; justify-content: center; + border: 1.5px solid var(--bg-panel-elevated); + } + .bell-card { + width: 100%; text-align: left; cursor: pointer; font-family: inherit; + background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; + padding: 12px 13px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 5px; color: var(--text-primary); + } + .bell-card:active { background: var(--bg-hover); } + .bell-card-row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; } + .bell-card-name { font-size: 15px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .bell-card-dir { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } + .bell-card-subject { font-size: 13px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .bell-card-note { font-size: 12px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .bell-meta { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin: -4px 0 10px; } + .bell-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.45; margin: 0 0 14px; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; } + .bell-back { width: 100%; margin-top: 10px; background: transparent; border: none; color: var(--accent-light); font-size: 14px; font-family: inherit; cursor: pointer; padding: 8px; } .quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; } .quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } .quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; } @@ -14417,6 +14442,105 @@ img.src = url; }); + // #6 — email-capture approval bell (mobile top bar, admin-only). The web "Email Capture" + // panel + the Matrix review room both decide the SAME pending proposals; this is a third + // surface over the SAME endpoints (GET /api/activity/proposals + POST .../{id}/approve|dismiss), + // so sync is automatic: deciding here flips the proposal status and the bot's poll redacts the + // Matrix thread; a Matrix/web decision drops the proposal from the pending list our poll reads, + // clearing the badge. No new endpoint, no LLM round-trip (edit-then-approve, like the web panel). + const MobileEmailBell = ({ token, onShowToast }) => { + const [proposals, setProposals] = useState(null); // null = not yet loaded + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); // the proposal being reviewed + const [noteDraft, setNoteDraft] = useState(''); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); // synchronous in-flight guard (setBusy is async — a fast double-tap could double-POST) + + const load = useCallback(async () => { + try { + const r = await api('/api/activity/proposals', {}, token); + setProposals(Array.isArray(r.proposals) ? r.proposals : []); + } catch (_) { /* leave the last good count; a transient failure shouldn't blank the bell */ } + }, [token]); + + // Poll while mounted so the badge reflects new captures AND decisions made elsewhere + // (a proposal approved/dismissed in the Matrix room or the web panel drops out of the + // pending list on the next poll, clearing the alert here — the "vice versa" sync). + useEffect(() => { + load(); + const id = setInterval(load, 45000); + return () => clearInterval(id); + }, [load]); + + const count = proposals ? proposals.length : 0; + const openSheet = () => { setSelected(null); setOpen(true); load(); }; + const closeSheet = () => { setOpen(false); setSelected(null); }; + const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); }; + + const decide = async (decision) => { + const p = selected; if (!p || busyRef.current) return; + if (decision === 'approve' && !noteDraft.trim()) { onShowToast('The note is empty', 'error'); return; } + busyRef.current = true; setBusy(true); + try { + const body = JSON.stringify(decision === 'approve' ? { note: noteDraft } : {}); + await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token); + onShowToast(decision === 'approve' ? 'Logged to the grid' : 'Dismissed', 'success'); + setProposals((ps) => (ps || []).filter((x) => x.id !== p.id)); + setSelected(null); + load(); // reconcile with the server (also catches anything decided in parallel) + } catch (err) { onShowToast(getErrorMessage(err, 'Action failed'), 'error'); } + finally { setBusy(false); busyRef.current = false; } + }; + + const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received'); + return ( + <> + + + {selected ? ( + <> +
{selected.investor_name || 'Unmatched investor'}
+
{dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}
+ {selected.email_subject &&
{selected.email_subject}
} + {selected.summary &&
{selected.summary}
} +
+ +