feat: quick actions on email rows (read/unread, star, trash)
- EmailRow shared component with hover action buttons: mark read/unread, star/unstar, trash
- Optimistic UI — local state patches immediately on click; row fades and becomes non-interactive during the API call
- Trashed rows disappear from the current folder view via onRemove callback
- Backend: EmailController single-email endpoints (POST /email/{id}/read|unread|star|unstar|trash|untrash)
- Star/Unstar added to CleanupActionType enum and CleanupService (maps to STARRED Gmail label via BatchModifyAsync)
- UserId scope enforced in ResolveTargetsAsync — a user can only act on their own emails
- action buttons use stopPropagation so clicking them does not open Gmail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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`;
|
||||
};
|
||||
|
||||
export default function EmailRow({ email: initial, onRemove }) {
|
||||
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 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' : ''}`}
|
||||
onClick={openInGmail}
|
||||
title="Open in Gmail"
|
||||
>
|
||||
<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.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>
|
||||
<button
|
||||
className="action-btn action-btn--danger"
|
||||
title="Move to trash"
|
||||
onClick={handleTrash}
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user