Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 34 additions & 29 deletions apps/web/src/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,10 +735,7 @@ function MessageRow({
onTrash,
onReadToggle,
onImportant,
readActionLabel,
readActionText,
openLabel,
openActionText,
t,
actionsEnabled,
}: {
message: InboxThread;
Expand All @@ -751,14 +748,17 @@ function MessageRow({
onTrash: () => void;
onReadToggle: () => void;
onImportant: () => void;
readActionLabel: string;
readActionText: string;
openLabel: string;
openActionText: string;
t: Translator;
actionsEnabled: boolean;
}) {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const senderText =
message.senderName.trim() || message.senderAddress.trim() || t("unknownSender");
const subjectText = message.subject.trim() || t("noSubject");
const previewText = message.preview.trim() || t("noPreview");
const actionIdentity = message.subject.trim() || senderText;
const readActionText = t(message.isRead ? "markUnread" : "markRead");
useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
Expand Down Expand Up @@ -792,33 +792,38 @@ function MessageRow({
className={selected ? "select-box checked" : "select-box"}
type="button"
onClick={onSelect}
aria-label={`Select ${message.subject || message.senderName}`}
aria-label={`${t("selectMessage")} ${actionIdentity}`}
>
{selected ? "✓" : ""}
</button>
<button
className={starred ? "row-icon starred" : "row-icon"}
type="button"
onClick={onStar}
aria-label={`${starred ? "Unstar" : "Star"} ${message.subject || message.senderName}`}
aria-label={`${t(starred ? "unstarMessage" : "starMessage")} ${actionIdentity}`}
disabled={!actionsEnabled}
>
<Star size={16} fill={starred ? "currentColor" : "none"} />
</button>
<button
className="row-icon important"
type="button"
aria-label={`Mark ${message.subject || message.senderName} important`}
aria-label={`${t(message.isImportant ? "markUnimportant" : "markImportant")} ${actionIdentity}`}
onClick={onImportant}
disabled={!actionsEnabled}
>
<ChevronsUpDown size={16} />
</button>
<button className="message-content" type="button" onClick={onOpen} aria-label={openLabel}>
<span className="sender">{message.senderName || message.senderAddress}</span>
<button
className="message-content"
type="button"
onClick={onOpen}
aria-label={`${t("openMessage")} ${actionIdentity}`}
>
<span className="sender">{senderText}</span>
<span className="subject-line">
<strong>{message.subject}</strong>
<span> — {message.preview}</span>
<strong>{subjectText}</strong>
{message.preview.trim() ? <span> — {previewText}</span> : <span> · {previewText}</span>}
</span>
{message.attachmentCount > 0 && (
<span className="attachment-chip">
Expand All @@ -837,13 +842,17 @@ function MessageRow({
</button>
{actionsEnabled && (
<div className="quick-actions">
<Button size="icon" aria-label="Archive" onClick={onArchive}>
<Button size="icon" aria-label={t("archive")} onClick={onArchive}>
<Archive size={16} />
</Button>
<Button size="icon" aria-label="Delete" onClick={onTrash}>
<Button size="icon" aria-label={t("delete")} onClick={onTrash}>
<Trash2 size={16} />
</Button>
<Button size="icon" aria-label={readActionLabel} onClick={onReadToggle}>
<Button
size="icon"
aria-label={`${readActionText} ${actionIdentity}`}
onClick={onReadToggle}
>
<Mail size={16} />
</Button>
</div>
Expand All @@ -854,7 +863,7 @@ function MessageRow({
ref={menuRef}
className="message-context-menu ui-menu"
role="menu"
aria-label={`Actions for ${message.subject || message.senderName}`}
aria-label={`${t("actionsFor")} ${actionIdentity}`}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
Expand Down Expand Up @@ -887,7 +896,7 @@ function MessageRow({
setMenu(null);
}}
>
<MailOpen size={16} /> <span>{openActionText}</span>
<MailOpen size={16} /> <span>{t("openMessage")}</span>
</button>
{actionsEnabled && (
<>
Expand All @@ -900,7 +909,7 @@ function MessageRow({
setMenu(null);
}}
>
<Archive size={16} /> Archive
<Archive size={16} /> <span>{t("archive")}</span>
</button>
<button
className="ui-menu-item"
Expand All @@ -922,7 +931,8 @@ function MessageRow({
setMenu(null);
}}
>
<Tag size={16} /> {message.isImportant ? "Mark unimportant" : "Mark important"}
<Tag size={16} />
<span>{t(message.isImportant ? "markUnimportant" : "markImportant")}</span>
</button>
<button
className="ui-menu-item danger"
Expand All @@ -933,7 +943,7 @@ function MessageRow({
setMenu(null);
}}
>
<Trash2 size={16} /> Delete
<Trash2 size={16} /> <span>{t("delete")}</span>
</button>
</>
)}
Expand Down Expand Up @@ -1002,12 +1012,7 @@ function VirtualMessageList({
message.id,
])
}
readActionLabel={`${t(message.isRead ? "markUnread" : "markRead")} ${
message.subject || message.senderName
}`}
readActionText={t(message.isRead ? "markUnread" : "markRead")}
openLabel={`${t("openMessage")} ${message.subject || message.senderName}`}
openActionText={t("openMessage")}
t={t}
actionsEnabled={actionsEnabled}
/>
</div>
Expand Down
8 changes: 6 additions & 2 deletions apps/web/tests/inbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ test("live inbox virtualizes large account-scoped pages and preserves selection"
const syntheticThreads = Array.from({ length: itemCount }, (_, index) => ({
id: `10000000-0000-7000-8000-${index.toString(16).padStart(12, "0")}`,
accountId: account.id,
senderName: `Sender ${index}`,
senderAddress: `sender-${index}@example.test`,
senderName: index === 1 ? "" : `Sender ${index}`,
senderAddress: index === 1 ? "" : `sender-${index}@example.test`,
subject: "",
preview: "",
lastMessageAt: "2026-09-07T17:00:00Z",
Expand Down Expand Up @@ -397,6 +397,10 @@ test("live inbox virtualizes large account-scoped pages and preserves selection"
await page.getByRole("button", { name: "Refresh" }).click();
await expect(firstSelection).toBeVisible({ timeout: 20_000 });
await expect(firstSelection).toHaveClass(/checked/);
await expect(page.locator(".message-row").first()).toContainText("(No subject)");
await expect(page.locator(".message-row").first()).toContainText("No preview");
await expect(page.locator(".message-row").first()).not.toContainText("— No preview");
await expect(page.locator(".message-row").nth(1)).toContainText("Unknown sender");

await page.getByRole("button", { name: "Open Sender 0" }).focus();
await page.keyboard.press("Enter");
Expand Down
14 changes: 14 additions & 0 deletions services/api/internal/modules/translations/catalogs.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@
"justNow": "Just now",
"noMessages": "No messages here",
"noSubject": "(No subject)",
"unknownSender": "Unknown sender",
"noPreview": "No preview",
"selectMessage": "Select",
"starMessage": "Star",
"unstarMessage": "Unstar",
"actionsFor": "Actions for",
"markUnimportant": "Mark unimportant",
"openMessage": "Open",
"toRecipients": "to",
"messageFrom": "Message from",
Expand Down Expand Up @@ -414,6 +421,13 @@
"justNow": "Ahora mismo",
"noMessages": "No hay mensajes aquí",
"noSubject": "(Sin asunto)",
"unknownSender": "Remitente desconocido",
"noPreview": "Sin vista previa",
"selectMessage": "Seleccionar",
"starMessage": "Destacar",
"unstarMessage": "Quitar destacado",
"actionsFor": "Acciones para",
"markUnimportant": "Marcar como no importante",
"openMessage": "Abrir",
"toRecipients": "para",
"messageFrom": "Mensaje de",
Expand Down