mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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": "إدارة هويات الإرسال",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "مدیریت هویتهای ارسال",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ניהול זהויות שליחה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "送信者情報を管理",
|
||||
|
||||
@@ -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": "보내는 사람 관리",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Илгээж буй таниулбаруудыг удирдах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление идентификациями отправителя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ป้ายกำกับที่แชร์",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Керування ідентифікаторами надсилання",
|
||||
|
||||
@@ -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": "管理寄件身分",
|
||||
|
||||
@@ -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": "管理发送身份",
|
||||
|
||||
Reference in New Issue
Block a user