@agenticmail/api 0.9.2 → 0.9.3
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/package.json +1 -1
- package/public/index.html +1 -0
- package/public/js/app.js +22 -0
- package/public/js/icons.js +7 -0
- package/public/js/list-view.js +42 -0
- package/public/js/sound.js +61 -0
- package/public/js/sse.js +18 -3
- package/public/styles.css +4 -0
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
<button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
|
|
40
40
|
</div>
|
|
41
41
|
<div class="topbar-right">
|
|
42
|
+
<button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
|
|
42
43
|
<button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
|
|
43
44
|
<button id="profile-btn" class="profile-trigger" title="Account">
|
|
44
45
|
<span id="profile-avatar"></span>
|
package/public/js/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { openMessage } from './message-view.js';
|
|
|
15
15
|
import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
|
|
16
16
|
import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
|
|
17
17
|
import { icon } from './icons.js';
|
|
18
|
+
import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
|
|
18
19
|
|
|
19
20
|
// Hydrate every `data-icon="name"` placeholder in the static HTML
|
|
20
21
|
// with the corresponding inline SVG. Done once on load so we don't
|
|
@@ -188,6 +189,27 @@ document.getElementById('sidebar-backdrop').addEventListener('click', () => {
|
|
|
188
189
|
document.getElementById('main')?.classList.remove('sidebar-open');
|
|
189
190
|
});
|
|
190
191
|
|
|
192
|
+
// Sound toggle. Stateful icon button — bell (on) / bell-slash (off).
|
|
193
|
+
// Clicking flips the preference (persisted to localStorage by the
|
|
194
|
+
// sound module), updates the icon, and plays a single test chime
|
|
195
|
+
// on transitions to ON so the user hears what they just enabled.
|
|
196
|
+
function renderSoundToggle() {
|
|
197
|
+
const btn = document.getElementById('sound-toggle-btn');
|
|
198
|
+
if (!btn) return;
|
|
199
|
+
const on = isSoundEnabled();
|
|
200
|
+
btn.innerHTML = icon(on ? 'soundOn' : 'soundOff', { size: 18 });
|
|
201
|
+
btn.title = on ? 'Notification sound: on (click to mute)' : 'Notification sound: off (click to enable)';
|
|
202
|
+
btn.classList.toggle('sound-on', on);
|
|
203
|
+
btn.classList.toggle('sound-off', !on);
|
|
204
|
+
}
|
|
205
|
+
document.getElementById('sound-toggle-btn')?.addEventListener('click', () => {
|
|
206
|
+
const next = !isSoundEnabled();
|
|
207
|
+
setSoundEnabled(next);
|
|
208
|
+
renderSoundToggle();
|
|
209
|
+
if (next) playNotificationSound(); // sample the chime on enable
|
|
210
|
+
});
|
|
211
|
+
renderSoundToggle();
|
|
212
|
+
|
|
191
213
|
document.getElementById('refresh-btn').addEventListener('click', async () => {
|
|
192
214
|
if (state.selectedAgent) {
|
|
193
215
|
await loadList(state.selectedAgent, state.selectedFolder);
|
package/public/js/icons.js
CHANGED
|
@@ -47,6 +47,13 @@ const PATHS = {
|
|
|
47
47
|
// ─── Status / dots ──────────────────────────────────────────────
|
|
48
48
|
dot: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
|
|
49
49
|
check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z',
|
|
50
|
+
// Material-style notifications bell — used for the sound-on
|
|
51
|
+
// state. Off uses the same path with a diagonal slash overlay
|
|
52
|
+
// applied via CSS (.icon-svg.off → ::after rule in styles.css).
|
|
53
|
+
soundOn: 'M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2zm6-6V11c0-3.07-1.64-5.64-4.5-6.32V4a1.5 1.5 0 0 0-3 0v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z',
|
|
54
|
+
// Bell with a slash through it (off state). Single-path so the
|
|
55
|
+
// icon hot-swaps cleanly without dependency on overlay rules.
|
|
56
|
+
soundOff: 'M16.27 19.27 18 21l-1.27 1.27-1.45-1.45A1.98 1.98 0 0 1 14 22h-4a2 2 0 0 1-2-2h4a2 2 0 0 0 .27-.27L3 9.46 4.27 8.19 16.27 19.27zM18 16v-5a6 6 0 0 0-4.5-5.81V5a1.5 1.5 0 0 0-3 0v.19a6 6 0 0 0-2.16.91L18 16z',
|
|
50
57
|
};
|
|
51
58
|
|
|
52
59
|
export function icon(name, opts = {}) {
|
package/public/js/list-view.js
CHANGED
|
@@ -214,6 +214,48 @@ async function loadDraftsList(agent) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Refresh the currently-rendered list without rebuilding the
|
|
219
|
+
* toolbar. Used by the SSE new-mail handler so a new email
|
|
220
|
+
* silently slides into the list — no flicker, no "Loading…",
|
|
221
|
+
* no bulk-action selection wiped, no scroll jump.
|
|
222
|
+
*
|
|
223
|
+
* Drafts have their own loader; everything else uses the
|
|
224
|
+
* generic digest path. Falls through to a noop on errors
|
|
225
|
+
* (the SSE notification already pinged the user, so a silent
|
|
226
|
+
* refresh failure is acceptable — the next user-driven
|
|
227
|
+
* refresh / folder switch will repair).
|
|
228
|
+
*/
|
|
229
|
+
export async function silentRefresh(agent, folder) {
|
|
230
|
+
if (!agent) return;
|
|
231
|
+
try {
|
|
232
|
+
if (folder === 'drafts') {
|
|
233
|
+
const data = await apiGet('/drafts', { agentKey: agent.apiKey });
|
|
234
|
+
const rows = Array.isArray(data?.drafts) ? data.drafts : [];
|
|
235
|
+
state.messages = rows.map(r => ({
|
|
236
|
+
uid: r.id,
|
|
237
|
+
__draftId: r.id,
|
|
238
|
+
subject: r.subject || '(no subject)',
|
|
239
|
+
from: [{ name: agent.name, address: agent.email }],
|
|
240
|
+
date: r.updated_at ? `${r.updated_at}Z`.replace('ZZ', 'Z') : null,
|
|
241
|
+
preview: (r.text_body || '').slice(0, 240),
|
|
242
|
+
flags: [],
|
|
243
|
+
__recipient: r.to_addr || '(no recipient)',
|
|
244
|
+
}));
|
|
245
|
+
renderList();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
await ensureFolderCache(agent);
|
|
249
|
+
const isStarred = folder === 'starred';
|
|
250
|
+
const imap = isStarred ? 'INBOX' : (state.folderNames?.[folder]);
|
|
251
|
+
if (!imap) return;
|
|
252
|
+
const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
|
|
253
|
+
const data = await apiGet(url, { agentKey: agent.apiKey });
|
|
254
|
+
state.messages = data.messages ?? [];
|
|
255
|
+
renderList();
|
|
256
|
+
} catch { /* silent — next user action will repair */ }
|
|
257
|
+
}
|
|
258
|
+
|
|
217
259
|
export function renderList() {
|
|
218
260
|
const root = document.getElementById('list-rows');
|
|
219
261
|
if (!root) return;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Notification sound — soft 2-note chime synthesised via Web Audio
|
|
2
|
+
// API (no external asset shipped, zero network cost). User
|
|
3
|
+
// preference (on/off) lives in localStorage.
|
|
4
|
+
//
|
|
5
|
+
// Why Web Audio and not an <audio src="...">: the asset would have
|
|
6
|
+
// to be bundled (cache invalidation, MIME types, paths under
|
|
7
|
+
// `/branding/`, etc.), and we'd still need code to gate playback
|
|
8
|
+
// on a user-toggle. Synthesizing a chime is one short function
|
|
9
|
+
// with no asset surface and lets us tweak the timbre by editing
|
|
10
|
+
// numbers.
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = 'agenticmail.notif.soundEnabled';
|
|
13
|
+
|
|
14
|
+
/** True if the user has the chime turned on. Defaults to true. */
|
|
15
|
+
export function isSoundEnabled() {
|
|
16
|
+
// null = never set → default ON. 'false' string = explicitly off.
|
|
17
|
+
return localStorage.getItem(STORAGE_KEY) !== 'false';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Persist the user's choice. */
|
|
21
|
+
export function setSoundEnabled(enabled) {
|
|
22
|
+
try { localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false'); } catch { /* private mode */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Play the new-mail chime. Two short sine pulses an octave apart
|
|
27
|
+
* (E5 → A5), 220 ms total, gain envelope quick attack + 60 ms
|
|
28
|
+
* decay so it reads as a soft "ding" rather than a buzz. Bails
|
|
29
|
+
* silently when sound is disabled or the browser blocks audio
|
|
30
|
+
* (e.g. tab hasn't received a user gesture yet — first arrival
|
|
31
|
+
* after a page load with no interaction may be muted by the
|
|
32
|
+
* autoplay policy; subsequent arrivals work).
|
|
33
|
+
*/
|
|
34
|
+
export function playNotificationSound() {
|
|
35
|
+
if (!isSoundEnabled()) return;
|
|
36
|
+
try {
|
|
37
|
+
const Ctor = window.AudioContext || window.webkitAudioContext;
|
|
38
|
+
if (!Ctor) return;
|
|
39
|
+
const ctx = new Ctor();
|
|
40
|
+
const now = ctx.currentTime;
|
|
41
|
+
const playTone = (freq, startOffset, duration = 0.08) => {
|
|
42
|
+
const osc = ctx.createOscillator();
|
|
43
|
+
const gain = ctx.createGain();
|
|
44
|
+
osc.type = 'sine';
|
|
45
|
+
osc.frequency.value = freq;
|
|
46
|
+
const start = now + startOffset;
|
|
47
|
+
// Quick attack + exponential decay = "chime" not "beep".
|
|
48
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
49
|
+
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
|
|
50
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
|
51
|
+
osc.connect(gain).connect(ctx.destination);
|
|
52
|
+
osc.start(start);
|
|
53
|
+
osc.stop(start + duration);
|
|
54
|
+
};
|
|
55
|
+
playTone(659.25, 0); // E5
|
|
56
|
+
playTone(880.00, 0.12); // A5 — major sixth above
|
|
57
|
+
// Close the context shortly after the tones end so we don't
|
|
58
|
+
// leak audio contexts. Some browsers cap at ~6 concurrent.
|
|
59
|
+
setTimeout(() => { try { ctx.close(); } catch {} }, 600);
|
|
60
|
+
} catch { /* audio blocked; user-toggle is still respected */ }
|
|
61
|
+
}
|
package/public/js/sse.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// Real-time mail delivery via Server-Sent Events. Every agent gets
|
|
2
2
|
// its own subscription; the dispatcher pushes a `new` event per
|
|
3
3
|
// arrived message. We fan that out to:
|
|
4
|
-
// 1. List view —
|
|
4
|
+
// 1. List view — silent in-place refresh (no flicker, no scroll
|
|
5
|
+
// jump, no bulk-selection wipe) if it's the active inbox
|
|
5
6
|
// 2. Profile dropdown — bump the per-agent unread counter
|
|
6
7
|
// 3. Browser notification — system ping when the tab is in the background
|
|
8
|
+
// 4. Soft chime (toggleable) when sound is enabled
|
|
7
9
|
import { state, API_URL } from './state.js';
|
|
8
10
|
import { toast } from './utils.js';
|
|
9
11
|
import { renderProfile } from './profile.js';
|
|
10
|
-
import {
|
|
12
|
+
import { silentRefresh } from './list-view.js';
|
|
13
|
+
import { playNotificationSound } from './sound.js';
|
|
11
14
|
|
|
12
15
|
export function subscribeToAllAgents() {
|
|
13
16
|
// Tear down previous controllers (called on agent-list refresh).
|
|
@@ -49,11 +52,23 @@ async function handleSseEvent(agent, event) {
|
|
|
49
52
|
|
|
50
53
|
const isOpen = state.selectedAgent?.id === agent.id;
|
|
51
54
|
if (isOpen) {
|
|
52
|
-
|
|
55
|
+
// Silent in-place refresh — re-fetches the list digest and
|
|
56
|
+
// re-renders ONLY the rows div. Toolbar (select-all, refresh,
|
|
57
|
+
// bulk-actions) is untouched; existing row checkboxes survive;
|
|
58
|
+
// scroll position is preserved by the browser since we replace
|
|
59
|
+
// only the inner content. No "Loading…" flicker.
|
|
60
|
+
await silentRefresh(agent, state.selectedFolder);
|
|
53
61
|
state.unread[agent.id] = 0; // user is looking — clear badge
|
|
54
62
|
renderProfile();
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
// Soft chime — respects the user's sound toggle. Plays for every
|
|
66
|
+
// arrival regardless of whether the tab is focused, because that
|
|
67
|
+
// is the whole point of the chime (a foregrounded tab still
|
|
68
|
+
// benefits from the audible ping when the user's attention is
|
|
69
|
+
// elsewhere on screen).
|
|
70
|
+
playNotificationSound();
|
|
71
|
+
|
|
57
72
|
fireBrowserNotification(agent, event, isOpen);
|
|
58
73
|
|
|
59
74
|
if (!isOpen) {
|
package/public/styles.css
CHANGED
|
@@ -159,6 +159,10 @@ a { color: var(--accent-strong); }
|
|
|
159
159
|
color: var(--ink-soft); font-size: 18px;
|
|
160
160
|
}
|
|
161
161
|
.icon-btn:hover { background: var(--bg-hover); }
|
|
162
|
+
/* Sound-toggle state colours. On = brand pink (alive); off = muted
|
|
163
|
+
so the button reads as "currently silent". */
|
|
164
|
+
#sound-toggle-btn.sound-on { color: var(--pink); }
|
|
165
|
+
#sound-toggle-btn.sound-off { color: var(--muted); }
|
|
162
166
|
|
|
163
167
|
/* ─── Profile (top right) ─────────────────────────────────────────── */
|
|
164
168
|
.profile-trigger {
|