9bbab5d32a
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
146 lines
5.1 KiB
React
146 lines
5.1 KiB
React
import { useState } from 'react';
|
||
import { EmailApi } from '../api/client.js';
|
||
|
||
const fmtDate = (iso) => {
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const diffDays = (now - d) / 86400000;
|
||
if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||
};
|
||
|
||
const fmtSize = (b) => {
|
||
if (b < 1024) return `${b} B`;
|
||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||
return `${(b / 1048576).toFixed(1)} MB`;
|
||
};
|
||
|
||
// "Why this matched": the API wraps matched terms in U+E000/U+E001 sentinels (NOT HTML).
|
||
// We tokenise and render the highlighted parts as <mark> React elements — React escapes
|
||
// all text nodes, so untrusted email content can never inject markup (no dangerouslySetInnerHTML).
|
||
const HL_START = String.fromCharCode(0xE000);
|
||
const HL_STOP = String.fromCharCode(0xE001);
|
||
const HL_RE = new RegExp(HL_START + '([\s\S]*?)' + HL_STOP, 'g');
|
||
function renderHighlight(s) {
|
||
const out = [];
|
||
let last = 0, key = 0, m;
|
||
HL_RE.lastIndex = 0;
|
||
while ((m = HL_RE.exec(s)) !== null) {
|
||
if (m.index > last) out.push(s.slice(last, m.index));
|
||
out.push(<mark key={key++}>{m[1]}</mark>);
|
||
last = HL_RE.lastIndex;
|
||
}
|
||
if (last < s.length) out.push(s.slice(last));
|
||
return out;
|
||
}
|
||
|
||
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
|
||
const [email, setEmail] = useState(initial);
|
||
const [acting, setActing] = useState(false);
|
||
|
||
const act = (fn, patch) => async (e) => {
|
||
e.stopPropagation();
|
||
if (acting) return;
|
||
setActing(true);
|
||
try {
|
||
await fn(email.id);
|
||
setEmail((prev) => ({ ...prev, ...patch }));
|
||
} finally {
|
||
setActing(false);
|
||
}
|
||
};
|
||
|
||
const handleUnsub = async (e) => {
|
||
e.stopPropagation();
|
||
if (acting) return;
|
||
setActing(true);
|
||
try {
|
||
const res = await EmailApi.unsubscribe(email.id);
|
||
if (res.method === 'mailto') {
|
||
window.location.href = res.target;
|
||
} else {
|
||
setEmail((prev) => ({ ...prev, _unsubDone: true }));
|
||
}
|
||
} finally {
|
||
setActing(false);
|
||
}
|
||
};
|
||
|
||
const handleTrash = async (e) => {
|
||
e.stopPropagation();
|
||
if (acting) return;
|
||
setActing(true);
|
||
try {
|
||
await EmailApi.trash(email.id);
|
||
onRemove?.(email.id);
|
||
} finally {
|
||
setActing(false);
|
||
}
|
||
};
|
||
|
||
const openInGmail = () => window.open(
|
||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||
'_blank',
|
||
'noopener,noreferrer'
|
||
);
|
||
|
||
return (
|
||
<tr
|
||
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
|
||
onClick={openInGmail}
|
||
title="Open in Gmail"
|
||
>
|
||
{onToggleSelect && (
|
||
<td className="el-select" onClick={(e) => e.stopPropagation()}>
|
||
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
|
||
</td>
|
||
)}
|
||
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||
<td className="el-sender" title={email.senderAddress}>
|
||
{email.senderDisplayName || email.senderAddress}
|
||
</td>
|
||
<td className="el-subject">
|
||
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||
{email.matchHighlight
|
||
? <span className="el-snippet"> — {renderHighlight(email.matchHighlight)}</span>
|
||
: email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||
</td>
|
||
<td className="el-meta">
|
||
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||
</td>
|
||
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
className="action-btn"
|
||
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||
onClick={email.isUnread
|
||
? act(EmailApi.markRead, { isUnread: false })
|
||
: act(EmailApi.markUnread, { isUnread: true })}
|
||
>{email.isUnread ? '✓' : '●'}</button>
|
||
<button
|
||
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||
title={email.isStarred ? 'Unstar' : 'Star'}
|
||
onClick={email.isStarred
|
||
? act(EmailApi.unstar, { isStarred: false })
|
||
: act(EmailApi.star, { isStarred: true })}
|
||
>⭐</button>
|
||
{email.hasListUnsubscribe && (
|
||
<button
|
||
className={`action-btn action-btn--unsub${email._unsubDone ? ' action-btn--done' : ''}`}
|
||
title={email._unsubDone ? 'Unsubscribed' : 'Unsubscribe'}
|
||
onClick={handleUnsub}
|
||
disabled={email._unsubDone}
|
||
>{email._unsubDone ? '✓' : '✉✕'}</button>
|
||
)}
|
||
<button
|
||
className="action-btn action-btn--danger"
|
||
title="Move to trash"
|
||
onClick={handleTrash}
|
||
>🗑️</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|