Files
Inboxintel/frontend/src/components/EmailRow.jsx
T
cesnimda 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
feat(search): why this matched highlights (#15)
2026-07-01 22:22:38 +02:00

146 lines
5.1 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}