@agenticmail/api 0.7.7 → 0.7.9
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/README.md +8 -0
- package/package.json +1 -1
- package/public/index.html +56 -840
- package/public/js/api.js +25 -0
- package/public/js/app.js +207 -0
- package/public/js/avatar.js +46 -0
- package/public/js/compose.js +81 -0
- package/public/js/icons.js +56 -0
- package/public/js/list-view.js +118 -0
- package/public/js/markdown.js +97 -0
- package/public/js/message-view.js +87 -0
- package/public/js/profile.js +54 -0
- package/public/js/search.js +45 -0
- package/public/js/sidebar.js +40 -0
- package/public/js/sse.js +97 -0
- package/public/js/state.js +17 -0
- package/public/js/time.js +33 -0
- package/public/js/utils.js +22 -0
- package/public/styles.css +597 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Single-message detail view, opened when the user clicks a list row.
|
|
2
|
+
import { state } from './state.js';
|
|
3
|
+
import { escapeHtml, stripHtml, toast } from './utils.js';
|
|
4
|
+
import { formatDateFull } from './time.js';
|
|
5
|
+
import { renderMarkdown } from './markdown.js';
|
|
6
|
+
import { avatarHtml } from './avatar.js';
|
|
7
|
+
import { apiGet, apiPost } from './api.js';
|
|
8
|
+
import { openReply } from './compose.js';
|
|
9
|
+
import { loadList } from './list-view.js';
|
|
10
|
+
import { icon } from './icons.js';
|
|
11
|
+
|
|
12
|
+
export async function openMessage(uid) {
|
|
13
|
+
if (!state.selectedAgent) return;
|
|
14
|
+
state.selectedUid = uid;
|
|
15
|
+
const root = document.getElementById('content');
|
|
16
|
+
root.innerHTML = `
|
|
17
|
+
<div class="message-toolbar">
|
|
18
|
+
<button class="icon-btn" id="msg-back" title="Back to list">${icon('back')}</button>
|
|
19
|
+
<button class="icon-btn" id="msg-reply" title="Reply">${icon('reply')}</button>
|
|
20
|
+
<button class="icon-btn" id="msg-reply-all" title="Reply all">${icon('replyAll')}</button>
|
|
21
|
+
<button class="icon-btn" id="msg-unread" title="Mark unread">${icon('mailUnread')}</button>
|
|
22
|
+
<div class="toolbar-spacer"></div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="message-view"><div class="empty">Loading…</div></div>
|
|
25
|
+
`;
|
|
26
|
+
document.getElementById('msg-back').addEventListener('click', () => { location.hash = '#/inbox'; });
|
|
27
|
+
document.getElementById('msg-reply').addEventListener('click', () => openReply(false));
|
|
28
|
+
document.getElementById('msg-reply-all').addEventListener('click', () => openReply(true));
|
|
29
|
+
document.getElementById('msg-unread').addEventListener('click', () => markUnread());
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const msg = await apiGet(`/mail/messages/${uid}`, { agentKey: state.selectedAgent.apiKey });
|
|
33
|
+
state.currentMessage = msg;
|
|
34
|
+
renderMessage(msg);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
root.querySelector('.message-view').innerHTML =
|
|
37
|
+
`<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderMessage(msg) {
|
|
42
|
+
const view = document.querySelector('.message-view');
|
|
43
|
+
if (!view) return;
|
|
44
|
+
const fromAddr = msg.from?.[0]?.address ?? '?';
|
|
45
|
+
const fromName = msg.from?.[0]?.name || fromAddr;
|
|
46
|
+
const toStr = (msg.to ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
|
|
47
|
+
const ccStr = (msg.cc ?? []).map(a => a.address).join(', ');
|
|
48
|
+
const senderPseudo = { name: fromName }; // for avatar generation
|
|
49
|
+
const bodyText = msg.text ?? stripHtml(msg.html ?? '');
|
|
50
|
+
|
|
51
|
+
const attachmentsHtml = (msg.attachments ?? []).length > 0
|
|
52
|
+
? `<div class="message-attachments">${msg.attachments.map(a =>
|
|
53
|
+
`<span class="message-attachment"><span class="att-icon">${icon('attachment', { size: 18 })}</span>${escapeHtml(a.filename ?? '(unnamed)')}${a.size ? ` · ${Math.round(a.size/1024)}KB` : ''}</span>`
|
|
54
|
+
).join('')}</div>`
|
|
55
|
+
: '';
|
|
56
|
+
|
|
57
|
+
view.innerHTML = `
|
|
58
|
+
<div class="message-header">
|
|
59
|
+
<h1 class="message-subject">${escapeHtml(msg.subject ?? '(no subject)')}</h1>
|
|
60
|
+
<div class="message-sender-row">
|
|
61
|
+
${avatarHtml(senderPseudo, 'avatar-md')}
|
|
62
|
+
<div class="message-meta">
|
|
63
|
+
<div class="message-from">
|
|
64
|
+
<span class="name">${escapeHtml(fromName)}</span>
|
|
65
|
+
<span class="addr"><${escapeHtml(fromAddr)}></span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="message-to">to ${escapeHtml(toStr)}${ccStr ? `, cc ${escapeHtml(ccStr)}` : ''}</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="message-date">${escapeHtml(formatDateFull(msg.date))}</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="message-body">${renderMarkdown(bodyText)}</div>
|
|
73
|
+
${attachmentsHtml}
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function markUnread() {
|
|
78
|
+
if (!state.currentMessage || !state.selectedAgent) return;
|
|
79
|
+
try {
|
|
80
|
+
await apiPost(`/mail/messages/${state.currentMessage.uid}/unseen`, {}, { agentKey: state.selectedAgent.apiKey });
|
|
81
|
+
toast('Marked unread.');
|
|
82
|
+
location.hash = '#/inbox';
|
|
83
|
+
await loadList(state.selectedAgent, state.selectedFolder);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
toast(`Failed: ${err.message}`, true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Top-right Gmail-style account switcher. Lists every AgenticMail
|
|
2
|
+
// agent the master key can see; clicking switches the active inbox.
|
|
3
|
+
// The bridge agent (host) gets a green checkmark + "Host" badge so
|
|
4
|
+
// it's distinguishable from sub-agent inboxes.
|
|
5
|
+
import { state } from './state.js';
|
|
6
|
+
import { escapeHtml } from './utils.js';
|
|
7
|
+
import { avatarHtml, isBridgeAgent } from './avatar.js';
|
|
8
|
+
import { icon } from './icons.js';
|
|
9
|
+
|
|
10
|
+
export function renderProfile() {
|
|
11
|
+
const a = state.selectedAgent;
|
|
12
|
+
const totalOtherUnread = Object.entries(state.unread ?? {})
|
|
13
|
+
.filter(([id]) => id !== a?.id)
|
|
14
|
+
.reduce((sum, [, n]) => sum + n, 0);
|
|
15
|
+
|
|
16
|
+
const avatarEl = document.getElementById('profile-avatar');
|
|
17
|
+
if (avatarEl) {
|
|
18
|
+
avatarEl.innerHTML = a
|
|
19
|
+
? avatarHtml(a) + (totalOtherUnread > 0 ? `<span class="avatar-check" style="background:#dc2626">${icon('dot', { size: 8 })}</span>` : '')
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const list = document.getElementById('profile-menu-list');
|
|
24
|
+
if (!list) return;
|
|
25
|
+
list.innerHTML = state.agents.map(agent => {
|
|
26
|
+
const selected = state.selectedAgent?.id === agent.id;
|
|
27
|
+
const badge = isBridgeAgent(agent)
|
|
28
|
+
? '<span class="role-badge role-badge-host">Host</span>'
|
|
29
|
+
: '<span class="role-badge role-badge-sub">Sub-agent</span>';
|
|
30
|
+
const check = selected ? `<span class="selected-check">${icon('check', { size: 20 })}</span>` : '';
|
|
31
|
+
const unread = state.unread?.[agent.id] ?? 0;
|
|
32
|
+
const unreadDot = unread > 0
|
|
33
|
+
? `<span class="role-badge" style="background:var(--pink);color:white;">${unread} new</span>`
|
|
34
|
+
: '';
|
|
35
|
+
return `
|
|
36
|
+
<div class="profile-menu-item" data-id="${agent.id}">
|
|
37
|
+
${avatarHtml(agent, 'avatar-md')}
|
|
38
|
+
<div class="meta">
|
|
39
|
+
<div class="name">${escapeHtml(agent.name)} ${badge} ${unreadDot}</div>
|
|
40
|
+
<div class="email">${escapeHtml(agent.email ?? '')}</div>
|
|
41
|
+
</div>
|
|
42
|
+
${check}
|
|
43
|
+
</div>
|
|
44
|
+
`;
|
|
45
|
+
}).join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toggleProfileMenu(e) {
|
|
49
|
+
if (e) e.stopPropagation();
|
|
50
|
+
document.getElementById('profile-menu').classList.toggle('open');
|
|
51
|
+
}
|
|
52
|
+
export function closeProfileMenu() {
|
|
53
|
+
document.getElementById('profile-menu').classList.remove('open');
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Gmail-style search query parser + match predicate + visual highlighter.
|
|
2
|
+
//
|
|
3
|
+
// "from:vesper" → only mail from vesper
|
|
4
|
+
// "subject:audit" → only mail with "audit" in the subject
|
|
5
|
+
// "audit from:vesper" → both must match
|
|
6
|
+
// "build small game" → free-text match across from + subject + preview
|
|
7
|
+
import { escapeHtml } from './utils.js';
|
|
8
|
+
|
|
9
|
+
export function parseSearch(query) {
|
|
10
|
+
const filters = { from: '', subject: '', text: '' };
|
|
11
|
+
const remaining = [];
|
|
12
|
+
const tokenRe = /(\w+):("([^"]*)"|(\S+))|("([^"]*)"|(\S+))/g;
|
|
13
|
+
let m;
|
|
14
|
+
while ((m = tokenRe.exec(query)) !== null) {
|
|
15
|
+
const op = m[1]?.toLowerCase();
|
|
16
|
+
const opVal = (m[3] ?? m[4] ?? '').toLowerCase();
|
|
17
|
+
const free = (m[6] ?? m[7] ?? '').toLowerCase();
|
|
18
|
+
if (op === 'from') filters.from = opVal;
|
|
19
|
+
else if (op === 'subject') filters.subject = opVal;
|
|
20
|
+
else if (free) remaining.push(free);
|
|
21
|
+
}
|
|
22
|
+
filters.text = remaining.join(' ');
|
|
23
|
+
return filters;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function matchesSearch(msg, filters) {
|
|
27
|
+
const fromAddr = (msg.from?.[0]?.address ?? '').toLowerCase();
|
|
28
|
+
const fromName = (msg.from?.[0]?.name ?? '').toLowerCase();
|
|
29
|
+
const subject = (msg.subject ?? '').toLowerCase();
|
|
30
|
+
const preview = (msg.preview ?? '').toLowerCase();
|
|
31
|
+
if (filters.from && !fromAddr.includes(filters.from) && !fromName.includes(filters.from)) return false;
|
|
32
|
+
if (filters.subject && !subject.includes(filters.subject)) return false;
|
|
33
|
+
if (filters.text) {
|
|
34
|
+
const hay = `${fromAddr} ${fromName} ${subject} ${preview}`;
|
|
35
|
+
if (!hay.includes(filters.text)) return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function highlightTerm(text, term) {
|
|
41
|
+
const safe = escapeHtml(text ?? '');
|
|
42
|
+
if (!term) return safe;
|
|
43
|
+
const escaped = term.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
44
|
+
return safe.replace(new RegExp(`(${escaped})`, 'ig'), '<mark class="search-hl">$1</mark>');
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Gmail-style folder sidebar.
|
|
2
|
+
//
|
|
3
|
+
// AgenticMail's mail store is IMAP-backed (Stalwart), so "folders"
|
|
4
|
+
// here are IMAP mailbox names. We expose the same shortlist Gmail's
|
|
5
|
+
// sidebar shows. The "All Mail" entry is a convenience that maps to
|
|
6
|
+
// the inbox endpoint until per-mailbox listing is wired through the
|
|
7
|
+
// public API.
|
|
8
|
+
import { state } from './state.js';
|
|
9
|
+
import { icon } from './icons.js';
|
|
10
|
+
|
|
11
|
+
export const FOLDERS = [
|
|
12
|
+
{ id: 'inbox', label: 'Inbox', icon: 'inbox' },
|
|
13
|
+
{ id: 'starred', label: 'Starred', icon: 'starOutline' },
|
|
14
|
+
{ id: 'sent', label: 'Sent', icon: 'sent' },
|
|
15
|
+
{ id: 'drafts', label: 'Drafts', icon: 'drafts' },
|
|
16
|
+
{ id: 'all', label: 'All Mail', icon: 'allMail' },
|
|
17
|
+
{ id: 'spam', label: 'Spam', icon: 'spam' },
|
|
18
|
+
{ id: 'trash', label: 'Trash', icon: 'trash' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function renderSidebar(onSelect) {
|
|
22
|
+
const root = document.getElementById('folder-list');
|
|
23
|
+
if (!root) return;
|
|
24
|
+
const active = state.selectedFolder ?? 'inbox';
|
|
25
|
+
const unread = state.unread?.[state.selectedAgent?.id] ?? 0;
|
|
26
|
+
root.innerHTML = FOLDERS.map(f => {
|
|
27
|
+
const isActive = f.id === active;
|
|
28
|
+
const showCount = f.id === 'inbox' && unread > 0;
|
|
29
|
+
return `
|
|
30
|
+
<div class="folder-row ${isActive ? 'active' : ''}" data-folder="${f.id}">
|
|
31
|
+
<span class="icon">${icon(f.icon, { size: 20 })}</span>
|
|
32
|
+
<span class="label">${f.label}</span>
|
|
33
|
+
<span class="count" ${showCount ? '' : 'data-zero'}>${showCount ? unread : ''}</span>
|
|
34
|
+
</div>
|
|
35
|
+
`;
|
|
36
|
+
}).join('');
|
|
37
|
+
root.querySelectorAll('.folder-row').forEach(el => {
|
|
38
|
+
el.addEventListener('click', () => onSelect(el.dataset.folder));
|
|
39
|
+
});
|
|
40
|
+
}
|
package/public/js/sse.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Real-time mail delivery via Server-Sent Events. Every agent gets
|
|
2
|
+
// its own subscription; the dispatcher pushes a `new` event per
|
|
3
|
+
// arrived message. We fan that out to:
|
|
4
|
+
// 1. List view — reload if it's the active inbox (instant ping)
|
|
5
|
+
// 2. Profile dropdown — bump the per-agent unread counter
|
|
6
|
+
// 3. Browser notification — system ping when the tab is in the background
|
|
7
|
+
import { state, API_URL } from './state.js';
|
|
8
|
+
import { toast } from './utils.js';
|
|
9
|
+
import { renderProfile } from './profile.js';
|
|
10
|
+
import { loadList } from './list-view.js';
|
|
11
|
+
|
|
12
|
+
export function subscribeToAllAgents() {
|
|
13
|
+
// Tear down previous controllers (called on agent-list refresh).
|
|
14
|
+
for (const c of state.sseControllers) { try { c.abort(); } catch {} }
|
|
15
|
+
state.sseControllers = [];
|
|
16
|
+
for (const agent of state.agents) {
|
|
17
|
+
const ctrl = new AbortController();
|
|
18
|
+
state.sseControllers.push(ctrl);
|
|
19
|
+
fetch(`${API_URL}/api/agenticmail/events`, {
|
|
20
|
+
headers: { Authorization: `Bearer ${agent.apiKey}`, Accept: 'text/event-stream' },
|
|
21
|
+
signal: ctrl.signal,
|
|
22
|
+
}).then(async res => {
|
|
23
|
+
if (!res.ok || !res.body) return;
|
|
24
|
+
const reader = res.body.getReader();
|
|
25
|
+
const dec = new TextDecoder();
|
|
26
|
+
let buf = '';
|
|
27
|
+
while (!ctrl.signal.aborted) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
buf += dec.decode(value, { stream: true });
|
|
31
|
+
let i;
|
|
32
|
+
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
33
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
34
|
+
for (const line of frame.split('\n')) {
|
|
35
|
+
if (!line.startsWith('data: ')) continue;
|
|
36
|
+
try { handleSseEvent(agent, JSON.parse(line.slice(6))); } catch {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}).catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handleSseEvent(agent, event) {
|
|
45
|
+
if (event.type !== 'new') return;
|
|
46
|
+
state.unread = state.unread ?? {};
|
|
47
|
+
state.unread[agent.id] = (state.unread[agent.id] ?? 0) + 1;
|
|
48
|
+
renderProfile();
|
|
49
|
+
|
|
50
|
+
const isOpen = state.selectedAgent?.id === agent.id;
|
|
51
|
+
if (isOpen) {
|
|
52
|
+
await loadList(agent, state.selectedFolder);
|
|
53
|
+
state.unread[agent.id] = 0; // user is looking — clear badge
|
|
54
|
+
renderProfile();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fireBrowserNotification(agent, event, isOpen);
|
|
58
|
+
|
|
59
|
+
if (!isOpen) {
|
|
60
|
+
const fromAddr = event.from?.address ?? event.from ?? 'someone';
|
|
61
|
+
const subject = event.subject ?? '(no subject)';
|
|
62
|
+
toast(`${agent.name}: ${subject} — from ${fromAddr}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function maybeRequestNotificationPermission() {
|
|
67
|
+
if (!('Notification' in window)) return;
|
|
68
|
+
if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
|
|
69
|
+
if (localStorage.getItem('agenticmail.notif.asked')) return;
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
Notification.requestPermission().finally(() => {
|
|
72
|
+
localStorage.setItem('agenticmail.notif.asked', '1');
|
|
73
|
+
});
|
|
74
|
+
}, 2000);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fireBrowserNotification(agent, event, isOpen) {
|
|
78
|
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
79
|
+
if (isOpen && document.visibilityState === 'visible') return;
|
|
80
|
+
const fromAddr = event.from?.address ?? event.from ?? 'unknown sender';
|
|
81
|
+
const subject = event.subject ?? '(no subject)';
|
|
82
|
+
try {
|
|
83
|
+
const n = new Notification(subject, {
|
|
84
|
+
body: `${agent.name} — from ${fromAddr}`,
|
|
85
|
+
icon: '/favicon.ico',
|
|
86
|
+
tag: `agenticmail-${agent.id}-${event.uid}`,
|
|
87
|
+
silent: false,
|
|
88
|
+
});
|
|
89
|
+
n.onclick = () => {
|
|
90
|
+
window.focus();
|
|
91
|
+
// Switching agent here requires the router; let the user click
|
|
92
|
+
// through manually so we don't tightly couple sse → router.
|
|
93
|
+
if (event.uid) location.hash = `#/m/${event.uid}`;
|
|
94
|
+
n.close();
|
|
95
|
+
};
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared mutable state for the AgenticMail web UI.
|
|
2
|
+
// One module, imported wherever state is read or written.
|
|
3
|
+
export const state = {
|
|
4
|
+
masterKey: null,
|
|
5
|
+
agents: [],
|
|
6
|
+
selectedAgent: null,
|
|
7
|
+
selectedFolder: 'inbox', // 'inbox' | 'sent' | 'drafts' | 'starred' | 'spam' | 'trash' | 'all'
|
|
8
|
+
messages: [],
|
|
9
|
+
selectedUid: null,
|
|
10
|
+
currentMessage: null,
|
|
11
|
+
composeReplyContext: null,
|
|
12
|
+
searchQuery: '',
|
|
13
|
+
sseControllers: [],
|
|
14
|
+
unread: {}, // { [agentId]: count }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const API_URL = window.location.origin;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Gmail-style relative date formatting.
|
|
2
|
+
//
|
|
3
|
+
// formatDate → short label for list rows (e.g. "10:42 AM", "Mon", "Mar 4")
|
|
4
|
+
// formatDateFull → verbose label for the open-message header
|
|
5
|
+
|
|
6
|
+
export function formatDate(iso) {
|
|
7
|
+
if (!iso) return '';
|
|
8
|
+
const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
|
|
9
|
+
const now = new Date();
|
|
10
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
11
|
+
if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
12
|
+
const days = Math.round((now - d) / (24 * 3600 * 1000));
|
|
13
|
+
if (days < 7) return d.toLocaleDateString(undefined, { weekday: 'short' });
|
|
14
|
+
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
15
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatDateFull(iso) {
|
|
19
|
+
if (!iso) return '';
|
|
20
|
+
const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const deltaMs = now - d;
|
|
23
|
+
let rel = '';
|
|
24
|
+
if (deltaMs < 45_000) rel = 'just now';
|
|
25
|
+
else if (deltaMs < 60 * 60 * 1000) rel = `${Math.round(deltaMs / 60_000)} minutes ago`;
|
|
26
|
+
else if (deltaMs < 24 * 60 * 60 * 1000) rel = `${Math.round(deltaMs / 3_600_000)} hours ago`;
|
|
27
|
+
const abs = d.toLocaleString(undefined, {
|
|
28
|
+
weekday: 'short', month: 'short', day: 'numeric',
|
|
29
|
+
hour: 'numeric', minute: '2-digit',
|
|
30
|
+
...(d.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}),
|
|
31
|
+
});
|
|
32
|
+
return rel ? `${rel} — ${abs}` : abs;
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tiny shared utilities: HTML escape, strip HTML, toast.
|
|
2
|
+
// Kept in their own module so view modules don't each redefine them.
|
|
3
|
+
|
|
4
|
+
export function escapeHtml(s) {
|
|
5
|
+
return String(s ?? '')
|
|
6
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function stripHtml(s) {
|
|
11
|
+
return (s ?? '').replace(/<[^>]*>/g, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function toast(msg, error = false) {
|
|
15
|
+
const t = document.getElementById('toast');
|
|
16
|
+
if (!t) return;
|
|
17
|
+
t.textContent = msg;
|
|
18
|
+
t.classList.toggle('error', error);
|
|
19
|
+
t.classList.add('show');
|
|
20
|
+
clearTimeout(t._timer);
|
|
21
|
+
t._timer = setTimeout(() => t.classList.remove('show'), 2500);
|
|
22
|
+
}
|