fix: make the message list one Tab stop that screen readers read row by row

The list was a stack of clickable divs. Screen readers heard nothing when
j or k moved the selection, every row added several Tab stops (Compose
sat 253 Tabs from the top), and a deleted message left the focus on the
page.

The list is now a treegrid named "Messages": one Tab stop, rows named in
one sentence (unread, sender, subject, date, flags, and the preview when
that setting is on), conversations with aria-expanded and their
messages a level down, position and list size on every row. j, k and the
arrows move the focus with the selection, Space ticks a row, Enter opens
it, Shift+F10 or the menu key opens its actions, and after a delete the
focus lands on the next row. With the actions button setting on, each
row shows a button for the same menu, for pointers that can't hover or
right-click.
This commit is contained in:
Linus Rath
2026-10-29 18:53:05 +01:00
parent 8b85f63c75
commit 8e201954b4
35 changed files with 681 additions and 72 deletions
@@ -0,0 +1,119 @@
import { useRef } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { rowA11yProps, rowLabel, useListboxFocus } from '../list-a11y';
function Row({ id, selected, onToggle = () => {}, onOpenMenu, onClick }: {
id: string; selected?: boolean; onToggle?: () => void; onOpenMenu?: () => void; onClick?: () => void;
}) {
return (
<div data-pos="1">
<div
{...rowA11yProps({ rowKey: id, label: `row ${id}`, selected: !!selected, selectionMode: false, onToggleCheck: onToggle, onOpenMenu })}
onClick={onClick}
>
<button type="button">star {id}</button>
<input aria-label={`check ${id}`} type="checkbox" />
</div>
</div>
);
}
function List({ ids, initial: active }: { ids: string[]; initial?: string }) {
const ref = useRef<HTMLDivElement>(null);
useListboxFocus(ref, active, `${ids.length}:${active}`);
return (
<>
<button type="button">outside</button>
<div ref={ref} tabIndex={-1} data-testid="scroller">
<div role="treegrid" aria-label="Messages" data-setsize="3">
{ids.map((id) => <Row key={id} id={id} selected={id === active} />)}
</div>
</div>
</>
);
}
const option = (name: string) => screen.getByRole('row', { name });
describe('message list as one widget', () => {
it('names a row in one sentence and skips what does not apply', () => {
expect(rowLabel(['unread', 'Anna Berg', 'Budget', false, null, '', 'Has attachment'])).toBe('unread, Anna Berg, Budget, Has attachment');
});
it('keeps one Tab stop: the open message, and takes the row controls out of the Tab order', () => {
render(<List ids={['a', 'b', 'c']} initial="b" />);
expect(option('row b')).toHaveAttribute('tabindex', '0');
expect(option('row a')).toHaveAttribute('tabindex', '-1');
expect(screen.getByText('star a')).toHaveAttribute('tabindex', '-1');
expect(document.querySelector('input[aria-label="check c"]')).toHaveAttribute('tabindex', '-1');
expect(option('row b')).toHaveAttribute('aria-selected', 'true');
expect(option('row b')).toHaveAttribute('aria-current', 'true');
expect(option('row a')).not.toHaveAttribute('aria-selected');
});
it('settles when a row control is set to -1 again (no observer loop)', async () => {
render(<List ids={['a']} initial="a" />);
const star = screen.getByText('star a');
star.setAttribute('tabindex', '-1');
star.setAttribute('tabindex', '0');
// A loop of observer callbacks would never let this timer run.
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(star).toHaveAttribute('tabindex', '-1');
}, 2000);
it('hands the focus from the list itself to the row with the Tab stop', () => {
render(<List ids={['a', 'b']} initial="b" />);
act(() => screen.getByTestId('scroller').focus());
expect(document.activeElement).toBe(option('row b'));
});
it('falls back to the first row when nothing is open', () => {
render(<List ids={['a', 'b']} />);
expect(option('row a')).toHaveAttribute('tabindex', '0');
});
it('moves the focus with the selection while the focus is in the list', () => {
const { rerender } = render(<List ids={['a', 'b', 'c']} initial="a" />);
act(() => option('row a').focus());
rerender(<List ids={['a', 'b', 'c']} initial="b" />);
expect(document.activeElement).toBe(option('row b'));
expect(option('row b')).toHaveAttribute('tabindex', '0');
expect(option('row a')).toHaveAttribute('tabindex', '-1');
});
it('never pulls the focus out of another part of the page', () => {
const { rerender } = render(<List ids={['a', 'b']} initial="a" />);
act(() => screen.getByText('outside').focus());
rerender(<List ids={['a', 'b']} initial="b" />);
expect(document.activeElement).toBe(screen.getByText('outside'));
expect(option('row b')).toHaveAttribute('tabindex', '0');
});
it('writes each row\'s position and the list size for screen readers', () => {
render(<List ids={['a']} initial="a" />);
expect(option('row a')).toHaveAttribute('aria-posinset', '1');
expect(option('row a')).toHaveAttribute('aria-setsize', '3');
});
it('opens with Enter, ticks with Space and opens the actions with Shift+F10', () => {
const onClick = vi.fn();
const onToggle = vi.fn();
const onOpenMenu = vi.fn();
render(<div role="treegrid"><Row id="a" onClick={onClick} onToggle={onToggle} onOpenMenu={onOpenMenu} /></div>);
fireEvent.keyDown(option('row a'), { key: 'Enter' });
fireEvent.keyDown(option('row a'), { key: ' ' });
fireEvent.keyDown(option('row a'), { key: 'F10', shiftKey: true });
fireEvent.keyDown(option('row a'), { key: 'ContextMenu' });
expect(onClick).toHaveBeenCalledTimes(1);
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onOpenMenu).toHaveBeenCalledTimes(2);
});
it('leaves keys from a control inside the row to that control', () => {
const onToggle = vi.fn();
render(<div role="treegrid"><Row id="a" onToggle={onToggle} /></div>);
fireEvent.keyDown(screen.getByText('star a'), { key: ' ' });
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -846,7 +846,8 @@ describe('ThreadListItem open message', () => {
const { container } = render(
<ThreadListItem thread={thread} isExpanded={false} selectedEmailKey="login-a/cf:eaaaaab" onToggleExpand={() => {}} onEmailSelect={() => {}} />,
);
expect(container.querySelector('[aria-current="true"]') !== null).toBe(open);
// The open message's row is marked current in the list (SR-01).
expect(container.querySelector('[role="row"][aria-current="true"]') !== null).toBe(open);
});
});
@@ -900,3 +901,68 @@ describe('ThreadListItem text direction', () => {
}
});
});
describe('ThreadListItem for screen readers and keyboards (SR-01, MO-18)', () => {
beforeEach(() => {
useSettingsStore.setState({ showPreview: false, announceRowPreview: false, showRowActionButton: false, interfaceLayout: 'default' } as never);
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: '' });
});
it('is one row named after sender, subject and state', () => {
renderRow(makeEmail({ keywords: {}, from: [{ name: 'Anna Berg', email: 'anna@example.com' }], subject: 'Budget' }));
const row = screen.getByRole('row');
const name = row.getAttribute('aria-label') ?? '';
expect(name).toMatch(/^unread, Anna Berg, Budget, /);
});
it('reads the preview only when that setting is on', () => {
const email = makeEmail({ preview: 'Numbers for Q3 attached' });
const { unmount } = renderRow(email);
expect(screen.getByRole('row').getAttribute('aria-label')).not.toContain('Numbers for Q3');
unmount();
useSettingsStore.setState({ announceRowPreview: true } as never);
renderRow(email);
expect(screen.getByRole('row').getAttribute('aria-label')).toContain('Numbers for Q3 attached');
});
it('ticks the row with Space', () => {
renderRow(makeEmail({ id: 'm1' }));
fireEvent.keyDown(screen.getByRole('row'), { key: ' ' });
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(1);
});
it('shows an actions button that opens the row menu when that setting is on', () => {
const onContextMenu = vi.fn();
const [thread] = groupEmailsByThread([makeEmail()]);
const ui = (
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onContextMenu={onContextMenu} />
);
const { rerender } = render(ui);
expect(screen.queryByRole('button', { name: 'row_actions' })).toBeNull();
act(() => { useSettingsStore.setState({ showRowActionButton: true } as never); });
rerender(ui);
fireEvent.click(screen.getByRole('button', { name: 'row_actions' }));
expect(onContextMenu).toHaveBeenCalledTimes(1);
});
it('tells screen readers whether a conversation is open and puts its messages a level down', () => {
const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' });
const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' });
const [thread] = groupEmailsByThread([first, second]);
render(<ThreadListItem thread={thread} isExpanded expandedEmails={thread.emails} onToggleExpand={() => {}} onEmailSelect={() => {}} />);
const rows = screen.getAllByRole('row');
expect(rows[0]).toHaveAttribute('aria-expanded', 'true');
expect(rows[0]).toHaveAttribute('aria-level', '1');
expect(rows.slice(1).every((r) => r.getAttribute('aria-level') === '2')).toBe(true);
// Each row holds its content in a cell, so its controls are allowed there.
expect(rows[0].querySelector('[role="gridcell"]')).not.toBeNull();
});
it('names the conversation toggle after its subject', () => {
const first = makeEmail({ id: 'a', threadId: 't', subject: 'Trip', receivedAt: '2024-01-15T09:00:00Z' });
const second = makeEmail({ id: 'b', threadId: 't', subject: 'Trip', receivedAt: '2024-01-15T10:00:00Z' });
const [thread] = groupEmailsByThread([first, second]);
render(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />);
expect(screen.getByRole('button', { name: /expand_named/ })).toBeTruthy();
});
});
+20
View File
@@ -3,6 +3,7 @@
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { MemoizedThreadListItem, listMailboxRole } from "./thread-list-item";
import { ListColumnsHeader } from "./list-columns";
import { useListboxFocus } from "./list-a11y";
import { useStableHandlers } from "@/hooks/use-stable-handlers";
import type { Attachment } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
@@ -487,6 +488,8 @@ export function EmailList({
[items],
);
// Each row's place among the rows (headers and notes don't count).
const rowPosOfItem = useMemo(() => new Map(itemOfThread.map((item, i) => [item, i + 1])), [itemOfThread]);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
@@ -500,6 +503,14 @@ export function EmailList({
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height,
});
// One Tab stop in the list, and the focus going along with j/k (SR-01/02).
const renderedRows = virtualizer.getVirtualItems();
useListboxFocus(
parentRef,
selectedEmailKey,
`${items.length}:${renderedRows[0]?.index ?? -1}-${renderedRows[renderedRows.length - 1]?.index ?? -1}:${expandedThreadIds.size}`,
);
// Another folder, tag, account, unified view or plugin tab - or another
// search - opens at the top. The scroll container outlives the switch, so
// the new list would otherwise open wherever the last one was scrolled to.
@@ -1119,6 +1130,13 @@ export function EmailList({
) : (
<>
<div
role="treegrid"
aria-label={t('listbox_label')}
aria-multiselectable
aria-busy={isLoading || undefined}
// Rows report where they are in the whole list (useListboxFocus);
// -1 while more can still load.
data-setsize={footerHasMore ? -1 : itemOfThread.length}
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -1133,6 +1151,8 @@ export function EmailList({
<div
key={virtualItem.key}
data-index={virtualItem.index}
role="presentation"
data-pos={thread ? rowPosOfItem.get(virtualItem.index) : undefined}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useEffect, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
/**
* The message list as one widget for keyboards and screen readers (SR-01,
* SR-02): a treegrid with one Tab stop, whose rows are named in one sentence
* (unread, sender, subject, date, flags); a conversation row expands into
* its messages (aria-expanded, aria-level). A treegrid rather than a
* listbox because rows hold controls (checkbox, star, hover actions), which
* an option may not. j/k and the arrows move
* the selection; the focus follows it, so a screen reader reads each row as
* it gets there. The controls inside a row (checkbox, star, hover actions)
* stay for the mouse but leave the Tab order: Space ticks the row, Enter
* opens it, Shift+F10 or the menu key opens its actions.
*/
export const ROW_KEY_ATTR = "data-row-key";
/** One sentence that names a row, from the parts that apply, in reading order. */
export function rowLabel(parts: ReadonlyArray<string | false | null | undefined>): string {
return parts.filter((p): p is string => typeof p === "string" && p.trim().length > 0).join(", ");
}
interface RowA11yOptions {
rowKey: string;
label: string;
/** The open message's row. */
selected: boolean;
/** A conversation row: whether it shows its messages. */
expanded?: boolean;
/** 2 for a message inside an expanded conversation. */
level?: number;
/** Ticked for a batch action; only set while something is ticked. */
checked?: boolean;
selectionMode: boolean;
onToggleCheck: () => void;
/** Opens the row's actions menu, as a right-click would. */
onOpenMenu?: (e: ReactMouseEvent) => void;
}
/** Props for a row's outer element. */
export function rowA11yProps({ rowKey, label, selected, expanded, level = 1, checked, selectionMode, onToggleCheck, onOpenMenu }: RowA11yOptions) {
return {
role: "row" as const,
// The list moves the single Tab stop (useListboxFocus); React leaves an
// attribute it didn't change alone, so -1 here doesn't undo that.
tabIndex: -1,
[ROW_KEY_ATTR]: rowKey,
"aria-label": label,
// Ticked rows are the selection while something is ticked; otherwise the
// open message is. aria-current marks the open message either way.
"aria-selected": selectionMode ? !!checked : selected ? true : undefined,
"aria-current": selected ? ("true" as const) : undefined,
"aria-expanded": expanded,
"aria-level": level,
onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => {
// Keys from a control inside the row are that control's.
if (e.target !== e.currentTarget) return;
if (e.key === " ") {
e.preventDefault();
onToggleCheck();
} else if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
e.currentTarget.click();
} else if (onOpenMenu && (e.key === "ContextMenu" || (e.key === "F10" && e.shiftKey))) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
onOpenMenu({
clientX: rect.left + Math.min(48, rect.width / 2),
clientY: rect.top + rect.height / 2,
preventDefault: () => {},
stopPropagation: () => {},
currentTarget: e.currentTarget,
target: e.currentTarget,
} as unknown as ReactMouseEvent);
}
},
};
}
const FOCUSABLE_IN_ROW = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function rowFor(list: HTMLElement, key: string | undefined): HTMLElement | null {
if (!key) return null;
const k = CSS.escape(key);
// A message's own row, else the collapsed conversation that holds it.
return list.querySelector<HTMLElement>(`[role="row"][${ROW_KEY_ATTR}="${k}"]`)
?? list.querySelector<HTMLElement>(`[role="row"][data-thread-keys~="${k}"]`);
}
/** Writes each rendered row's place in the whole list onto it. */
function applyPositions(list: HTMLElement): void {
list.querySelectorAll<HTMLElement>("[data-pos]").forEach((wrapper) => {
const row = wrapper.querySelector<HTMLElement>('[role="row"]');
const setSize = wrapper.closest<HTMLElement>("[data-setsize]")?.dataset.setsize;
if (!row || !setSize) return;
if (row.getAttribute("aria-posinset") !== wrapper.dataset.pos) row.setAttribute("aria-posinset", wrapper.dataset.pos ?? "");
if (row.getAttribute("aria-setsize") !== setSize) row.setAttribute("aria-setsize", setSize);
});
}
/**
* Keeps one Tab stop in the list (the open message's row, else the first
* row), moves the focus with the selection while the focus is in the list,
* and takes the controls inside rows out of the Tab order, also the ones
* that mount later (hover actions appear on pointer-over).
*/
export function useListboxFocus(listRef: RefObject<HTMLElement | null>, activeKey: string | undefined, renderVersion: unknown): void {
// Inner controls out of the Tab order.
useEffect(() => {
const list = listRef.current;
if (!list) return;
const demote = (root: ParentNode) => {
root.querySelectorAll<HTMLElement>('[role="row"] ' + FOCUSABLE_IN_ROW.split(", ").join(', [role="row"] ')).forEach((el) => {
if (el.getAttribute("tabindex") !== "-1") el.setAttribute("tabindex", "-1");
});
};
demote(list);
const observer = new MutationObserver((records) => {
for (const r of records) {
if (r.type === "childList" && r.addedNodes.length) { demote(list); return; }
if (r.type === "attributes" && r.target instanceof HTMLElement && r.target.closest('[role="row"]') && r.target.getAttribute("role") !== "row") {
// Only change what isn't -1 yet: writing the same value again is a
// mutation too, and would call this observer forever.
if (r.target.matches(FOCUSABLE_IN_ROW) && r.target.getAttribute("tabindex") !== "-1") r.target.setAttribute("tabindex", "-1");
}
}
});
observer.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ["tabindex"] });
// The list itself takes the focus from the skip link and from the focus
// rescue after a delete; hand it on to the row that holds the Tab stop.
const onFocus = (e: FocusEvent) => {
if (e.target !== list) return;
list.querySelector<HTMLElement>('[role="row"][tabindex="0"]')?.focus();
};
list.addEventListener("focus", onFocus);
return () => {
observer.disconnect();
list.removeEventListener("focus", onFocus);
};
}, [listRef]);
// The single Tab stop, and the focus following the selection.
useEffect(() => {
const list = listRef.current;
if (!list) return;
applyPositions(list);
const target = rowFor(list, activeKey) ?? list.querySelector<HTMLElement>('[role="row"]');
list.querySelectorAll<HTMLElement>('[role="row"][tabindex="0"]').forEach((el) => {
if (el !== target) el.setAttribute("tabindex", "-1");
});
if (!target) return;
target.setAttribute("tabindex", "0");
const active = document.activeElement;
// Only while the keyboard is working the list: never pull the focus out
// of the reader, the composer or a dialog.
if (active && active !== target && list.contains(active)) {
target.focus({ preventScroll: false });
}
}, [listRef, activeKey, renderVersion]);
}
+34 -2
View File
@@ -21,6 +21,7 @@ import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge } from "./tag-badge";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell, listLeadClass } from "./list-columns";
import { rowA11yProps, rowLabel } from "./list-a11y";
interface ThreadEmailItemProps {
email: Email;
@@ -40,6 +41,8 @@ export function ThreadEmailItem({
onContextMenu,
}: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const tStatus = useTranslations('email_list');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
@@ -103,6 +106,29 @@ export function ThreadEmailItem({
}
};
const senderName = sender?.name || sender?.email?.split('@')[0] || t('unknown_sender');
const selectLabel = tBatch('select_row', { sender: senderName, subject: email.subject || t('no_subject') });
const a11y = rowA11yProps({
rowKey: emailKeyFor(email),
label: rowLabel([
isUnread && t('unread'),
senderName,
formatDate(emailDisplayDate(email)),
isStarred && tStatus('starred'),
isAnswered && tStatus('replied'),
isForwarded && tStatus('forwarded'),
email.hasAttachment && tStatus('has_attachment'),
// The row shows the message's text where the subject would be.
cleanPreview(email.preview),
]),
selected: !!selected,
level: 2,
checked: isChecked,
selectionMode: selectedEmailKeys.size > 0,
onToggleCheck: () => toggleEmailSelection(email),
onOpenMenu: onContextMenu ? handleContextMenu : undefined,
});
const rowHandlers = {
onClick: handleClick,
onDoubleClick: (e: React.MouseEvent) => {
@@ -125,6 +151,7 @@ export function ThreadEmailItem({
{...dragHandlers}
{...longPressHandlers}
{...rowHandlers}
{...a11y}
data-testid="thread-email-row"
className={cn(
"relative cursor-pointer select-none px-3 transition-colors duration-150",
@@ -137,7 +164,7 @@ export function ThreadEmailItem({
)}
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex items-center" style={{ gap: 'var(--density-item-gap)' }}>
<div role="gridcell" className="flex items-center" style={{ gap: 'var(--density-item-gap)' }}>
{lead && (
<span className={cn(lead, 'flex shrink-0 items-center justify-center')}>
{selecting ? (
@@ -145,6 +172,7 @@ export function ThreadEmailItem({
onClick={handleCheckboxClick}
role="checkbox"
aria-checked={isChecked}
aria-label={selectLabel}
className={cn("rounded p-1 hover:bg-muted/50", isChecked && "text-primary")}
>
{isChecked ? <CheckSquare className="w-3.5 h-3.5" /> : <Square className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -198,6 +226,7 @@ export function ThreadEmailItem({
<div
{...dragHandlers}
{...longPressHandlers}
{...a11y}
className={cn(
"relative cursor-pointer select-none transition-all duration-150",
"ps-12 pe-4",
@@ -221,11 +250,14 @@ export function ThreadEmailItem({
onContextMenu={handleContextMenu}
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex items-start gap-3">
<div role="gridcell" className="flex items-start gap-3">
{/* Checkbox - only visible when in selection mode */}
{selectedEmailKeys.size > 0 && (
<button
onClick={handleCheckboxClick}
role="checkbox"
aria-checked={isChecked}
aria-label={selectLabel}
className={cn(
"p-1 rounded mt-0.5 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
+105 -11
View File
@@ -2,6 +2,7 @@
import React, { useCallback } from "react";
import { cleanPreview, formatDate } from "@/lib/utils";
import { rowA11yProps, rowLabel } from "./list-a11y";
import { emailDisplayDate } from "@/lib/email-date";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { Email, ThreadGroup } from "@/lib/jmap/types";
@@ -11,7 +12,7 @@ import { VerificationCodeChip, useListVerificationCode } from "./verification-co
import type { Attachment } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert, MoreHorizontal } from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
@@ -408,6 +409,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const accountColor = account?.avatarColor;
const accountDescription = describeAccount(email.accountLabel, account);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
const announceRowPreview = useSettingsStore((state) => state.announceRowPreview);
const showRowActionButton = useSettingsStore((state) => state.showRowActionButton);
const isMobile = useUIStore((state) => state.isMobile);
// One line per row where the list has the full width; stacked on phones.
const { oneLine: oneLineRow, columns } = useListRowShape();
@@ -519,6 +522,20 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onContextMenu?.(e, email);
};
// The row's name for screen readers, in the order people scan a row.
const a11yLabel = rowLabel([
isUnread && t('unread'),
sender?.name || (sender?.email && formatAddress(sender.email)) || tUnknown,
email.subject || tNoSubject,
formatDate(emailDisplayDate(email)),
isPinned && tStatus('pinned'),
isStarred && tStatus('starred'),
isAnswered && tStatus('replied'),
isForwarded && tStatus('forwarded'),
showPaperclip && tStatus('has_attachment'),
announceRowPreview && trimmedPreview,
]);
// Once something is ticked, the whole row is the checkbox, as in phone
// mail apps (#1014). Mountain View on the desktop opens the message and
// keeps the ticks instead, as Gmail does (#1231).
@@ -557,7 +574,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
data-mv-mobile-row={mvMobileRow ? "" : undefined}
data-mv-row={mvRow && !resolvedRowTint ? "" : undefined}
data-checked={isChecked ? "" : undefined}
aria-current={selected ? 'true' : undefined}
{...rowA11yProps({
rowKey: emailKeyFor(email),
label: a11yLabel,
selected,
checked: isChecked,
selectionMode: selectedEmailKeys.size > 0,
onToggleCheck: () => toggleEmailSelection(email),
onOpenMenu: onContextMenu ? handleContextMenu : undefined,
})}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
mvMobileRow && "border-b-transparent",
@@ -589,6 +614,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onContextMenu={handleContextMenu}
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
>
{showRowActionButton && onContextMenu && (
// For pointers that can't hover or right-click (MO-18); keyboards use
// Shift+F10 on the row, so it stays out of the Tab order.
<div role="gridcell" className="absolute end-2 top-1/2 z-20 -translate-y-1/2">
<button
type="button"
data-row-actions=""
onClick={(e) => { e.stopPropagation(); handleContextMenu(e); }}
aria-label={tStatus('row_actions')}
title={tStatus('row_actions')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground"
>
<MoreHorizontal className="w-4 h-4" aria-hidden="true" />
</button>
</div>
)}
{swipeEnabled && swipeMeta && (
// Decorative reveal on the physically exposed edge. That edge
// follows the (physical) translate direction, not text direction,
@@ -603,7 +644,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
<div
className={cn('px-3', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', swipeEnabled && 'relative z-10 bg-inherit')}
role="gridcell"
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', swipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */}
@@ -891,6 +933,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
{hoverActionsArmed && !email.isScheduled && (
// A cell of its own: a row holds cells, not buttons (SR-01).
<div role="gridcell">
<EmailHoverActions
email={email}
backgroundClassName={resolvedRowTint ? resolvedRowTint : mvRow ? mvHoverBackground(isChecked, isUnread, selected) : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
@@ -906,6 +950,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
mailboxRole={currentMailboxRole ?? null}
pinToFirstLine={mvRow}
/>
</div>
)}
</div>
);
@@ -1089,6 +1134,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
thread.emails.some(e => emailKeyFor(e) === selectedEmailKey);
const isChecked = thread.emails.some(e => selectedEmailKeys.has(emailKeyFor(e)));
const announceRowPreview = useSettingsStore((state) => state.announceRowPreview);
const showRowActionButton = useSettingsStore((state) => state.showRowActionButton);
if (emailCount === 1) {
return (
@@ -1194,8 +1241,23 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu?.(e, latestEmail);
};
const threadSubject = latestEmail.subject || tNoSubject;
const a11yLabel = rowLabel([
hasUnread && tEmailViewer('unread'),
displayNames.join(', '),
threadSubject,
t('messages_tooltip', { count: emailCount }),
formatDate(emailDisplayDate(latestEmail)),
hasPinned && tStatus('pinned'),
hasStarred && tStatus('starred'),
hasAnswered && tStatus('replied'),
hasForwarded && tStatus('forwarded'),
hasAttachment && tStatus('has_attachment'),
announceRowPreview && trimmedPreview,
]);
return (
<div ref={ref} className={cn(
<div ref={ref} role="presentation" className={cn(
"border-b border-border",
mvMobileRow && "border-b-transparent",
isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
@@ -1206,7 +1268,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onPointerOver={armHoverActions}
onFocus={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
aria-current={isSelected ? 'true' : undefined}
{...rowA11yProps({
rowKey: emailKeyFor(latestEmail),
label: a11yLabel,
selected: isSelected && !isExpanded,
expanded: isExpanded,
checked: isChecked,
selectionMode: selectedEmailKeys.size > 0,
onToggleCheck: toggleThreadSelection,
onOpenMenu: onContextMenu ? handleContextMenu : undefined,
})}
// Every message in the conversation: the open one may be any of them.
data-thread-keys={thread.emails.map((e) => emailKeyFor(e)).join(' ')}
data-unread={hasUnread ? 'true' : 'false'}
data-mv-mobile-row={mvMobileRow ? "" : undefined}
data-mv-row={mvRow && !rowTint ? "" : undefined}
@@ -1241,6 +1314,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu={handleContextMenu}
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: threadSwipeEnabled ? 'pan-y' : undefined }}
>
{showRowActionButton && onContextMenu && (
// For pointers that can't hover or right-click (MO-18); keyboards use
// Shift+F10 on the row, so it stays out of the Tab order.
<div role="gridcell" className="absolute end-2 top-1/2 z-20 -translate-y-1/2">
<button
type="button"
data-row-actions=""
onClick={(e) => { e.stopPropagation(); handleContextMenu(e); }}
aria-label={tStatus('row_actions')}
title={tStatus('row_actions')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground"
>
<MoreHorizontal className="w-4 h-4" aria-hidden="true" />
</button>
</div>
)}
{threadSwipeEnabled && threadSwipeMeta && (
// As on a single message's row: the physically exposed edge.
<div
@@ -1252,7 +1341,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
)}
<div
className={cn('px-3', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', threadSwipeEnabled && 'relative z-10 bg-inherit')}
role="gridcell"
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', threadSwipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: threadSwipeEnabled && threadOffsetX ? `translateX(${threadOffsetX}px)` : undefined, transition: threadSwipeEnabled && threadOffsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */}
@@ -1319,7 +1409,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
"bg-background border border-border"
)}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
aria-label={isExpanded ? t('collapse_named', { subject: threadSubject }) : t('expand_named', { subject: threadSubject })}
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
@@ -1364,7 +1454,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
}}
className="-m-0.5 shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
aria-label={isExpanded ? t('collapse_named', { subject: threadSubject }) : t('expand_named', { subject: threadSubject })}
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -1620,6 +1710,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
{hoverActionsArmed && !latestEmail.isScheduled && (
<div role="gridcell">
<EmailHoverActions
email={latestEmail}
backgroundClassName={rowTint ? rowTint : mvRow ? mvHoverBackground(isChecked, hasUnread, isSelected) : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
@@ -1635,15 +1726,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
mailboxRole={currentMailboxRole ?? null}
pinToFirstLine={mvRow}
/>
</div>
)}
</div>
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin me-2" />
{t('loading')}
<div role="row" aria-level={2} className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<div role="gridcell" className="flex items-center">
<Loader2 className="w-4 h-4 animate-spin me-2" aria-hidden="true" />
{t('loading')}
</div>
</div>
) : (
emailsToShow.map((email, index) => (
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {لا توجد رسائل} one {رسالة واحدة} other {# رسائل}}",
"search_results": "{count, plural, =0 {لا توجد نتائج} one {نتيجة واحدة} other {# نتائج}}"
},
"category_tabs": "فئات البريد الوارد"
"category_tabs": "فئات البريد الوارد",
"listbox_label": "الرسائل",
"row_actions": "الإجراءات"
},
"shared_tags": {
"title": "الوسوم المشتركة",
@@ -3569,7 +3571,9 @@
"star": "تمييز المحادثة بنجمة",
"unstar": "إزالة النجمة عن المحادثة",
"toggle_thread": "تبديل عرض المحادثة",
"messages_count": "{count, plural, zero {لا رسائل} one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}}"
"messages_count": "{count, plural, zero {لا رسائل} one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}}",
"expand_named": "توسيع المحادثة: {subject}",
"collapse_named": "طي المحادثة: {subject}"
},
"identities": {
"modal_title": "إدارة هويات الإرسال",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {cap missatge} one {# missatge} other {# missatges}}",
"search_results": "{count, plural, =0 {Cap resultat} one {# resultat} other {# resultats}}"
},
"category_tabs": "Categories de la safata d'entrada"
"category_tabs": "Categories de la safata d'entrada",
"listbox_label": "Missatges",
"row_actions": "Accions"
},
"shared_tags": {
"title": "Etiquetes compartides",
@@ -3569,7 +3571,9 @@
"star": "Marca la conversa amb estrella",
"unstar": "Treu l'estrella de la conversa",
"toggle_thread": "Commuta el fil",
"messages_count": "{count, plural, one {# missatge} other {# missatges}}"
"messages_count": "{count, plural, one {# missatge} other {# missatges}}",
"expand_named": "Desplega la conversa: {subject}",
"collapse_named": "Replega la conversa: {subject}"
},
"identities": {
"modal_title": "Gestiona les identitats d'enviament",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {žádné zprávy} one {# zpráva} few {# zprávy} other {# zpráv}}",
"search_results": "{count, plural, =0 {Žádné výsledky} one {# výsledek} few {# výsledky} other {# výsledků}}"
},
"category_tabs": "Kategorie doručené pošty"
"category_tabs": "Kategorie doručené pošty",
"listbox_label": "Zprávy",
"row_actions": "Akce"
},
"shared_tags": {
"title": "Sdílené štítky",
@@ -3569,7 +3571,9 @@
"star": "Označit konverzaci hvězdičkou",
"unstar": "Odebrat hvězdičku z konverzace",
"toggle_thread": "Přepnout zobrazení vlákna",
"messages_count": "{count, plural, one {# zpráva} few {# zprávy} many {# zprávy} other {# zpráv}}"
"messages_count": "{count, plural, one {# zpráva} few {# zprávy} many {# zprávy} other {# zpráv}}",
"expand_named": "Rozbalit konverzaci: {subject}",
"collapse_named": "Sbalit konverzaci: {subject}"
},
"identities": {
"modal_title": "Správa identit odesílatele",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {ingen beskeder} one {# besked} other {# beskeder}}",
"search_results": "{count, plural, =0 {Ingen resultater} one {# resultat} other {# resultater}}"
},
"category_tabs": "Indbakkekategorier"
"category_tabs": "Indbakkekategorier",
"listbox_label": "Beskeder",
"row_actions": "Handlinger"
},
"shared_tags": {
"title": "Delte tags",
@@ -3569,7 +3571,9 @@
"star": "Stjernemarkér samtale",
"unstar": "Fjern stjernemarkering",
"toggle_thread": "Slå tråd til/fra",
"messages_count": "{count, plural, one {# besked} other {# beskeder}}"
"messages_count": "{count, plural, one {# besked} other {# beskeder}}",
"expand_named": "Udvid samtale: {subject}",
"collapse_named": "Skjul samtale: {subject}"
},
"identities": {
"modal_title": "Administrér afsenderidentiteter",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {keine Nachrichten} one {# Nachricht} other {# Nachrichten}}",
"search_results": "{count, plural, =0 {Keine Ergebnisse} one {# Ergebnis} other {# Ergebnisse}}"
},
"category_tabs": "Posteingangskategorien"
"category_tabs": "Posteingangskategorien",
"listbox_label": "Nachrichten",
"row_actions": "Aktionen"
},
"shared_tags": {
"title": "Geteilte Labels",
@@ -3569,7 +3571,9 @@
"star": "Unterhaltung mit Stern markieren",
"unstar": "Stern von Unterhaltung entfernen",
"toggle_thread": "Unterhaltung ein-/ausklappen",
"messages_count": "{count, plural, one {# Nachricht} other {# Nachrichten}}"
"messages_count": "{count, plural, one {# Nachricht} other {# Nachrichten}}",
"expand_named": "Konversation aufklappen: {subject}",
"collapse_named": "Konversation zuklappen: {subject}"
},
"identities": {
"modal_title": "Sendeidentitäten verwalten",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {no messages} one {# message} other {# messages}}",
"search_results": "{count, plural, =0 {No results} one {# result} other {# results}}"
},
"category_tabs": "Inbox categories"
"category_tabs": "Inbox categories",
"listbox_label": "Messages",
"row_actions": "Actions"
},
"shared_tags": {
"title": "Shared tags",
@@ -3569,7 +3571,9 @@
"star": "Star conversation",
"unstar": "Unstar conversation",
"toggle_thread": "Toggle thread",
"messages_count": "{count, plural, one {# message} other {# messages}}"
"messages_count": "{count, plural, one {# message} other {# messages}}",
"expand_named": "Expand conversation: {subject}",
"collapse_named": "Collapse conversation: {subject}"
},
"identities": {
"modal_title": "Manage sending identities",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {ningún mensaje} one {# mensaje} other {# mensajes}}",
"search_results": "{count, plural, =0 {Sin resultados} one {# resultado} other {# resultados}}"
},
"category_tabs": "Categorías de la bandeja de entrada"
"category_tabs": "Categorías de la bandeja de entrada",
"listbox_label": "Mensajes",
"row_actions": "Acciones"
},
"shared_tags": {
"title": "Etiquetas compartidas",
@@ -3569,7 +3571,9 @@
"star": "Destacar conversación",
"unstar": "Quitar destacado de conversación",
"toggle_thread": "Expandir/contraer hilo",
"messages_count": "{count, plural, one {# mensaje} other {# mensajes}}"
"messages_count": "{count, plural, one {# mensaje} other {# mensajes}}",
"expand_named": "Expandir conversación: {subject}",
"collapse_named": "Contraer conversación: {subject}"
},
"identities": {
"modal_title": "Administrar Identidades de Envío",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {kirju pole} one {# kiri} other {# kirja}}",
"search_results": "{count, plural, =0 {Tulemusi pole} one {# tulemus} other {# tulemust}}"
},
"category_tabs": "Postkasti kategooriad"
"category_tabs": "Postkasti kategooriad",
"listbox_label": "Sõnumid",
"row_actions": "Toimingud"
},
"shared_tags": {
"title": "Jagatud sildid",
@@ -3569,7 +3571,9 @@
"star": "Tähista vestlus",
"unstar": "Eemalda vestluse tähistus",
"toggle_thread": "Lülita vestlus",
"messages_count": "{count, plural, one {# sõnum} other {# sõnumit}}"
"messages_count": "{count, plural, one {# sõnum} other {# sõnumit}}",
"expand_named": "Ava vestlus: {subject}",
"collapse_named": "Sulge vestlus: {subject}"
},
"identities": {
"modal_title": "Halda saatja identiteete",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {بدون پیام} other {# پیام}}",
"search_results": "{count, plural, =0 {نتیجه‌ای نیست} other {# نتیجه}}"
},
"category_tabs": "دسته‌های صندوق ورودی"
"category_tabs": "دسته‌های صندوق ورودی",
"listbox_label": "پیام‌ها",
"row_actions": "اقدامات"
},
"shared_tags": {
"title": "برچسب‌های مشترک",
@@ -3569,7 +3571,9 @@
"star": "ستاره‌دار کردن مکالمه",
"unstar": "حذف ستاره مکالمه",
"toggle_thread": "تغییر وضعیت رشته",
"messages_count": "{count, plural, one {# پیام} other {# پیام}}"
"messages_count": "{count, plural, one {# پیام} other {# پیام}}",
"expand_named": "باز کردن مکالمه: {subject}",
"collapse_named": "جمع کردن مکالمه: {subject}"
},
"identities": {
"modal_title": "مدیریت هویت‌های ارسال",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder} : {count, plural, =0 {aucun message} one {# message} other {# messages}}",
"search_results": "{count, plural, =0 {Aucun résultat} one {# résultat} other {# résultats}}"
},
"category_tabs": "Catégories de la boîte de réception"
"category_tabs": "Catégories de la boîte de réception",
"listbox_label": "Messages",
"row_actions": "Actions"
},
"shared_tags": {
"title": "Étiquettes partagées",
@@ -3569,7 +3571,9 @@
"star": "Marquer la conversation comme favorite",
"unstar": "Retirer des favoris",
"toggle_thread": "Déplier/replier la discussion",
"messages_count": "{count, plural, one {# message} other {# messages}}"
"messages_count": "{count, plural, one {# message} other {# messages}}",
"expand_named": "Développer la conversation: {subject}",
"collapse_named": "Réduire la conversation: {subject}"
},
"identities": {
"modal_title": "Gérer les identités d'envoi",
+6 -2
View File
@@ -344,7 +344,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {אין הודעות} one {הודעה אחת} other {# הודעות}}",
"search_results": "{count, plural, =0 {אין תוצאות} one {תוצאה אחת} other {# תוצאות}}"
},
"category_tabs": "קטגוריות של תיבת הדואר הנכנס"
"category_tabs": "קטגוריות של תיבת הדואר הנכנס",
"listbox_label": "הודעות",
"row_actions": "פעולות"
},
"shared_tags": {
"title": "תגים משותפים",
@@ -3487,7 +3489,9 @@
"star": "שיחת כוכב",
"unstar": "הסר שיחה בכוכב",
"toggle_thread": "החלף חוט",
"messages_count": "{count, plural, one {הודעה אחת} two {שתי הודעות} other {# הודעות}}"
"messages_count": "{count, plural, one {הודעה אחת} two {שתי הודעות} other {# הודעות}}",
"expand_named": "הרחב את השיחה: {subject}",
"collapse_named": "כווץ את השיחה: {subject}"
},
"identities": {
"modal_title": "ניהול זהויות שליחה",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {nincs üzenet} other {# üzenet}}",
"search_results": "{count, plural, =0 {Nincs találat} other {# találat}}"
},
"category_tabs": "Beérkezett üzenetek kategóriái"
"category_tabs": "Beérkezett üzenetek kategóriái",
"listbox_label": "Üzenetek",
"row_actions": "Műveletek"
},
"shared_tags": {
"title": "Megosztott címkék",
@@ -3569,7 +3571,9 @@
"star": "Beszélgetés csillagozása",
"unstar": "Beszélgetés csillagozásának megszüntetése",
"toggle_thread": "Beszélgetés váltása",
"messages_count": "{count, plural, one {# üzenet} other {# üzenet}}"
"messages_count": "{count, plural, one {# üzenet} other {# üzenet}}",
"expand_named": "Beszélgetés kibontása: {subject}",
"collapse_named": "Beszélgetés összecsukása: {subject}"
},
"identities": {
"modal_title": "Küldő azonosságok kezelése",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {nessun messaggio} one {# messaggio} other {# messaggi}}",
"search_results": "{count, plural, =0 {Nessun risultato} one {# risultato} other {# risultati}}"
},
"category_tabs": "Categorie della posta in arrivo"
"category_tabs": "Categorie della posta in arrivo",
"listbox_label": "Messaggi",
"row_actions": "Azioni"
},
"shared_tags": {
"title": "Etichette condivise",
@@ -3569,7 +3571,9 @@
"star": "Aggiungi stella alla conversazione",
"unstar": "Rimuovi stella dalla conversazione",
"toggle_thread": "Espandi/comprimi discussione",
"messages_count": "{count, plural, one {# messaggio} other {# messaggi}}"
"messages_count": "{count, plural, one {# messaggio} other {# messaggi}}",
"expand_named": "Espandi conversazione: {subject}",
"collapse_named": "Comprimi conversazione: {subject}"
},
"identities": {
"modal_title": "Gestisci identità di invio",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {メッセージはありません} other {#件のメッセージ}}",
"search_results": "{count, plural, =0 {結果はありません} other {#件の結果}}"
},
"category_tabs": "受信トレイのカテゴリ"
"category_tabs": "受信トレイのカテゴリ",
"listbox_label": "メッセージ",
"row_actions": "アクション"
},
"shared_tags": {
"title": "共有タグ",
@@ -3569,7 +3571,9 @@
"star": "会話にスターを付ける",
"unstar": "会話のスターを外す",
"toggle_thread": "スレッドの展開/折りたたみ",
"messages_count": "{count, plural, other {#件のメッセージ}}"
"messages_count": "{count, plural, other {#件のメッセージ}}",
"expand_named": "会話を展開: {subject}",
"collapse_named": "会話を折りたたむ: {subject}"
},
"identities": {
"modal_title": "送信者情報を管理",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {메시지 없음} other {메시지 #개}}",
"search_results": "{count, plural, =0 {결과 없음} other {결과 #개}}"
},
"category_tabs": "받은편지함 카테고리"
"category_tabs": "받은편지함 카테고리",
"listbox_label": "메시지",
"row_actions": "작업"
},
"shared_tags": {
"title": "공유 태그",
@@ -3569,7 +3571,9 @@
"star": "대화 전체에 별표 달기",
"unstar": "대화 전체 별표 해제",
"toggle_thread": "스레드 켜기/끄기",
"messages_count": "{count, plural, other {메시지 #개}}"
"messages_count": "{count, plural, other {메시지 #개}}",
"expand_named": "대화 펼치기: {subject}",
"collapse_named": "대화 접기: {subject}"
},
"identities": {
"modal_title": "보내는 사람 관리",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {nav vēstuļu} zero {# vēstules} one {# vēstule} other {# vēstules}}",
"search_results": "{count, plural, =0 {Nav rezultātu} zero {# rezultāti} one {# rezultāts} other {# rezultāti}}"
},
"category_tabs": "Iesūtnes kategorijas"
"category_tabs": "Iesūtnes kategorijas",
"listbox_label": "Vēstules",
"row_actions": "Darbības"
},
"shared_tags": {
"title": "Kopīgotie tagi",
@@ -3569,7 +3571,9 @@
"star": "Pievienot zvaigznīti sarunai",
"unstar": "Noņemt zvaigznīti sarunai",
"toggle_thread": "Pārslēgt sarunu",
"messages_count": "{count, plural, zero {# ziņojumu} one {# ziņojums} other {# ziņojumi}}"
"messages_count": "{count, plural, zero {# ziņojumu} one {# ziņojums} other {# ziņojumi}}",
"expand_named": "Izvērst sarunu: {subject}",
"collapse_named": "Sairt sarunu: {subject}"
},
"identities": {
"modal_title": "Pārvaldīt sūtītāja identitātes",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {зурвас алга} other {# зурвас}}",
"search_results": "{count, plural, =0 {Илэрц алга} other {# илэрц}}"
},
"category_tabs": "Ирсэн шуудангийн ангиллууд"
"category_tabs": "Ирсэн шуудангийн ангиллууд",
"listbox_label": "Зурвасууд",
"row_actions": "Үйлдлүүд"
},
"shared_tags": {
"title": "Хуваалцсан тагууд",
@@ -3569,7 +3571,9 @@
"star": "Харилцан яриаг одтой болгох",
"unstar": "Харилцан ярианы одыг авах",
"toggle_thread": "Хэлхээсийг дэлгэх/хумих",
"messages_count": "{count, plural, one {# мессеж} other {# мессеж}}"
"messages_count": "{count, plural, one {# мессеж} other {# мессеж}}",
"expand_named": "Харилцан яриаг өргөжүүлэх: {subject}",
"collapse_named": "Харилцан яриаг хураах: {subject}"
},
"identities": {
"modal_title": "Илгээж буй таниулбаруудыг удирдах",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {ingen meldinger} one {# melding} other {# meldinger}}",
"search_results": "{count, plural, =0 {Ingen treff} one {# treff} other {# treff}}"
},
"category_tabs": "Innbokskategorier"
"category_tabs": "Innbokskategorier",
"listbox_label": "Meldinger",
"row_actions": "Handlinger"
},
"shared_tags": {
"title": "Delte etiketter",
@@ -3569,7 +3571,9 @@
"star": "Stjernemerk samtalen",
"unstar": "Fjern stjernemerking fra samtalen",
"toggle_thread": "Utvid eller fold sammen tråden",
"messages_count": "{count, plural, one {# melding} other {# meldinger}}"
"messages_count": "{count, plural, one {# melding} other {# meldinger}}",
"expand_named": "Utvid samtalen: {subject}",
"collapse_named": "Fold sammen samtalen: {subject}"
},
"identities": {
"modal_title": "Administrer avsenderidentiteter",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {geen berichten} one {# bericht} other {# berichten}}",
"search_results": "{count, plural, =0 {Geen resultaten} one {# resultaat} other {# resultaten}}"
},
"category_tabs": "Inboxcategorieën"
"category_tabs": "Inboxcategorieën",
"listbox_label": "Berichten",
"row_actions": "Acties"
},
"shared_tags": {
"title": "Gedeelde labels",
@@ -3569,7 +3571,9 @@
"star": "Ster toevoegen aan gesprek",
"unstar": "Ster verwijderen van gesprek",
"toggle_thread": "Gesprek in-/uitvouwen",
"messages_count": "{count, plural, one {# bericht} other {# berichten}}"
"messages_count": "{count, plural, one {# bericht} other {# berichten}}",
"expand_named": "Gesprek uitklappen: {subject}",
"collapse_named": "Gesprek inklappen: {subject}"
},
"identities": {
"modal_title": "Verzendidentiteiten beheren",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {brak wiadomości} one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}}",
"search_results": "{count, plural, =0 {Brak wyników} one {# wynik} few {# wyniki} many {# wyników} other {# wyników}}"
},
"category_tabs": "Kategorie skrzynki odbiorczej"
"category_tabs": "Kategorie skrzynki odbiorczej",
"listbox_label": "Wiadomości",
"row_actions": "Akcje"
},
"shared_tags": {
"title": "Wspólne etykiety",
@@ -3569,7 +3571,9 @@
"star": "Oznacz konwersację gwiazdką",
"unstar": "Usuń gwiazdkę z konwersacji",
"toggle_thread": "Przełącz widok wątku",
"messages_count": "{count, plural, one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}}"
"messages_count": "{count, plural, one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}}",
"expand_named": "Rozwiń konwersację: {subject}",
"collapse_named": "Zwiń konwersację: {subject}"
},
"identities": {
"modal_title": "Zarządzaj tożsamościami nadawcy",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {nenhuma mensagem} one {# mensagem} other {# mensagens}}",
"search_results": "{count, plural, =0 {Nenhum resultado} one {# resultado} other {# resultados}}"
},
"category_tabs": "Categorias da caixa de entrada"
"category_tabs": "Categorias da caixa de entrada",
"listbox_label": "Mensagens",
"row_actions": "Ações"
},
"shared_tags": {
"title": "Etiquetas compartilhadas",
@@ -3569,7 +3571,9 @@
"star": "Adicionar estrela à conversa",
"unstar": "Remover estrela da conversa",
"toggle_thread": "Expandir/recolher conversa",
"messages_count": "{count, plural, one {# mensagem} other {# mensagens}}"
"messages_count": "{count, plural, one {# mensagem} other {# mensagens}}",
"expand_named": "Expandir conversa: {subject}",
"collapse_named": "Recolher conversa: {subject}"
},
"identities": {
"modal_title": "Gerenciar Identidades de Envio",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {niciun mesaj} one {# mesaj} few {# mesaje} other {# de mesaje}}",
"search_results": "{count, plural, =0 {Niciun rezultat} one {# rezultat} few {# rezultate} other {# de rezultate}}"
},
"category_tabs": "Categoriile căsuței de primire"
"category_tabs": "Categoriile căsuței de primire",
"listbox_label": "Mesaje",
"row_actions": "Acțiuni"
},
"shared_tags": {
"title": "Etichete partajate",
@@ -3569,7 +3571,9 @@
"star": "Marcați conversația cu stea",
"unstar": "Dezactivează marcajul conversației",
"toggle_thread": "Comutare fir de discuție",
"messages_count": "{count, plural, one {# mesaj} few {# mesaje} other {# de mesaje}}"
"messages_count": "{count, plural, one {# mesaj} few {# mesaje} other {# de mesaje}}",
"expand_named": "Extinde conversația: {subject}",
"collapse_named": "Ascunde conversația: {subject}"
},
"identities": {
"modal_title": "Gestionarea identităților de expediere",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {нет сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}",
"search_results": "{count, plural, =0 {Ничего не найдено} one {# результат} few {# результата} many {# результатов} other {# результата}}"
},
"category_tabs": "Категории входящих"
"category_tabs": "Категории входящих",
"listbox_label": "Сообщения",
"row_actions": "Действия"
},
"shared_tags": {
"title": "Общие теги",
@@ -3569,7 +3571,9 @@
"star": "Добавить звёздочку к разговору",
"unstar": "Убрать звёздочку у разговора",
"toggle_thread": "Переключить цепочку",
"messages_count": "{count, plural, one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}"
"messages_count": "{count, plural, one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}",
"expand_named": "Развернуть разговор: {subject}",
"collapse_named": "Свернуть разговор: {subject}"
},
"identities": {
"modal_title": "Управление идентификациями отправителя",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {žiadne správy} one {# správa} few {# správy} other {# správ}}",
"search_results": "{count, plural, =0 {Žiadne výsledky} one {# výsledok} few {# výsledky} other {# výsledkov}}"
},
"category_tabs": "Kategórie doručenej pošty"
"category_tabs": "Kategórie doručenej pošty",
"listbox_label": "Správy",
"row_actions": "Akcie"
},
"shared_tags": {
"title": "Zdieľané štítky",
@@ -3569,7 +3571,9 @@
"star": "Označiť konverzáciu hviezdičkou",
"unstar": "Odstrániť hviezdičku z konverzácie",
"toggle_thread": "Prepnúť zobrazenie vlákna",
"messages_count": "{count, plural, one {# správa} few {# správy} many {# správy} other {# správ}}"
"messages_count": "{count, plural, one {# správa} few {# správy} many {# správy} other {# správ}}",
"expand_named": "Rozbaliť konverzáciu: {subject}",
"collapse_named": "Zbaliť konverzáciu: {subject}"
},
"identities": {
"modal_title": "Správa identít odosielateľa",
+6 -2
View File
@@ -456,7 +456,9 @@
"star": "ติดดาวการสนทนา",
"unstar": "ยกเลิกติดดาวการสนทนา",
"toggle_thread": "สลับมุมมองการสนทนา",
"messages_count": "{count, plural, other {# ข้อความ}}"
"messages_count": "{count, plural, other {# ข้อความ}}",
"expand_named": "ขยายการสนทนา: {subject}",
"collapse_named": "ย่อการสนทนา: {subject}"
},
"notifications": {
"email_sent": "ส่งอีเมลแล้ว",
@@ -641,7 +643,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {ไม่มีข้อความ} other {# ข้อความ}}",
"search_results": "{count, plural, =0 {ไม่พบผลลัพธ์} other {# ผลลัพธ์}}"
},
"category_tabs": "หมวดหมู่กล่องจดหมายเข้า"
"category_tabs": "หมวดหมู่กล่องจดหมายเข้า",
"listbox_label": "ข้อความ",
"row_actions": "การดำเนินการ"
},
"shared_tags": {
"title": "ป้ายกำกับที่แชร์",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {ileti yok} other {# ileti}}",
"search_results": "{count, plural, =0 {Sonuç yok} other {# sonuç}}"
},
"category_tabs": "Gelen kutusu kategorileri"
"category_tabs": "Gelen kutusu kategorileri",
"listbox_label": "Mesajlar",
"row_actions": "Eylemler"
},
"shared_tags": {
"title": "Paylaşılan etiketler",
@@ -3569,7 +3571,9 @@
"star": "Konuşmaya yıldız ekle",
"unstar": "Konuşmanın yıldızını kaldır",
"toggle_thread": "İleti dizisini aç/kapat",
"messages_count": "{count, plural, one {# ileti} other {# ileti}}"
"messages_count": "{count, plural, one {# ileti} other {# ileti}}",
"expand_named": "Konuşmayı genişlet: {subject}",
"collapse_named": "Konuşmayı daralt: {subject}"
},
"identities": {
"modal_title": "Gönderme Kimliklerini Yönet",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}: {count, plural, =0 {немає повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}",
"search_results": "{count, plural, =0 {Нічого не знайдено} one {# результат} few {# результати} many {# результатів} other {# результату}}"
},
"category_tabs": "Категорії вхідних"
"category_tabs": "Категорії вхідних",
"listbox_label": "Повідомлення",
"row_actions": "Дії"
},
"shared_tags": {
"title": "Спільні теги",
@@ -3569,7 +3571,9 @@
"star": "Зоряна розмова",
"unstar": "Зняти зірочку з розмови",
"toggle_thread": "Перемкнути потік",
"messages_count": "{count, plural, one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}"
"messages_count": "{count, plural, one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлення}}",
"expand_named": "Розгорнути бесіду: {subject}",
"collapse_named": "Згорнути бесіду: {subject}"
},
"identities": {
"modal_title": "Керування ідентифікаторами надсилання",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}:{count, plural, =0 {沒有郵件} other {# 封郵件}}",
"search_results": "{count, plural, =0 {沒有結果} other {# 個結果}}"
},
"category_tabs": "收件匣分類"
"category_tabs": "收件匣分類",
"listbox_label": "郵件",
"row_actions": "動作"
},
"shared_tags": {
"title": "共用標籤",
@@ -3569,7 +3571,9 @@
"star": "為對話串加上星號",
"unstar": "移除對話串星號",
"toggle_thread": "展開/收合對話串",
"messages_count": "{count, plural, other {# 封郵件}}"
"messages_count": "{count, plural, other {# 封郵件}}",
"expand_named": "展開對話串: {subject}",
"collapse_named": "收合對話串: {subject}"
},
"identities": {
"modal_title": "管理寄件身分",
+6 -2
View File
@@ -379,7 +379,9 @@
"folder_loaded": "{folder}:{count, plural, =0 {没有邮件} other {# 封邮件}}",
"search_results": "{count, plural, =0 {没有结果} other {# 个结果}}"
},
"category_tabs": "收件箱分类"
"category_tabs": "收件箱分类",
"listbox_label": "邮件",
"row_actions": "操作"
},
"shared_tags": {
"title": "共享标签",
@@ -3569,7 +3571,9 @@
"star": "会话加星标",
"unstar": "取消会话星标",
"toggle_thread": "展开/折叠会话",
"messages_count": "{count, plural, other {# 条消息}}"
"messages_count": "{count, plural, other {# 条消息}}",
"expand_named": "展开会话: {subject}",
"collapse_named": "折叠会话: {subject}"
},
"identities": {
"modal_title": "管理发送身份",