mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: edit the name recipients see on the Account page and stop cutting account addresses
Settings > Account had a "Display name" that edited Stalwart's account description, while mail went out with the identity's name, so changing it changed nothing people saw. The page now shows "Your name", the primary address's name, and saves it to that identity. The account switcher and avatars take the same name; the principal description is only used for an account without an identity name. The address editor calls the field "Your name" too, and "identities" are "addresses" in the Account section and its dialog, said once instead of twice. Logged-in accounts show the server only for an account in trouble or one sharing its address with another, with no drag hint or arrows for a single account. The switcher wraps addresses instead of cutting them and has no remove button per row; an account you're not in is removed from Settings > Account. Changed tests: auth-store-display-name now expects the identity name to win over the principal; account-switcher-remove expects no remove button on the rows; settings-editors-unsaved uses the renamed "Add address" button and the "Your name" label, which has no placeholder.
This commit is contained in:
@@ -34,6 +34,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
const t = useTranslations('identities.form');
|
||||
const tValidation = useTranslations('identities.validation_errors');
|
||||
const tOrg = useTranslations('org_signature');
|
||||
const tName = useTranslations('uxs2_ident.your_name');
|
||||
const isEditing = !!identity;
|
||||
// Composing > Plain text only: the signature is plain text too.
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
@@ -156,8 +157,9 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
{/* "Your name", like the Account page: the one name recipients see. */}
|
||||
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')}
|
||||
{tName('label')}
|
||||
</label>
|
||||
<Input
|
||||
id="identity-name"
|
||||
@@ -165,12 +167,12 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
maxLength={256}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('name_placeholder')}
|
||||
disabled={isSubmitting}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
aria-describedby={errors.name ? 'name-error' : 'identity-name-hint'}
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
<p id="identity-name-hint" className="text-xs text-muted-foreground mt-1">{tName('description')}</p>
|
||||
{errors.name && (
|
||||
<p
|
||||
id="name-error"
|
||||
@@ -324,7 +326,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
}
|
||||
|
||||
/** The signature an address sends with when the admin locked signatures, read-only. */
|
||||
function LockedSignature({ html, text }: { html: string; text: string }) {
|
||||
export function LockedSignature({ html, text }: { html: string; text: string }) {
|
||||
const t = useTranslations('org_signature');
|
||||
const preview = html.trim() ? sanitizeSignatureHtmlForDisplay(html) : '';
|
||||
const plain = preview ? '' : getPlainTextSignature({ textSignature: text, htmlSignature: html });
|
||||
|
||||
@@ -48,6 +48,7 @@ interface IdentityManagerModalProps {
|
||||
|
||||
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
|
||||
const t = useTranslations('identities');
|
||||
const tAddr = useTranslations('uxs2_ident.addresses');
|
||||
const tNotif = useTranslations('notifications');
|
||||
// A locked or hidden signature: the identity's own isn't what it sends with.
|
||||
const signatureRestriction = useRestriction('signature');
|
||||
@@ -131,13 +132,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
await refreshIdentities();
|
||||
setIsCreating(false);
|
||||
toast.success(tNotif('identity_created'));
|
||||
toast.success(tAddr('added'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_create_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tAddr, tNotif]);
|
||||
|
||||
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
@@ -154,13 +155,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
await refreshIdentities();
|
||||
setEditingId(null);
|
||||
toast.success(tNotif('identity_updated'));
|
||||
toast.success(tAddr('saved'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_update_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tAddr, tNotif]);
|
||||
|
||||
const handleDelete = useCallback(async (identity: Identity) => {
|
||||
if (!client) return;
|
||||
@@ -170,7 +171,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('delete_confirm_title'),
|
||||
title: tAddr('delete_title'),
|
||||
message: t('delete_confirm'),
|
||||
confirmText: t('delete_button'),
|
||||
variant: "destructive",
|
||||
@@ -182,14 +183,14 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
try {
|
||||
if (!(await deleteIdentityWithHooks(client, identity))) return;
|
||||
await refreshIdentities();
|
||||
toast.success(tNotif('identity_deleted'));
|
||||
toast.success(tAddr('deleted'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_delete_failed', { error: message }));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
|
||||
}, [client, refreshIdentities, t, tAddr, tNotif, confirmDialog]);
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
@@ -223,7 +224,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-muted-foreground" />
|
||||
{t('modal_title')}
|
||||
{tAddr('modal_title')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -231,7 +232,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
{/* Create New Form */}
|
||||
{isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
|
||||
<h3 className="text-sm font-semibold mb-4">{tAddr('add')}</h3>
|
||||
<IdentityForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => void cancelForm()}
|
||||
@@ -247,7 +248,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 me-2" />
|
||||
{t('create_new')}
|
||||
{tAddr('add')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -261,7 +262,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
{editingId === identity.id ? (
|
||||
<div className="p-4 bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
{t('edit_identity')}
|
||||
{tAddr('edit')}
|
||||
</h3>
|
||||
<IdentityForm
|
||||
identity={identity}
|
||||
@@ -324,7 +325,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t('edit_identity')}
|
||||
label={tAddr('edit')}
|
||||
onClick={() => setEditingId(identity.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
@@ -358,7 +359,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
{identities.length === 0 && !isCreating && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('no_identities')}</p>
|
||||
<p className="text-sm">{tAddr('count', { count: 0 })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// Removing an account from the popover: the app's confirm dialog instead of
|
||||
// the browser's, a control a touch screen can reach, and sign-out rows in
|
||||
// the menu's plain text colour.
|
||||
// The popover's rows: no remove button (settings-p2-11), whole addresses,
|
||||
// and sign-out rows in the menu's plain text colour.
|
||||
|
||||
const { AccountSwitcher } = await import('../account-switcher');
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
@@ -18,35 +17,34 @@ function account(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const removeAccount = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
removeAccount.mockClear();
|
||||
useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'a' } as never);
|
||||
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
|
||||
});
|
||||
|
||||
describe('removing an account from the popover', () => {
|
||||
it('asks with the app dialog, not the browser, and removes on confirm', async () => {
|
||||
const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
// settings-p2-11: each row had a hidden X next to "Sign out of …" below.
|
||||
// An account you're not in is removed under Settings > Account, where
|
||||
// "Manage accounts…" leads.
|
||||
it('has no remove button on the rows', () => {
|
||||
render(<AccountSwitcher variant="rail" />);
|
||||
fireEvent.click(screen.getByTestId('account-switcher'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' }));
|
||||
|
||||
expect(browserConfirm).not.toHaveBeenCalled();
|
||||
const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' });
|
||||
expect(removeAccount).not.toHaveBeenCalled();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); });
|
||||
expect(removeAccount).toHaveBeenCalledWith('b');
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
browserConfirm.mockRestore();
|
||||
expect(screen.queryByRole('menuitem', { name: 'remove_account' })).toBeNull();
|
||||
expect(screen.getAllByTestId('account-option')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the remove control visible on a touch screen', () => {
|
||||
// The address is what tells two same-named accounts apart, and it was cut
|
||||
// ("dso-ux-settings@example…").
|
||||
it('shows the whole address, wrapped, with the full text as a tooltip', () => {
|
||||
const long = (id: string, email: string) => ({ ...account(id), email, username: email, label: 'Sam', displayName: 'Sam' });
|
||||
useAccountStore.setState({ accounts: [long('a', 'dso-ux-settings@example.org'), long('b', 'dso-ux-settings-b@example.org')] } as never);
|
||||
render(<AccountSwitcher variant="rail" />);
|
||||
fireEvent.click(screen.getByTestId('account-switcher'));
|
||||
expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100');
|
||||
const title = screen.getByText('dso-ux-settings-b@example.org');
|
||||
expect(title.className).not.toContain('truncate');
|
||||
expect(title.className).toContain('wrap-anywhere');
|
||||
expect(title.getAttribute('title')).toBe('dso-ux-settings-b@example.org');
|
||||
});
|
||||
|
||||
it('draws "sign out of all accounts" like the other rows, start-aligned', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { HelpMenuItems } from "@/components/layout/help-links";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName } from "@/lib/account-utils";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName, accountSharesAddress } from "@/lib/account-utils";
|
||||
import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -20,9 +20,6 @@ import { useRouter } from "@/i18n/navigation";
|
||||
import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn";
|
||||
import { buildSettingsPath } from "@/lib/deep-links";
|
||||
|
||||
@@ -118,9 +115,7 @@ export function AccountSwitcher({
|
||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
|
||||
const tWipe = useTranslations("offlineWipe");
|
||||
// An account that signed out by itself beside this one: its data waits.
|
||||
const [, noteWaitsChanged] = useState(0);
|
||||
@@ -218,20 +213,6 @@ export function AccountSwitcher({
|
||||
router.push(buildSettingsPath("account", "browser") as never);
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => {
|
||||
e.stopPropagation();
|
||||
const label = toUnicodeEmail(account.email || account.username);
|
||||
// The popover closes first: the dialog is the only thing asking now.
|
||||
setOpen(false);
|
||||
const ok = await confirm({
|
||||
title: t("remove_account_title"),
|
||||
message: t("remove_account_confirm", { account: label }),
|
||||
confirmText: t("remove_account"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (ok) removeAccount(account.id);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
@@ -316,9 +297,7 @@ export function AccountSwitcher({
|
||||
const unreadFor = (account: AccountEntry) =>
|
||||
mvPopover || severalAccounts ? inboxUnread(unreadByAccount[account.id]) : undefined;
|
||||
// The server is only worth a line when it is what tells two rows apart.
|
||||
const addressKey = (account: AccountEntry) => toUnicodeEmail(account.email || account.username).trim().toLowerCase();
|
||||
const sharesAddress = (account: AccountEntry) =>
|
||||
accounts.some((other) => other.id !== account.id && addressKey(other) === addressKey(account));
|
||||
const sharesAddress = (account: AccountEntry) => accountSharesAddress(account, accounts);
|
||||
const connectedIds = accounts.filter((account) => account.isConnected).map((account) => account.id);
|
||||
const allInboxesUnread = totalInboxUnread(connectedIds, unreadByAccount);
|
||||
// What waits in the accounts not on screen: a dot on the avatar says so.
|
||||
@@ -341,7 +320,7 @@ export function AccountSwitcher({
|
||||
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
|
||||
className
|
||||
)}
|
||||
title={variant !== "expanded" ? [displayName, othersUnreadText].filter(Boolean).join("\n") : undefined}
|
||||
title={[displayName, displaySubtitle, variant !== "expanded" ? othersUnreadText : null].filter(Boolean).join("\n")}
|
||||
aria-label={othersUnreadText ? `${t("switch_account")}, ${othersUnreadText}` : t("switch_account")}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
@@ -414,8 +393,8 @@ export function AccountSwitcher({
|
||||
<div className="flex items-center gap-4 rounded-t-3xl bg-background px-4 pt-4 pb-3" data-mv-account-current="">
|
||||
<AccountAvatar account={activeAccount} accounts={accounts} size="xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-lg leading-6 truncate">{displayName}</p>
|
||||
{displaySubtitle && <p className="text-sm text-muted-foreground truncate">{displaySubtitle}</p>}
|
||||
<p className="text-lg leading-6 wrap-anywhere">{displayName}</p>
|
||||
{displaySubtitle && <p className="text-sm text-muted-foreground wrap-anywhere">{displaySubtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -479,7 +458,7 @@ export function AccountSwitcher({
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
(!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7")
|
||||
isDraggable && "pe-7"
|
||||
)}
|
||||
role="menuitem"
|
||||
disabled={isActive}
|
||||
@@ -494,7 +473,7 @@ export function AccountSwitcher({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
<span className="text-sm font-medium wrap-anywhere" title={toUnicodeEmail(text.title)}>
|
||||
{toUnicodeEmail(text.title)}
|
||||
</span>
|
||||
{account.isDefault && accounts.length > 1 && (
|
||||
@@ -505,7 +484,7 @@ export function AccountSwitcher({
|
||||
<UnreadBadge count={unreadFor(account)} />
|
||||
</div>
|
||||
{text.subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<p className="text-xs text-muted-foreground wrap-anywhere" title={toUnicodeEmail(text.subtitle)}>
|
||||
{toUnicodeEmail(text.subtitle)}
|
||||
</p>
|
||||
)}
|
||||
@@ -524,25 +503,11 @@ export function AccountSwitcher({
|
||||
{isDraggable && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100",
|
||||
!isActive && !account.isDefault ? "end-10" : "end-1.5"
|
||||
)}
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 end-1.5 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
{!isActive && !account.isDefault && (
|
||||
// Shown on hover and focus, and always on a touch screen (R7).
|
||||
<IconButton
|
||||
onClick={(e) => void handleRemove(e, account)}
|
||||
role="menuitem"
|
||||
label={t("remove_account")}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity group-hover/acct:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -718,7 +683,6 @@ export function AccountSwitcher({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AccountSettings } from '../account-settings';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
// settings-p2-2: Account had "Display name" (Stalwart's account description,
|
||||
// "DSO UX audit settings (throwaway…)") while mail went out as the identity's
|
||||
// "Sam Settings". There is one name now: the address's, "Your name".
|
||||
// settings-p2-11: the server host stood under every account, and the drag
|
||||
// hint showed with one account.
|
||||
|
||||
vi.mock('next-intl', () => {
|
||||
const t = (key: string, values?: Record<string, unknown>) => (values ? `${key} ${JSON.stringify(values)}` : key);
|
||||
return { useTranslations: () => t, useLocale: () => 'en' };
|
||||
});
|
||||
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: vi.fn(async () => 'Admin note (throwaway)') }));
|
||||
vi.mock('../identity-settings', () => ({ EmailAddressesSection: () => null }));
|
||||
vi.mock('../layout-settings', () => ({ MultipleAccountsSection: () => null }));
|
||||
vi.mock('../sign-out-row', () => ({ SignOutRow: () => null }));
|
||||
vi.mock('../account-archive-settings', () => ({ AccountArchiveSettings: () => null }));
|
||||
|
||||
const sam: Identity = {
|
||||
id: 'i1', name: 'Sam Settings', email: 'sam@x.test', textSignature: '', htmlSignature: '', mayDelete: false,
|
||||
};
|
||||
|
||||
function entry(id: string, email: string, serverUrl = 'https://mail.x.test') {
|
||||
return {
|
||||
id, label: 'Admin note (throwaway)', displayName: 'Admin note (throwaway)', email, username: email, serverUrl,
|
||||
authMode: 'basic' as const, rememberMe: true, cookieSlot: 0, isConnected: true, hasError: false, lastLoginAt: 1,
|
||||
avatarColor: '#2563eb', isDefault: id === 'a',
|
||||
};
|
||||
}
|
||||
|
||||
const updateIdentity = vi.fn();
|
||||
const removeAccount = vi.fn();
|
||||
|
||||
function signIn(accounts: ReturnType<typeof entry>[]) {
|
||||
useAccountStore.setState({ accounts: accounts as never, activeAccountId: 'a', defaultAccountId: 'a' });
|
||||
useIdentityStore.setState({ identities: [sam] });
|
||||
useAuthStore.setState({
|
||||
username: 'sam@x.test', serverUrl: 'https://mail.x.test', isDemoMode: false, authMode: 'basic',
|
||||
client: { updateIdentity, getSharedAccounts: () => [] } as never,
|
||||
activeAccountId: 'a', identities: [sam], primaryIdentity: sam, removeAccount,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateIdentity.mockReset().mockResolvedValue(undefined);
|
||||
removeAccount.mockReset();
|
||||
});
|
||||
|
||||
describe('Account: your name', () => {
|
||||
it("shows the address's name, not the server's account description, and saves it to the address", async () => {
|
||||
signIn([entry('a', 'sam@x.test')]);
|
||||
render(<AccountSettings />);
|
||||
|
||||
const row = screen.getByText('label').closest('[data-search-label]') as HTMLElement;
|
||||
const input = within(row).getByRole('textbox');
|
||||
expect(input).toHaveValue('Sam Settings');
|
||||
const profile = document.querySelector('[data-settings-section="profile"]') as HTMLElement;
|
||||
expect(within(profile).queryByText('Admin note (throwaway)')).toBeNull();
|
||||
expect(within(profile).queryByDisplayValue('Admin note (throwaway)')).toBeNull();
|
||||
expect(screen.queryByText('name_label')).toBeNull();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Sam Lee' } });
|
||||
await act(async () => { fireEvent.submit(input.closest('form')!); });
|
||||
|
||||
expect(updateIdentity).toHaveBeenCalledWith('i1', { name: 'Sam Lee' });
|
||||
expect(useAuthStore.getState().primaryIdentity?.name).toBe('Sam Lee');
|
||||
// The switcher and the avatar follow the name people see.
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById('a')?.displayName).toBe('Sam Lee'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account: signed-in accounts', () => {
|
||||
it('shows no server line and no drag hint for one account', () => {
|
||||
signIn([entry('a', 'sam@x.test')]);
|
||||
render(<AccountSettings />);
|
||||
expect(screen.queryByTestId('account-server')).toBeNull();
|
||||
expect(screen.queryByText('mail.x.test')).toBeNull();
|
||||
expect(screen.queryByText('accounts.description')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'accounts.move_up' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the server only when two accounts share an address', () => {
|
||||
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@y.test', 'https://mail.y.test')]);
|
||||
const { unmount } = render(<AccountSettings />);
|
||||
expect(screen.queryByTestId('account-server')).toBeNull();
|
||||
expect(screen.getByText('accounts.description')).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@x.test', 'https://mail.y.test')]);
|
||||
render(<AccountSettings />);
|
||||
expect(screen.getAllByTestId('account-server').map((el) => el.textContent)).toEqual(['mail.x.test', 'mail.y.test']);
|
||||
});
|
||||
|
||||
it('removes an account you are not in after asking', async () => {
|
||||
signIn([entry('a', 'sam@x.test'), entry('b', 'sam@y.test')]);
|
||||
render(<AccountSettings />);
|
||||
// Only the other account has a remove button.
|
||||
const buttons = screen.getAllByRole('button', { name: 'remove_account' });
|
||||
expect(buttons).toHaveLength(1);
|
||||
fireEvent.click(buttons[0]);
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(removeAccount).not.toHaveBeenCalled();
|
||||
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'remove_account' })); });
|
||||
expect(removeAccount).toHaveBeenCalledWith('b');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import en from '@/locales/en/common.json';
|
||||
import { EmailAddressesSection } from '../identity-settings';
|
||||
|
||||
// SET-19: "Learn more" next to Sub-Addressing opened the identity manager,
|
||||
@@ -21,4 +22,17 @@ describe('Email addresses section', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'manage' }));
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
// settings-p2-18: "Email addresses: The email addresses you can send from"
|
||||
// over "Your sender addresses: Addresses you can send mail from". The
|
||||
// section says it once; the row only counts them, without "identity".
|
||||
it('says what the addresses are once, and counts them as addresses', () => {
|
||||
render(<EmailAddressesSection />);
|
||||
const row = screen.getByText('label').closest('[data-search-label]') as HTMLElement;
|
||||
expect(row.querySelector('p')).toBeNull();
|
||||
expect(within(row).getByText('count')).toBeTruthy();
|
||||
expect(screen.queryByText('identities_count.description')).toBeNull();
|
||||
expect(en.uxs2_ident.addresses.count).not.toMatch(/identit/i);
|
||||
expect(en.uxs2_ident.addresses.manage).toBe('Edit addresses…');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,8 +102,9 @@ describe('identity dialog', () => {
|
||||
// In a portal on body, not inside the settings page.
|
||||
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(document.body);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'create_new' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Work' } });
|
||||
// "Add address" and "Your name" (settings-p2-2, p2-3), no placeholder.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'add' }));
|
||||
fireEvent.change(screen.getByLabelText('label'), { target: { value: 'Work' } });
|
||||
|
||||
clickOutside();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
@@ -174,60 +174,6 @@ function PasswordChangeSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable display name (the Stalwart principal's name). Rendered by the
|
||||
* Account page's Profile section; the Security page no longer repeats it.
|
||||
*/
|
||||
export function DisplayNameSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore();
|
||||
const [name, setName] = useState(displayName);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(displayName);
|
||||
}, [displayName]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateDisplayName(name);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
toast.success(t('display_name.success'));
|
||||
} catch (err) {
|
||||
toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingPrincipal) {
|
||||
return (
|
||||
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={displayName || t('display_name.placeholder')}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || name === displayName}
|
||||
>
|
||||
{saved ? <Check className="w-4 h-4" /> : isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('display_name.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
function generateTotp(accountLabel: string, issuer: string): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight, ChevronUp, ChevronDown } from '@/components/icons';
|
||||
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight, ChevronUp, ChevronDown, X } from '@/components/icons';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
|
||||
@@ -12,18 +12,18 @@ import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { getMaxAccounts, accountRowText } from '@/lib/account-utils';
|
||||
import { getMaxAccounts, accountRowText, accountSharesAddress } from '@/lib/account-utils';
|
||||
import { formatFileSize, cn } from '@/lib/utils';
|
||||
import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { isPasswordSession } from '@/lib/auth/password-session';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { AccountArchiveSettings } from './account-archive-settings';
|
||||
import { SignOutRow } from './sign-out-row';
|
||||
import { DisplayNameSection } from './account-security-settings';
|
||||
import { YourNameRow } from './your-name-row';
|
||||
import { EmailAddressesSection } from './identity-settings';
|
||||
import { MultipleAccountsSection } from './layout-settings';
|
||||
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
|
||||
function hostnameOf(serverUrl: string): string {
|
||||
try { return toUnicodeDomain(new URL(serverUrl).hostname); } catch { return serverUrl; }
|
||||
@@ -42,30 +42,15 @@ function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
|
||||
export function AccountSettings() {
|
||||
const t = useTranslations('settings.account');
|
||||
const tCommon = useTranslations('common');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const router = useRouter();
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
|
||||
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
|
||||
const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode;
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
// The display name is editable on Stalwart (the principal's name) for
|
||||
// password sign-ins; identity-provider and token sessions cannot manage
|
||||
// the principal.
|
||||
const { isStalwart, probe, fetchPrincipal } = useAccountSecurityStore();
|
||||
const activeAccount = useAccountStore((s) => (activeAccountId ? s.getAccountById(activeAccountId) : undefined));
|
||||
const canEditDisplayName = isStalwart === true && isPasswordSession(authMode, activeAccount);
|
||||
// Wait for `client` before probing, as the Security page does: on reload
|
||||
// the persisted session flips authenticated before the client reconnects.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
if (isStalwart === null) {
|
||||
void probe().then((detected) => { if (detected) void fetchPrincipal(); });
|
||||
} else if (isStalwart) {
|
||||
void fetchPrincipal();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, client, isStalwart]);
|
||||
|
||||
// Shared/group accounts delegated to this session (excludes the user's own
|
||||
// primary account). These can be drilled into for scoped settings editing.
|
||||
@@ -83,10 +68,8 @@ export function AccountSettings() {
|
||||
const draggedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0;
|
||||
// `account.displayName` is refreshed from the server on every login/restore
|
||||
// (Stalwart principal "Full name" when available - #900); the identity name
|
||||
// is only a fallback until that entry exists.
|
||||
const displayName = account?.displayName || primaryIdentity?.name || (isDemoMode ? 'Nora Lindqvist' : undefined);
|
||||
// Shown when there is no identity to edit: the name the app knows.
|
||||
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Nora Lindqvist' : undefined);
|
||||
const email = primaryIdentity?.email || account?.email || username;
|
||||
const max = getMaxAccounts();
|
||||
|
||||
@@ -131,6 +114,18 @@ export function AccountSettings() {
|
||||
void switchAccount(id);
|
||||
}, [activeAccountId, switchAccount]);
|
||||
|
||||
// The switcher has no remove button on its rows; an account you're not in
|
||||
// is removed here ("Manage accounts…" leads to this list).
|
||||
const handleRemove = useCallback(async (target: AccountEntry) => {
|
||||
const ok = await confirm({
|
||||
title: tSidebar('remove_account_title'),
|
||||
message: tSidebar('remove_account_confirm', { account: toUnicodeEmail(target.email || target.username) }),
|
||||
confirmText: tSidebar('remove_account'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (ok) removeAccount(target.id);
|
||||
}, [confirm, removeAccount, tSidebar]);
|
||||
|
||||
const handleAddAccount = useCallback(() => {
|
||||
router.push(`/login?mode=add-account` as never);
|
||||
}, [router]);
|
||||
@@ -148,14 +143,9 @@ export function AccountSettings() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection id="profile" title={t('title')} description={t('description')}>
|
||||
{/* Display Name: editable where the server lets us, read-only otherwise. */}
|
||||
{canEditDisplayName ? (
|
||||
<DisplayNameSection />
|
||||
) : (
|
||||
<SettingItem label={t('name_label')}>
|
||||
<span className="text-sm text-foreground">{displayName || tCommon('unknown')}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
{/* One name: the primary address's, what recipients see (settings-p2-2).
|
||||
Stalwart's principal description is an admin's field and isn't shown. */}
|
||||
<YourNameRow fallbackName={displayName} />
|
||||
|
||||
{/* Email Address */}
|
||||
<SettingItem label={t('email.label')}>
|
||||
@@ -225,13 +215,20 @@ export function AccountSettings() {
|
||||
|
||||
{/* Logged-in accounts list */}
|
||||
{accounts.length > 0 && (
|
||||
<SettingsSection id="browser" title={t('accounts.title')} description={t('accounts.description')}>
|
||||
<SettingsSection
|
||||
id="browser"
|
||||
title={t('accounts.title')}
|
||||
// "Drag to change the order" says nothing with one account.
|
||||
description={accounts.length > 1 ? t('accounts.description') : undefined}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((a, index) => (
|
||||
<AccountRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
text={accountRowText(a, accounts)}
|
||||
showServer={a.hasError || accountSharesAddress(a, accounts)}
|
||||
canReorder={accounts.length > 1}
|
||||
index={index}
|
||||
isActive={a.id === activeAccountId}
|
||||
isFirst={index === 0}
|
||||
@@ -245,6 +242,7 @@ export function AccountSettings() {
|
||||
onMoveDown={() => moveAccount(index, index + 1)}
|
||||
onSwitch={() => handleSwitch(a.id)}
|
||||
onSetDefault={() => setDefaultAccount(a.id)}
|
||||
onRemove={() => void handleRemove(a)}
|
||||
labels={{
|
||||
active: t('accounts.active'),
|
||||
default: t('accounts.default_badge'),
|
||||
@@ -253,6 +251,7 @@ export function AccountSettings() {
|
||||
moveUp: t('accounts.move_up'),
|
||||
moveDown: t('accounts.move_down'),
|
||||
dragHandle: t('accounts.drag_handle'),
|
||||
remove: tSidebar('remove_account'),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -272,6 +271,8 @@ export function AccountSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
{archiveEnabled && <AccountArchiveSettings />}
|
||||
|
||||
<MultipleAccountsSection />
|
||||
@@ -320,6 +321,10 @@ export function AccountSettings() {
|
||||
interface AccountRowProps {
|
||||
account: AccountEntry;
|
||||
text: { title: string; subtitle: string | null };
|
||||
/** The server line: only for an account in trouble or a shared address. */
|
||||
showServer: boolean;
|
||||
/** Dragging and the arrows: only with two accounts or more. */
|
||||
canReorder: boolean;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isFirst: boolean;
|
||||
@@ -333,6 +338,7 @@ interface AccountRowProps {
|
||||
onMoveDown: () => void;
|
||||
onSwitch: () => void;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
labels: {
|
||||
active: string;
|
||||
default: string;
|
||||
@@ -341,12 +347,15 @@ interface AccountRowProps {
|
||||
moveUp: string;
|
||||
moveDown: string;
|
||||
dragHandle: string;
|
||||
remove: string;
|
||||
};
|
||||
}
|
||||
|
||||
function AccountRow({
|
||||
account,
|
||||
text,
|
||||
showServer,
|
||||
canReorder,
|
||||
index,
|
||||
isActive,
|
||||
isFirst,
|
||||
@@ -360,11 +369,12 @@ function AccountRow({
|
||||
onMoveDown,
|
||||
onSwitch,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
labels,
|
||||
}: AccountRowProps) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
draggable={canReorder}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
@@ -378,12 +388,14 @@ function AccountRow({
|
||||
: 'border-border hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
|
||||
title={labels.dragHandle}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
{canReorder && (
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
|
||||
title={labels.dragHandle}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar
|
||||
@@ -412,7 +424,7 @@ function AccountRow({
|
||||
title={isActive ? labels.active : labels.switchTo}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">
|
||||
<span className="text-sm font-medium wrap-anywhere" title={toUnicodeEmail(text.title)}>
|
||||
{toUnicodeEmail(text.title)}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
@@ -420,23 +432,18 @@ function AccountRow({
|
||||
)}
|
||||
</div>
|
||||
{text.subtitle && (
|
||||
<p dir="ltr" className="text-xs text-muted-foreground truncate rtl:text-right">
|
||||
<p dir="ltr" className="text-xs text-muted-foreground wrap-anywhere rtl:text-right">
|
||||
{toUnicodeEmail(text.subtitle)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{account.hasError ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
) : (
|
||||
<span className={cn(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
|
||||
)} />
|
||||
)}
|
||||
<span className="text-[0.625rem] text-muted-foreground truncate">
|
||||
{hostnameOf(account.serverUrl)}
|
||||
</span>
|
||||
</div>
|
||||
{showServer && (
|
||||
<div className="flex items-center gap-1 mt-0.5" data-testid="account-server">
|
||||
{account.hasError && <AlertCircle className="w-3 h-3 text-destructive" />}
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{hostnameOf(account.serverUrl)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
@@ -451,6 +458,7 @@ function AccountRow({
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canReorder && (<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
@@ -471,6 +479,18 @@ function AccountRow({
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</>)}
|
||||
{!isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={labels.remove}
|
||||
aria-label={labels.remove}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,29 +27,23 @@ export function EmailAddressesSection() {
|
||||
const t = useTranslations('settings.identities');
|
||||
const tAccount = useTranslations('settings.account');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const tAddr = useTranslations('uxs2_ident.addresses');
|
||||
const { identities } = useIdentityStore();
|
||||
const { subAddressDelimiter, updateSetting } = useSettingsStore();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection id="addresses" title={tAccount('sections.addresses')} description={t('description')}>
|
||||
{/* Identity Count */}
|
||||
<SettingItem
|
||||
label={t('identities_count.label')}
|
||||
description={t('identities_count.description')}
|
||||
term="identity"
|
||||
>
|
||||
{/* The section says what the addresses are; the row under it doesn't
|
||||
say it again (settings-p2-18). No "identity": a developer word. */}
|
||||
<SettingsSection id="addresses" title={tAccount('sections.addresses')} description={tAddr('description')}>
|
||||
<SettingItem label={tAddr('label')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{identities.length === 0
|
||||
? t('identities_count.count_zero')
|
||||
: identities.length === 1
|
||||
? t('identities_count.count_one')
|
||||
: t('identities_count.count_other', { count: identities.length })}
|
||||
{tAddr('count', { count: identities.length })}
|
||||
</span>
|
||||
<Button onClick={() => setShowModal(true)} size="sm">
|
||||
{t('manage')}
|
||||
{tAddr('manage')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Check, Loader2 } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingItem } from './settings-section';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { saveIdentityChanges } from '@/stores/identity-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
/**
|
||||
* "Your name" on the Account page: the name on the primary sending address,
|
||||
* which is what recipients see next to it. It saves to that identity, so the
|
||||
* Account page and the address editor change the same name (settings-p2-2).
|
||||
* Without an identity there is nothing to save to, and the row only shows
|
||||
* the name the app knows.
|
||||
*/
|
||||
export function YourNameRow({ fallbackName }: { fallbackName?: string }) {
|
||||
const t = useTranslations('uxs2_ident.your_name');
|
||||
const tForm = useTranslations('identities.form');
|
||||
const tCommon = useTranslations('common');
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const identity = useAuthStore((s) => s.primaryIdentity);
|
||||
const current = identity?.name ?? '';
|
||||
const [name, setName] = useState(current);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => { setName(current); }, [current]);
|
||||
|
||||
if (!identity || !client) {
|
||||
return (
|
||||
<SettingItem label={t('label')} description={t('description')}>
|
||||
<span className="text-sm text-foreground">{fallbackName || tCommon('unknown')}</span>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!(await saveIdentityChanges(client, identity, { name: name.trim() }))) return;
|
||||
useAuthStore.getState().syncIdentities();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
toast.success(t('saved'));
|
||||
} catch (error) {
|
||||
toast.error(t('error'), error instanceof Error ? error.message : undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem label={t('label')} description={t('description')}>
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => { e.preventDefault(); void save(); }}
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={256}
|
||||
autoComplete="name"
|
||||
className="w-48"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={saving || name.trim() === current.trim()}>
|
||||
{saved ? <Check className="w-4 h-4" /> : saving ? <Loader2 className="w-4 h-4 animate-spin" /> : tForm('save')}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import en from '@/locales/en/common.json';
|
||||
import de from '@/locales/de/common.json';
|
||||
import {
|
||||
type SettingsSearchTab,
|
||||
matchesAllWords,
|
||||
settingsSubResults,
|
||||
subResultText,
|
||||
tabSearchEntries,
|
||||
} from '../settings-search';
|
||||
|
||||
// settings-p2-3: "identity", "alias", "sender" and "from address" found
|
||||
// nothing, and "signature" led to a row that only linked elsewhere. The
|
||||
// signatures now sit on Composing, and these words find them.
|
||||
const everywhere = (messages: unknown, query: string) => (Object.keys(tabSearchEntries) as SettingsSearchTab[]).flatMap((tab) =>
|
||||
settingsSubResults(messages, tab, 'Bulwark')
|
||||
.filter((r) => matchesAllWords(subResultText(r), query))
|
||||
.map((r) => `${tab}: ${r.label}`));
|
||||
|
||||
describe('settings search: addresses and your name', () => {
|
||||
it('finds the sending addresses on Account for identity and alias', () => {
|
||||
for (const query of ['identity', 'alias', 'sender address']) {
|
||||
expect(everywhere(en, query), query).toContain('account: Email addresses');
|
||||
}
|
||||
});
|
||||
|
||||
it('finds "Your name" for display name', () => {
|
||||
expect(everywhere(en, 'display name')).toContain('account: Your name');
|
||||
});
|
||||
|
||||
it('finds them in German too', () => {
|
||||
expect(everywhere(de, 'Identität')).toContain('account: E-Mail-Adressen');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { toUnicodeEmail } from '@/lib/idn';
|
||||
|
||||
/**
|
||||
* Utilities for multi-account support:
|
||||
@@ -174,6 +175,19 @@ export function accountRowText(
|
||||
return { title: name, subtitle: address };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether another signed-in account has the same address (one mailbox on two
|
||||
* servers). Only then is the server worth a line under an account: in every
|
||||
* other case it says nothing the person needs (settings-14, settings-p2-11).
|
||||
*/
|
||||
export function accountSharesAddress(
|
||||
account: NamedAccount,
|
||||
accounts: NamedAccount[],
|
||||
): boolean {
|
||||
const key = (a: NamedAccount) => toUnicodeEmail(a.email || a.username).trim().toLowerCase();
|
||||
return accounts.some((other) => other.id !== account.id && key(other) === key(account));
|
||||
}
|
||||
|
||||
/**
|
||||
* The name an account's avatar takes its initials from. Usually the account's
|
||||
* name. When another account has the same name, both would show the same two
|
||||
|
||||
@@ -64,12 +64,12 @@ const syn = (id: string) => `uxs_structure.search.${id}`;
|
||||
// the label (see descriptionKeyFor) and searched too.
|
||||
export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
|
||||
account: [
|
||||
{ label: 'settings.account.name_label' },
|
||||
{ label: 'uxs2_ident.your_name.label', synonyms: 'uxs2_ident.search.your_name' },
|
||||
{ label: 'settings.account.email.label' },
|
||||
{ label: 'settings.account.auth_method_label' },
|
||||
{ label: 'settings.account.server.label', advanced: true },
|
||||
{ label: 'settings.account.sections.addresses', synonyms: syn('signature') },
|
||||
{ label: 'settings.identities.identities_count.label' },
|
||||
{ label: 'settings.account.sections.addresses', synonyms: 'uxs2_ident.search.addresses' },
|
||||
{ label: 'uxs2_ident.addresses.label' },
|
||||
{ label: 'settings.identities.sub_addressing.label' },
|
||||
{ label: 'settings.email_behavior.sub_address_delimiter.label', advanced: true },
|
||||
{ label: 'settings.account.accounts.title', when: 'multiAccount' },
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, zero {# ساعة} one {ساعة واحدة} two {ساعتان} few {# ساعات} many {# ساعة} other {# ساعة}}",
|
||||
"desktop_attention_title": "على هذا الكمبيوتر"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "اسمك",
|
||||
"description": "يرى الناس هذا الاسم بجانب عنوانك عندما ترسل إليهم رسالة.",
|
||||
"saved": "تم حفظ اسمك.",
|
||||
"error": "تعذّر حفظ اسمك. حاول مرة أخرى."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "العناوين التي يمكنك الإرسال منها، لكل منها اسمه وتوقيعه.",
|
||||
"label": "عناوينك",
|
||||
"count": "{count, plural, =0 {لا توجد عناوين بعد} zero {# عنوان} one {عنوان واحد} two {عنوانان} few {# عناوين} many {# عنوانًا} other {# عنوان}}",
|
||||
"manage": "تعديل العناوين…",
|
||||
"modal_title": "عناوين بريدك الإلكتروني",
|
||||
"add": "إضافة عنوان",
|
||||
"edit": "تعديل العنوان",
|
||||
"added": "تمت إضافة العنوان.",
|
||||
"saved": "تم حفظ العنوان.",
|
||||
"deleted": "تم حذف العنوان.",
|
||||
"delete_title": "حذف هذا العنوان؟"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "التوقيعات",
|
||||
"description": "يُضاف في نهاية الرسائل التي تكتبها. لكل عنوان توقيعه الخاص.",
|
||||
"saved": "تم حفظ التوقيع.",
|
||||
"error": "تعذّر حفظ التوقيع. حاول مرة أخرى.",
|
||||
"no_addresses": "لا يوجد عنوان للإرسال منه بعد."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "هوية هويات اسم مستعار مرسل عنوان المرسل عنوان من identity alias sender",
|
||||
"signatures": "توقيع توقيع البريد تذييل هوية مرسل عنوان من signature",
|
||||
"your_name": "الاسم المعروض الاسم الكامل اسم المرسل display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "أدخل عنوان الخادم أولًا.",
|
||||
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hora} other {# hores}}",
|
||||
"desktop_attention_title": "En aquest ordinador"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "El vostre nom",
|
||||
"description": "La gent veu aquest nom al costat de la vostra adreça quan els envieu correu.",
|
||||
"saved": "S'ha desat el vostre nom.",
|
||||
"error": "No s'ha pogut desar el vostre nom. Torneu-ho a provar."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Les adreces des de les quals podeu enviar, cadascuna amb el seu nom i la seva signatura.",
|
||||
"label": "Les vostres adreces",
|
||||
"count": "{count, plural, =0 {Encara no hi ha adreces} one {# adreça} other {# adreces}}",
|
||||
"manage": "Edita les adreces…",
|
||||
"modal_title": "Les vostres adreces de correu",
|
||||
"add": "Afegeix una adreça",
|
||||
"edit": "Edita l'adreça",
|
||||
"added": "S'ha afegit l'adreça.",
|
||||
"saved": "S'ha desat l'adreça.",
|
||||
"deleted": "S'ha suprimit l'adreça.",
|
||||
"delete_title": "Voleu suprimir aquesta adreça?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signatures",
|
||||
"description": "S'afegeix al final dels missatges que escriviu. Cada adreça té la seva.",
|
||||
"saved": "S'ha desat la signatura.",
|
||||
"error": "No s'ha pogut desar la signatura. Torneu-ho a provar.",
|
||||
"no_addresses": "Encara no teniu cap adreça per enviar."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitat identitats àlies remitent adreça del remitent adreça de identity alias sender",
|
||||
"signatures": "signatura signatura de correu peu identitat remitent adreça de signature",
|
||||
"your_name": "nom visible nom complet nom del remitent display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Primer escriu l'adreça del servidor.",
|
||||
"server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hodina} few {# hodiny} many {# hodiny} other {# hodin}}",
|
||||
"desktop_attention_title": "Na tomto počítači"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Vaše jméno",
|
||||
"description": "Toto jméno lidé vidí vedle vaší adresy, když jim pošlete e-mail.",
|
||||
"saved": "Vaše jméno je uloženo.",
|
||||
"error": "Vaše jméno se nepodařilo uložit. Zkuste to znovu."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adresy, ze kterých můžete odesílat, každá s vlastním jménem a podpisem.",
|
||||
"label": "Vaše adresy",
|
||||
"count": "{count, plural, =0 {Zatím žádné adresy} one {# adresa} few {# adresy} many {# adresy} other {# adres}}",
|
||||
"manage": "Upravit adresy…",
|
||||
"modal_title": "Vaše e-mailové adresy",
|
||||
"add": "Přidat adresu",
|
||||
"edit": "Upravit adresu",
|
||||
"added": "Adresa přidána.",
|
||||
"saved": "Adresa uložena.",
|
||||
"deleted": "Adresa smazána.",
|
||||
"delete_title": "Smazat tuto adresu?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Podpisy",
|
||||
"description": "Přidá se na konec zpráv, které píšete. Každá adresa má svůj vlastní.",
|
||||
"saved": "Podpis uložen.",
|
||||
"error": "Podpis se nepodařilo uložit. Zkuste to znovu.",
|
||||
"no_addresses": "Zatím nemáte adresu, ze které lze odesílat."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identita identity alias aliasy odesílatel adresa odesílatele adresa od identity alias sender",
|
||||
"signatures": "podpis e-mailový podpis zápatí identita odesílatel adresa od signature",
|
||||
"your_name": "zobrazované jméno celé jméno jméno odesílatele display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Nejdřív zadejte adresu serveru.",
|
||||
"server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# time} other {# timer}}",
|
||||
"desktop_attention_title": "På denne computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Dit navn",
|
||||
"description": "Folk ser dette navn ved siden af din adresse, når du sender dem mail.",
|
||||
"saved": "Dit navn er gemt.",
|
||||
"error": "Dit navn kunne ikke gemmes. Prøv igen."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "De adresser, du kan sende fra, hver med sit eget navn og sin egen signatur.",
|
||||
"label": "Dine adresser",
|
||||
"count": "{count, plural, =0 {Ingen adresser endnu} one {# adresse} other {# adresser}}",
|
||||
"manage": "Rediger adresser…",
|
||||
"modal_title": "Dine e-mailadresser",
|
||||
"add": "Tilføj adresse",
|
||||
"edit": "Rediger adresse",
|
||||
"added": "Adressen er tilføjet.",
|
||||
"saved": "Adressen er gemt.",
|
||||
"deleted": "Adressen er slettet.",
|
||||
"delete_title": "Slet denne adresse?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signaturer",
|
||||
"description": "Tilføjes i slutningen af de beskeder, du skriver. Hver adresse har sin egen.",
|
||||
"saved": "Signaturen er gemt.",
|
||||
"error": "Signaturen kunne ikke gemmes. Prøv igen.",
|
||||
"no_addresses": "Der er endnu ingen adresse at sende fra."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitet identiteter alias afsender afsenderadresse fra-adresse identity alias sender",
|
||||
"signatures": "signatur e-mailsignatur sidefod identitet afsender fra-adresse signature",
|
||||
"your_name": "visningsnavn fulde navn afsendernavn display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Angiv først serverens adresse.",
|
||||
"server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# Stunde} other {# Stunden}}",
|
||||
"desktop_attention_title": "Auf diesem Computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Ihr Name",
|
||||
"description": "Diesen Namen sehen andere neben Ihrer Adresse, wenn Sie ihnen schreiben.",
|
||||
"saved": "Ihr Name ist gespeichert.",
|
||||
"error": "Ihr Name konnte nicht gespeichert werden. Versuchen Sie es noch einmal."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Die Adressen, von denen Sie senden können, jede mit eigenem Namen und eigener Signatur.",
|
||||
"label": "Ihre Adressen",
|
||||
"count": "{count, plural, =0 {Noch keine Adressen} one {# Adresse} other {# Adressen}}",
|
||||
"manage": "Adressen bearbeiten…",
|
||||
"modal_title": "Ihre E-Mail-Adressen",
|
||||
"add": "Adresse hinzufügen",
|
||||
"edit": "Adresse bearbeiten",
|
||||
"added": "Adresse hinzugefügt.",
|
||||
"saved": "Adresse gespeichert.",
|
||||
"deleted": "Adresse gelöscht.",
|
||||
"delete_title": "Diese Adresse löschen?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signaturen",
|
||||
"description": "Steht am Ende der Nachrichten, die Sie schreiben. Jede Adresse hat ihre eigene.",
|
||||
"saved": "Signatur gespeichert.",
|
||||
"error": "Die Signatur konnte nicht gespeichert werden. Versuchen Sie es noch einmal.",
|
||||
"no_addresses": "Es gibt noch keine Adresse zum Senden."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "Identität Identitäten Alias Aliasse Absender Absenderadresse Von-Adresse Absendername identity alias sender",
|
||||
"signatures": "Signatur E-Mail-Signatur Grußformel Fußzeile Identität Absender Von-Adresse signature",
|
||||
"your_name": "Anzeigename vollständiger Name Absendername display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.",
|
||||
"server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hour} other {# hours}}",
|
||||
"desktop_attention_title": "On this computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Your name",
|
||||
"description": "People see this name next to your address when you send them mail.",
|
||||
"saved": "Your name is saved.",
|
||||
"error": "Your name could not be saved. Try again."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "The addresses you can send from, each with its own name and signature.",
|
||||
"label": "Your addresses",
|
||||
"count": "{count, plural, =0 {No addresses yet} one {# address} other {# addresses}}",
|
||||
"manage": "Edit addresses…",
|
||||
"modal_title": "Your email addresses",
|
||||
"add": "Add address",
|
||||
"edit": "Edit address",
|
||||
"added": "Address added.",
|
||||
"saved": "Address saved.",
|
||||
"deleted": "Address deleted.",
|
||||
"delete_title": "Delete this address?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signatures",
|
||||
"description": "Added at the end of the messages you write. Each address has its own.",
|
||||
"saved": "Signature saved.",
|
||||
"error": "The signature could not be saved. Try again.",
|
||||
"no_addresses": "There is no address to send from yet."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identity identities alias aliases sender sender address from address from name",
|
||||
"signatures": "signature email signature sign-off footer identity sender from address",
|
||||
"your_name": "display name full name sender name from name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Enter your server's address first.",
|
||||
"server_declined": "You didn't allow this server, so nothing was sent to it.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hora} other {# horas}}",
|
||||
"desktop_attention_title": "En este ordenador"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Tu nombre",
|
||||
"description": "La gente ve este nombre junto a tu dirección cuando les envías un correo.",
|
||||
"saved": "Tu nombre se ha guardado.",
|
||||
"error": "No se ha podido guardar tu nombre. Inténtalo de nuevo."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Las direcciones desde las que puedes enviar, cada una con su propio nombre y firma.",
|
||||
"label": "Tus direcciones",
|
||||
"count": "{count, plural, =0 {Todavía no hay direcciones} one {# dirección} other {# direcciones}}",
|
||||
"manage": "Editar direcciones…",
|
||||
"modal_title": "Tus direcciones de correo",
|
||||
"add": "Añadir dirección",
|
||||
"edit": "Editar dirección",
|
||||
"added": "Dirección añadida.",
|
||||
"saved": "Dirección guardada.",
|
||||
"deleted": "Dirección eliminada.",
|
||||
"delete_title": "¿Eliminar esta dirección?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Firmas",
|
||||
"description": "Se añade al final de los mensajes que escribes. Cada dirección tiene la suya.",
|
||||
"saved": "Firma guardada.",
|
||||
"error": "No se ha podido guardar la firma. Inténtalo de nuevo.",
|
||||
"no_addresses": "Todavía no hay ninguna dirección desde la que enviar."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identidad identidades alias remitente dirección del remitente dirección de envío identity alias sender",
|
||||
"signatures": "firma firma de correo pie identidad remitente dirección de envío signature",
|
||||
"your_name": "nombre visible nombre completo nombre del remitente display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Primero escribe la dirección del servidor.",
|
||||
"server_declined": "No permitiste este servidor, así que no se le envió nada.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# tund} other {# tundi}}",
|
||||
"desktop_attention_title": "Selles arvutis"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Teie nimi",
|
||||
"description": "Seda nime näevad inimesed teie aadressi kõrval, kui saadate neile kirja.",
|
||||
"saved": "Teie nimi on salvestatud.",
|
||||
"error": "Teie nime ei õnnestunud salvestada. Proovige uuesti."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Aadressid, millelt saate saata, igaühel oma nimi ja allkiri.",
|
||||
"label": "Teie aadressid",
|
||||
"count": "{count, plural, =0 {Aadresse veel pole} one {# aadress} other {# aadressi}}",
|
||||
"manage": "Muuda aadresse…",
|
||||
"modal_title": "Teie e-posti aadressid",
|
||||
"add": "Lisa aadress",
|
||||
"edit": "Muuda aadressi",
|
||||
"added": "Aadress lisatud.",
|
||||
"saved": "Aadress salvestatud.",
|
||||
"deleted": "Aadress kustutatud.",
|
||||
"delete_title": "Kas kustutada see aadress?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Allkirjad",
|
||||
"description": "Lisatakse teie kirjutatud kirjade lõppu. Igal aadressil on oma.",
|
||||
"saved": "Allkiri salvestatud.",
|
||||
"error": "Allkirja ei õnnestunud salvestada. Proovige uuesti.",
|
||||
"no_addresses": "Teil pole veel aadressi, millelt saata."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identiteet identiteedid alias saatja saatja aadress saatja nimi identity alias sender",
|
||||
"signatures": "allkiri e-kirja allkiri jalus identiteet saatja saatja aadress signature",
|
||||
"your_name": "kuvatav nimi täisnimi saatja nimi display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Sisesta kõigepealt serveri aadress.",
|
||||
"server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# ساعت} other {# ساعت}}",
|
||||
"desktop_attention_title": "روی این رایانه"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "نام شما",
|
||||
"description": "وقتی برای دیگران نامه میفرستید، این نام را کنار نشانی شما میبینند.",
|
||||
"saved": "نام شما ذخیره شد.",
|
||||
"error": "نام شما ذخیره نشد. دوباره امتحان کنید."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "نشانیهایی که میتوانید از آنها نامه بفرستید، هر کدام با نام و امضای خودش.",
|
||||
"label": "نشانیهای شما",
|
||||
"count": "{count, plural, =0 {هنوز نشانیای نیست} one {# نشانی} other {# نشانی}}",
|
||||
"manage": "ویرایش نشانیها…",
|
||||
"modal_title": "نشانیهای ایمیل شما",
|
||||
"add": "افزودن نشانی",
|
||||
"edit": "ویرایش نشانی",
|
||||
"added": "نشانی افزوده شد.",
|
||||
"saved": "نشانی ذخیره شد.",
|
||||
"deleted": "نشانی حذف شد.",
|
||||
"delete_title": "این نشانی حذف شود؟"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "امضاها",
|
||||
"description": "به انتهای پیامهایی که مینویسید افزوده میشود. هر نشانی امضای خودش را دارد.",
|
||||
"saved": "امضا ذخیره شد.",
|
||||
"error": "امضا ذخیره نشد. دوباره امتحان کنید.",
|
||||
"no_addresses": "هنوز نشانیای برای فرستادن ندارید."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "هویت هویتها نام مستعار فرستنده نشانی فرستنده نشانی از identity alias sender",
|
||||
"signatures": "امضا امضای ایمیل پانویس هویت فرستنده نشانی از signature",
|
||||
"your_name": "نام نمایشی نام کامل نام فرستنده display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
|
||||
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# heure} other {# heures}}",
|
||||
"desktop_attention_title": "Sur cet ordinateur"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Votre nom",
|
||||
"description": "Les gens voient ce nom à côté de votre adresse quand vous leur écrivez.",
|
||||
"saved": "Votre nom est enregistré.",
|
||||
"error": "Votre nom n'a pas pu être enregistré. Réessayez."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Les adresses depuis lesquelles vous pouvez envoyer, chacune avec son nom et sa signature.",
|
||||
"label": "Vos adresses",
|
||||
"count": "{count, plural, =0 {Pas encore d'adresse} one {# adresse} other {# adresses}}",
|
||||
"manage": "Modifier les adresses…",
|
||||
"modal_title": "Vos adresses e-mail",
|
||||
"add": "Ajouter une adresse",
|
||||
"edit": "Modifier l'adresse",
|
||||
"added": "Adresse ajoutée.",
|
||||
"saved": "Adresse enregistrée.",
|
||||
"deleted": "Adresse supprimée.",
|
||||
"delete_title": "Supprimer cette adresse ?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signatures",
|
||||
"description": "Ajoutée à la fin des messages que vous écrivez. Chaque adresse a la sienne.",
|
||||
"saved": "Signature enregistrée.",
|
||||
"error": "La signature n'a pas pu être enregistrée. Réessayez.",
|
||||
"no_addresses": "Vous n'avez encore aucune adresse d'envoi."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identité identités alias expéditeur adresse d'expédition adresse de l'expéditeur identity alias sender",
|
||||
"signatures": "signature signature e-mail pied de page identité expéditeur adresse d'expédition",
|
||||
"your_name": "nom affiché nom complet nom de l'expéditeur display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Saisissez d'abord l'adresse du serveur.",
|
||||
"server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.",
|
||||
|
||||
@@ -7147,6 +7147,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {שעה אחת} two {שעתיים} other {# שעות}}",
|
||||
"desktop_attention_title": "במחשב הזה"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "השם שלכם",
|
||||
"description": "אנשים רואים את השם הזה ליד הכתובת שלכם כשאתם שולחים להם דואר.",
|
||||
"saved": "השם שלכם נשמר.",
|
||||
"error": "לא ניתן היה לשמור את השם שלכם. נסו שוב."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "הכתובות שמהן אפשר לשלוח, לכל אחת שם וחתימה משלה.",
|
||||
"label": "הכתובות שלכם",
|
||||
"count": "{count, plural, =0 {עדיין אין כתובות} one {כתובת אחת} two {# כתובות} other {# כתובות}}",
|
||||
"manage": "עריכת כתובות…",
|
||||
"modal_title": "כתובות הדואר שלכם",
|
||||
"add": "הוספת כתובת",
|
||||
"edit": "עריכת כתובת",
|
||||
"added": "הכתובת נוספה.",
|
||||
"saved": "הכתובת נשמרה.",
|
||||
"deleted": "הכתובת נמחקה.",
|
||||
"delete_title": "למחוק את הכתובת הזו?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "חתימות",
|
||||
"description": "נוספת בסוף ההודעות שאתם כותבים. לכל כתובת חתימה משלה.",
|
||||
"saved": "החתימה נשמרה.",
|
||||
"error": "לא ניתן היה לשמור את החתימה. נסו שוב.",
|
||||
"no_addresses": "עדיין אין כתובת לשליחה."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "זהות זהויות כינוי שולח כתובת השולח כתובת מאת identity alias sender",
|
||||
"signatures": "חתימה חתימת דואר כותרת תחתונה זהות שולח כתובת מאת signature",
|
||||
"your_name": "שם תצוגה שם מלא שם השולח display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "קודם צריך להזין את כתובת השרת.",
|
||||
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# óra} other {# óra}}",
|
||||
"desktop_attention_title": "Ezen a számítógépen"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Az Ön neve",
|
||||
"description": "Ezt a nevet látják mások a címe mellett, amikor levelet küld nekik.",
|
||||
"saved": "A neve mentve.",
|
||||
"error": "A nevét nem sikerült menteni. Próbálja újra."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "A címek, amelyekről küldhet, mindegyik saját névvel és aláírással.",
|
||||
"label": "Az Ön címei",
|
||||
"count": "{count, plural, =0 {Még nincs cím} one {# cím} other {# cím}}",
|
||||
"manage": "Címek szerkesztése…",
|
||||
"modal_title": "Az Ön e-mail-címei",
|
||||
"add": "Cím hozzáadása",
|
||||
"edit": "Cím szerkesztése",
|
||||
"added": "Cím hozzáadva.",
|
||||
"saved": "Cím mentve.",
|
||||
"deleted": "Cím törölve.",
|
||||
"delete_title": "Törli ezt a címet?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Aláírások",
|
||||
"description": "Az Ön által írt üzenetek végére kerül. Minden címnek saját aláírása van.",
|
||||
"saved": "Aláírás mentve.",
|
||||
"error": "Az aláírást nem sikerült menteni. Próbálja újra.",
|
||||
"no_addresses": "Még nincs cím, amelyről küldhetne."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitás identitások álnév alias feladó feladó címe feladócím identity alias sender",
|
||||
"signatures": "aláírás e-mail aláírás lábléc identitás feladó feladócím signature",
|
||||
"your_name": "megjelenített név teljes név feladó neve display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Először adja meg a szerver címét.",
|
||||
"server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# ora} other {# ore}}",
|
||||
"desktop_attention_title": "Su questo computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Il tuo nome",
|
||||
"description": "Le persone vedono questo nome accanto al tuo indirizzo quando scrivi loro.",
|
||||
"saved": "Il tuo nome è stato salvato.",
|
||||
"error": "Non è stato possibile salvare il tuo nome. Riprova."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Gli indirizzi da cui puoi inviare, ognuno con il proprio nome e la propria firma.",
|
||||
"label": "I tuoi indirizzi",
|
||||
"count": "{count, plural, =0 {Ancora nessun indirizzo} one {# indirizzo} other {# indirizzi}}",
|
||||
"manage": "Modifica indirizzi…",
|
||||
"modal_title": "I tuoi indirizzi email",
|
||||
"add": "Aggiungi indirizzo",
|
||||
"edit": "Modifica indirizzo",
|
||||
"added": "Indirizzo aggiunto.",
|
||||
"saved": "Indirizzo salvato.",
|
||||
"deleted": "Indirizzo eliminato.",
|
||||
"delete_title": "Eliminare questo indirizzo?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Firme",
|
||||
"description": "Aggiunta alla fine dei messaggi che scrivi. Ogni indirizzo ha la sua.",
|
||||
"saved": "Firma salvata.",
|
||||
"error": "Non è stato possibile salvare la firma. Riprova.",
|
||||
"no_addresses": "Non c'è ancora un indirizzo da cui inviare."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identità alias mittente indirizzo del mittente indirizzo da identity sender",
|
||||
"signatures": "firma firma email piè di pagina identità mittente indirizzo da signature",
|
||||
"your_name": "nome visualizzato nome completo nome del mittente display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Inserisci prima l'indirizzo del server.",
|
||||
"server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {#時間}}",
|
||||
"desktop_attention_title": "このコンピューター"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "あなたの名前",
|
||||
"description": "メールを送ると、相手にはこの名前がアドレスの横に表示されます。",
|
||||
"saved": "名前を保存しました。",
|
||||
"error": "名前を保存できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"addresses": {
|
||||
"description": "送信に使えるアドレスです。アドレスごとに名前と署名があります。",
|
||||
"label": "あなたのアドレス",
|
||||
"count": "{count, plural, =0 {まだアドレスはありません} other {# 件のアドレス}}",
|
||||
"manage": "アドレスを編集…",
|
||||
"modal_title": "あなたのメールアドレス",
|
||||
"add": "アドレスを追加",
|
||||
"edit": "アドレスを編集",
|
||||
"added": "アドレスを追加しました。",
|
||||
"saved": "アドレスを保存しました。",
|
||||
"deleted": "アドレスを削除しました。",
|
||||
"delete_title": "このアドレスを削除しますか?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "署名",
|
||||
"description": "書いたメッセージの最後に付きます。アドレスごとに別の署名を使えます。",
|
||||
"saved": "署名を保存しました。",
|
||||
"error": "署名を保存できませんでした。もう一度お試しください。",
|
||||
"no_addresses": "送信に使えるアドレスがまだありません。"
|
||||
},
|
||||
"search": {
|
||||
"addresses": "ID アイデンティティ エイリアス 差出人 差出人アドレス 送信元アドレス identity alias sender",
|
||||
"signatures": "署名 メール署名 フッター アイデンティティ 差出人 送信元アドレス signature",
|
||||
"your_name": "表示名 氏名 差出人名 display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "先にサーバーのアドレスを入力してください。",
|
||||
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {#시간}}",
|
||||
"desktop_attention_title": "이 컴퓨터에서"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "내 이름",
|
||||
"description": "메일을 보내면 받는 사람에게 주소 옆에 이 이름이 보입니다.",
|
||||
"saved": "이름을 저장했습니다.",
|
||||
"error": "이름을 저장하지 못했습니다. 다시 시도하세요."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "보낼 때 쓸 수 있는 주소입니다. 주소마다 이름과 서명이 따로 있습니다.",
|
||||
"label": "내 주소",
|
||||
"count": "{count, plural, =0 {아직 주소가 없습니다} other {주소 #개}}",
|
||||
"manage": "주소 편집…",
|
||||
"modal_title": "내 이메일 주소",
|
||||
"add": "주소 추가",
|
||||
"edit": "주소 편집",
|
||||
"added": "주소를 추가했습니다.",
|
||||
"saved": "주소를 저장했습니다.",
|
||||
"deleted": "주소를 삭제했습니다.",
|
||||
"delete_title": "이 주소를 삭제할까요?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "서명",
|
||||
"description": "작성한 메시지 끝에 붙습니다. 주소마다 서명이 따로 있습니다.",
|
||||
"saved": "서명을 저장했습니다.",
|
||||
"error": "서명을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"no_addresses": "아직 보낼 수 있는 주소가 없습니다."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "ID 아이덴티티 별칭 보낸 사람 보낸 사람 주소 발신 주소 identity alias sender",
|
||||
"signatures": "서명 이메일 서명 바닥글 아이덴티티 보낸 사람 발신 주소 signature",
|
||||
"your_name": "표시 이름 전체 이름 보낸 사람 이름 display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "먼저 서버 주소를 입력하세요.",
|
||||
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, zero {# stundu} one {# stunda} other {# stundas}}",
|
||||
"desktop_attention_title": "Šajā datorā"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Jūsu vārds",
|
||||
"description": "Šo vārdu cilvēki redz blakus jūsu adresei, kad sūtāt viņiem vēstuli.",
|
||||
"saved": "Jūsu vārds ir saglabāts.",
|
||||
"error": "Jūsu vārdu neizdevās saglabāt. Mēģiniet vēlreiz."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adreses, no kurām varat sūtīt, katrai savs vārds un paraksts.",
|
||||
"label": "Jūsu adreses",
|
||||
"count": "{count, plural, =0 {Vēl nav adrešu} zero {# adrešu} one {# adrese} other {# adreses}}",
|
||||
"manage": "Rediģēt adreses…",
|
||||
"modal_title": "Jūsu e-pasta adreses",
|
||||
"add": "Pievienot adresi",
|
||||
"edit": "Rediģēt adresi",
|
||||
"added": "Adrese pievienota.",
|
||||
"saved": "Adrese saglabāta.",
|
||||
"deleted": "Adrese izdzēsta.",
|
||||
"delete_title": "Vai dzēst šo adresi?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Paraksti",
|
||||
"description": "Tiek pievienots jūsu rakstīto ziņojumu beigās. Katrai adresei ir savs.",
|
||||
"saved": "Paraksts saglabāts.",
|
||||
"error": "Parakstu neizdevās saglabāt. Mēģiniet vēlreiz.",
|
||||
"no_addresses": "Vēl nav adreses, no kuras sūtīt."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitāte identitātes aizstājvārds sūtītājs sūtītāja adrese no adrese identity alias sender",
|
||||
"signatures": "paraksts e-pasta paraksts kājene identitāte sūtītājs no adrese signature",
|
||||
"your_name": "parādāmais vārds pilns vārds sūtītāja vārds display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Vispirms ievadiet servera adresi.",
|
||||
"server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# цаг} other {# цаг}}",
|
||||
"desktop_attention_title": "Энэ компьютер дээр"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Таны нэр",
|
||||
"description": "Та хүмүүст захидал илгээхэд энэ нэр таны хаягийн хажууд харагдана.",
|
||||
"saved": "Таны нэр хадгалагдлаа.",
|
||||
"error": "Таны нэрийг хадгалж чадсангүй. Дахин оролдоно уу."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Таны илгээж болох хаягууд, тус бүр өөрийн нэр, гарын үсэгтэй.",
|
||||
"label": "Таны хаягууд",
|
||||
"count": "{count, plural, =0 {Одоогоор хаяг алга} one {# хаяг} other {# хаяг}}",
|
||||
"manage": "Хаягуудыг засах…",
|
||||
"modal_title": "Таны имэйл хаягууд",
|
||||
"add": "Хаяг нэмэх",
|
||||
"edit": "Хаягийг засах",
|
||||
"added": "Хаяг нэмэгдлээ.",
|
||||
"saved": "Хаяг хадгалагдлаа.",
|
||||
"deleted": "Хаяг устгагдлаа.",
|
||||
"delete_title": "Энэ хаягийг устгах уу?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Гарын үсгүүд",
|
||||
"description": "Таны бичсэн захидлын төгсгөлд нэмэгдэнэ. Хаяг бүр өөрийн гарын үсэгтэй.",
|
||||
"saved": "Гарын үсэг хадгалагдлаа.",
|
||||
"error": "Гарын үсгийг хадгалж чадсангүй. Дахин оролдоно уу.",
|
||||
"no_addresses": "Илгээх хаяг одоогоор алга."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "хувийн мэдээлэл таних тэмдэг өөр нэр илгээгч илгээгчийн хаяг хэнээс хаяг identity alias sender",
|
||||
"signatures": "гарын үсэг имэйлийн гарын үсэг хөл хэсэг илгээгч хэнээс хаяг signature",
|
||||
"your_name": "харагдах нэр бүтэн нэр илгээгчийн нэр display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
|
||||
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# time} other {# timer}}",
|
||||
"desktop_attention_title": "På denne datamaskinen"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Navnet ditt",
|
||||
"description": "Folk ser dette navnet ved siden av adressen din når du sender dem e-post.",
|
||||
"saved": "Navnet ditt er lagret.",
|
||||
"error": "Navnet ditt kunne ikke lagres. Prøv igjen."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adressene du kan sende fra, hver med sitt eget navn og sin egen signatur.",
|
||||
"label": "Adressene dine",
|
||||
"count": "{count, plural, =0 {Ingen adresser ennå} one {# adresse} other {# adresser}}",
|
||||
"manage": "Rediger adresser…",
|
||||
"modal_title": "E-postadressene dine",
|
||||
"add": "Legg til adresse",
|
||||
"edit": "Rediger adresse",
|
||||
"added": "Adressen er lagt til.",
|
||||
"saved": "Adressen er lagret.",
|
||||
"deleted": "Adressen er slettet.",
|
||||
"delete_title": "Vil du slette denne adressen?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Signaturer",
|
||||
"description": "Legges til på slutten av meldingene du skriver. Hver adresse har sin egen.",
|
||||
"saved": "Signaturen er lagret.",
|
||||
"error": "Signaturen kunne ikke lagres. Prøv igjen.",
|
||||
"no_addresses": "Det finnes ingen adresse å sende fra ennå."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitet identiteter alias avsender avsenderadresse fra-adresse identity sender",
|
||||
"signatures": "signatur e-postsignatur bunntekst identitet avsender fra-adresse signature",
|
||||
"your_name": "visningsnavn fullt navn avsendernavn display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Skriv inn serveradressen først.",
|
||||
"server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# uur} other {# uur}}",
|
||||
"desktop_attention_title": "Op deze computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Uw naam",
|
||||
"description": "Mensen zien deze naam naast uw adres als u ze mailt.",
|
||||
"saved": "Uw naam is opgeslagen.",
|
||||
"error": "Uw naam kon niet worden opgeslagen. Probeer het opnieuw."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "De adressen waarvanaf u kunt verzenden, elk met een eigen naam en handtekening.",
|
||||
"label": "Uw adressen",
|
||||
"count": "{count, plural, =0 {Nog geen adressen} one {# adres} other {# adressen}}",
|
||||
"manage": "Adressen bewerken…",
|
||||
"modal_title": "Uw e-mailadressen",
|
||||
"add": "Adres toevoegen",
|
||||
"edit": "Adres bewerken",
|
||||
"added": "Adres toegevoegd.",
|
||||
"saved": "Adres opgeslagen.",
|
||||
"deleted": "Adres verwijderd.",
|
||||
"delete_title": "Dit adres verwijderen?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Handtekeningen",
|
||||
"description": "Komt onder de berichten die u schrijft. Elk adres heeft een eigen handtekening.",
|
||||
"saved": "Handtekening opgeslagen.",
|
||||
"error": "De handtekening kon niet worden opgeslagen. Probeer het opnieuw.",
|
||||
"no_addresses": "Er is nog geen adres om vanaf te verzenden."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identiteit identiteiten alias afzender afzenderadres van-adres identity sender",
|
||||
"signatures": "handtekening e-mailhandtekening voettekst identiteit afzender van-adres signature",
|
||||
"your_name": "weergavenaam volledige naam afzendernaam display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Vul eerst het adres van de server in.",
|
||||
"server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# godzina} few {# godziny} many {# godzin} other {# godziny}}",
|
||||
"desktop_attention_title": "Na tym komputerze"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Twoje imię i nazwisko",
|
||||
"description": "Ludzie widzą tę nazwę obok Twojego adresu, gdy wysyłasz im wiadomość.",
|
||||
"saved": "Zapisano Twoje imię i nazwisko.",
|
||||
"error": "Nie udało się zapisać Twojego imienia i nazwiska. Spróbuj ponownie."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adresy, z których możesz wysyłać, każdy z własną nazwą i podpisem.",
|
||||
"label": "Twoje adresy",
|
||||
"count": "{count, plural, =0 {Jeszcze brak adresów} one {# adres} few {# adresy} many {# adresów} other {# adresu}}",
|
||||
"manage": "Edytuj adresy…",
|
||||
"modal_title": "Twoje adresy e-mail",
|
||||
"add": "Dodaj adres",
|
||||
"edit": "Edytuj adres",
|
||||
"added": "Dodano adres.",
|
||||
"saved": "Zapisano adres.",
|
||||
"deleted": "Usunięto adres.",
|
||||
"delete_title": "Usunąć ten adres?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Podpisy",
|
||||
"description": "Dodawany na końcu pisanych wiadomości. Każdy adres ma własny.",
|
||||
"saved": "Zapisano podpis.",
|
||||
"error": "Nie udało się zapisać podpisu. Spróbuj ponownie.",
|
||||
"no_addresses": "Nie masz jeszcze adresu, z którego można wysyłać."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "tożsamość tożsamości alias nadawca adres nadawcy adres od identity sender",
|
||||
"signatures": "podpis podpis e-mail stopka tożsamość nadawca adres od signature",
|
||||
"your_name": "nazwa wyświetlana imię i nazwisko nazwa nadawcy display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Najpierw wpisz adres serwera.",
|
||||
"server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hora} other {# horas}}",
|
||||
"desktop_attention_title": "Neste computador"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "O seu nome",
|
||||
"description": "As pessoas veem este nome ao lado do seu endereço quando lhes envia e-mail.",
|
||||
"saved": "O seu nome foi guardado.",
|
||||
"error": "Não foi possível guardar o seu nome. Tente novamente."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Os endereços a partir dos quais pode enviar, cada um com o seu nome e assinatura.",
|
||||
"label": "Os seus endereços",
|
||||
"count": "{count, plural, =0 {Ainda sem endereços} one {# endereço} other {# endereços}}",
|
||||
"manage": "Editar endereços…",
|
||||
"modal_title": "Os seus endereços de e-mail",
|
||||
"add": "Adicionar endereço",
|
||||
"edit": "Editar endereço",
|
||||
"added": "Endereço adicionado.",
|
||||
"saved": "Endereço guardado.",
|
||||
"deleted": "Endereço eliminado.",
|
||||
"delete_title": "Eliminar este endereço?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Assinaturas",
|
||||
"description": "É acrescentada no fim das mensagens que escreve. Cada endereço tem a sua.",
|
||||
"saved": "Assinatura guardada.",
|
||||
"error": "Não foi possível guardar a assinatura. Tente novamente.",
|
||||
"no_addresses": "Ainda não há nenhum endereço a partir do qual enviar."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identidade identidades alias remetente endereço do remetente endereço de envio identity sender",
|
||||
"signatures": "assinatura assinatura de e-mail rodapé identidade remetente endereço de envio signature",
|
||||
"your_name": "nome a mostrar nome completo nome do remetente display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Digite primeiro o endereço do servidor.",
|
||||
"server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# oră} few {# ore} other {# de ore}}",
|
||||
"desktop_attention_title": "Pe acest computer"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Numele dvs.",
|
||||
"description": "Oamenii văd acest nume lângă adresa dvs. când le trimiteți e-mail.",
|
||||
"saved": "Numele dvs. a fost salvat.",
|
||||
"error": "Numele dvs. nu a putut fi salvat. Încercați din nou."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adresele de pe care puteți trimite, fiecare cu numele și semnătura ei.",
|
||||
"label": "Adresele dvs.",
|
||||
"count": "{count, plural, =0 {Încă nicio adresă} one {# adresă} few {# adrese} other {# de adrese}}",
|
||||
"manage": "Editați adresele…",
|
||||
"modal_title": "Adresele dvs. de e-mail",
|
||||
"add": "Adăugați o adresă",
|
||||
"edit": "Editați adresa",
|
||||
"added": "Adresa a fost adăugată.",
|
||||
"saved": "Adresa a fost salvată.",
|
||||
"deleted": "Adresa a fost ștearsă.",
|
||||
"delete_title": "Ștergeți această adresă?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Semnături",
|
||||
"description": "Se adaugă la sfârșitul mesajelor pe care le scrieți. Fiecare adresă are semnătura ei.",
|
||||
"saved": "Semnătura a fost salvată.",
|
||||
"error": "Semnătura nu a putut fi salvată. Încercați din nou.",
|
||||
"no_addresses": "Nu există încă nicio adresă de pe care să trimiteți."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identitate identități alias expeditor adresa expeditorului adresa de la identity sender",
|
||||
"signatures": "semnătură semnătură e-mail subsol identitate expeditor adresa de la signature",
|
||||
"your_name": "nume afișat nume complet numele expeditorului display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Introdu mai întâi adresa serverului.",
|
||||
"server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# час} few {# часа} many {# часов} other {# часа}}",
|
||||
"desktop_attention_title": "На этом компьютере"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Ваше имя",
|
||||
"description": "Это имя люди видят рядом с вашим адресом, когда вы им пишете.",
|
||||
"saved": "Ваше имя сохранено.",
|
||||
"error": "Не удалось сохранить ваше имя. Попробуйте ещё раз."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Адреса, с которых вы можете отправлять почту, у каждого своё имя и своя подпись.",
|
||||
"label": "Ваши адреса",
|
||||
"count": "{count, plural, =0 {Адресов пока нет} one {# адрес} few {# адреса} many {# адресов} other {# адреса}}",
|
||||
"manage": "Изменить адреса…",
|
||||
"modal_title": "Ваши адреса электронной почты",
|
||||
"add": "Добавить адрес",
|
||||
"edit": "Изменить адрес",
|
||||
"added": "Адрес добавлен.",
|
||||
"saved": "Адрес сохранён.",
|
||||
"deleted": "Адрес удалён.",
|
||||
"delete_title": "Удалить этот адрес?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Подписи",
|
||||
"description": "Добавляется в конце ваших писем. У каждого адреса своя.",
|
||||
"saved": "Подпись сохранена.",
|
||||
"error": "Не удалось сохранить подпись. Попробуйте ещё раз.",
|
||||
"no_addresses": "Пока нет адреса, с которого можно отправлять."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "идентичность личность псевдоним алиас отправитель адрес отправителя адрес от identity alias sender",
|
||||
"signatures": "подпись подпись письма нижний колонтитул отправитель адрес от signature",
|
||||
"your_name": "отображаемое имя полное имя имя отправителя display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Сначала введите адрес сервера.",
|
||||
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# hodina} few {# hodiny} many {# hodiny} other {# hodín}}",
|
||||
"desktop_attention_title": "Na tomto počítači"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Vaše meno",
|
||||
"description": "Toto meno ľudia vidia vedľa vašej adresy, keď im pošlete e-mail.",
|
||||
"saved": "Vaše meno je uložené.",
|
||||
"error": "Vaše meno sa nepodarilo uložiť. Skúste to znova."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Adresy, z ktorých môžete odosielať, každá s vlastným menom a podpisom.",
|
||||
"label": "Vaše adresy",
|
||||
"count": "{count, plural, =0 {Zatiaľ žiadne adresy} one {# adresa} few {# adresy} many {# adresy} other {# adries}}",
|
||||
"manage": "Upraviť adresy…",
|
||||
"modal_title": "Vaše e-mailové adresy",
|
||||
"add": "Pridať adresu",
|
||||
"edit": "Upraviť adresu",
|
||||
"added": "Adresa pridaná.",
|
||||
"saved": "Adresa uložená.",
|
||||
"deleted": "Adresa odstránená.",
|
||||
"delete_title": "Odstrániť túto adresu?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Podpisy",
|
||||
"description": "Pridá sa na koniec správ, ktoré píšete. Každá adresa má vlastný.",
|
||||
"saved": "Podpis uložený.",
|
||||
"error": "Podpis sa nepodarilo uložiť. Skúste to znova.",
|
||||
"no_addresses": "Zatiaľ nemáte adresu, z ktorej sa dá odosielať."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "identita identity alias odosielateľ adresa odosielateľa adresa od identity sender",
|
||||
"signatures": "podpis e-mailový podpis päta identita odosielateľ adresa od signature",
|
||||
"your_name": "zobrazované meno celé meno meno odosielateľa display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Najprv zadajte adresu servera.",
|
||||
"server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.",
|
||||
|
||||
@@ -7154,6 +7154,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {# ชั่วโมง}}",
|
||||
"desktop_attention_title": "บนคอมพิวเตอร์เครื่องนี้"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "ชื่อของคุณ",
|
||||
"description": "ผู้คนจะเห็นชื่อนี้ข้างที่อยู่ของคุณเมื่อคุณส่งอีเมลถึงพวกเขา",
|
||||
"saved": "บันทึกชื่อของคุณแล้ว",
|
||||
"error": "บันทึกชื่อของคุณไม่ได้ ลองอีกครั้ง"
|
||||
},
|
||||
"addresses": {
|
||||
"description": "ที่อยู่ที่คุณใช้ส่งได้ แต่ละที่อยู่มีชื่อและลายเซ็นของตัวเอง",
|
||||
"label": "ที่อยู่ของคุณ",
|
||||
"count": "{count, plural, =0 {ยังไม่มีที่อยู่} other {# ที่อยู่}}",
|
||||
"manage": "แก้ไขที่อยู่…",
|
||||
"modal_title": "ที่อยู่อีเมลของคุณ",
|
||||
"add": "เพิ่มที่อยู่",
|
||||
"edit": "แก้ไขที่อยู่",
|
||||
"added": "เพิ่มที่อยู่แล้ว",
|
||||
"saved": "บันทึกที่อยู่แล้ว",
|
||||
"deleted": "ลบที่อยู่แล้ว",
|
||||
"delete_title": "ลบที่อยู่นี้หรือไม่"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "ลายเซ็น",
|
||||
"description": "ใส่ไว้ท้ายข้อความที่คุณเขียน แต่ละที่อยู่มีลายเซ็นของตัวเอง",
|
||||
"saved": "บันทึกลายเซ็นแล้ว",
|
||||
"error": "บันทึกลายเซ็นไม่ได้ ลองอีกครั้ง",
|
||||
"no_addresses": "ยังไม่มีที่อยู่สำหรับส่ง"
|
||||
},
|
||||
"search": {
|
||||
"addresses": "ข้อมูลประจำตัว นามแฝง ผู้ส่ง ที่อยู่ผู้ส่ง ที่อยู่จาก identity alias sender",
|
||||
"signatures": "ลายเซ็น ลายเซ็นอีเมล ท้ายกระดาษ ผู้ส่ง ที่อยู่จาก signature",
|
||||
"your_name": "ชื่อที่แสดง ชื่อเต็ม ชื่อผู้ส่ง display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
|
||||
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# saat} other {# saat}}",
|
||||
"desktop_attention_title": "Bu bilgisayarda"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Adınız",
|
||||
"description": "Birine e-posta gönderdiğinizde bu adı adresinizin yanında görür.",
|
||||
"saved": "Adınız kaydedildi.",
|
||||
"error": "Adınız kaydedilemedi. Yeniden deneyin."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Gönderebileceğiniz adresler; her birinin kendi adı ve imzası var.",
|
||||
"label": "Adresleriniz",
|
||||
"count": "{count, plural, =0 {Henüz adres yok} one {# adres} other {# adres}}",
|
||||
"manage": "Adresleri düzenle…",
|
||||
"modal_title": "E-posta adresleriniz",
|
||||
"add": "Adres ekle",
|
||||
"edit": "Adresi düzenle",
|
||||
"added": "Adres eklendi.",
|
||||
"saved": "Adres kaydedildi.",
|
||||
"deleted": "Adres silindi.",
|
||||
"delete_title": "Bu adres silinsin mi?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "İmzalar",
|
||||
"description": "Yazdığınız iletilerin sonuna eklenir. Her adresin kendi imzası vardır.",
|
||||
"saved": "İmza kaydedildi.",
|
||||
"error": "İmza kaydedilemedi. Yeniden deneyin.",
|
||||
"no_addresses": "Henüz gönderebileceğiniz bir adres yok."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "kimlik kimlikler takma ad gönderen gönderen adresi kimden adresi identity alias sender",
|
||||
"signatures": "imza e-posta imzası alt bilgi kimlik gönderen kimden adresi signature",
|
||||
"your_name": "görünen ad tam ad gönderen adı display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Önce sunucu adresini girin.",
|
||||
"server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, one {# година} few {# години} many {# годин} other {# години}}",
|
||||
"desktop_attention_title": "На цьому комп'ютері"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Ваше ім'я",
|
||||
"description": "Це ім'я люди бачать поруч із вашою адресою, коли ви їм пишете.",
|
||||
"saved": "Ваше ім'я збережено.",
|
||||
"error": "Не вдалося зберегти ваше ім'я. Спробуйте ще раз."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Адреси, з яких ви можете надсилати пошту, кожна зі своїм ім'ям і підписом.",
|
||||
"label": "Ваші адреси",
|
||||
"count": "{count, plural, =0 {Адрес поки немає} one {# адреса} few {# адреси} many {# адрес} other {# адреси}}",
|
||||
"manage": "Змінити адреси…",
|
||||
"modal_title": "Ваші адреси електронної пошти",
|
||||
"add": "Додати адресу",
|
||||
"edit": "Змінити адресу",
|
||||
"added": "Адресу додано.",
|
||||
"saved": "Адресу збережено.",
|
||||
"deleted": "Адресу видалено.",
|
||||
"delete_title": "Видалити цю адресу?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Підписи",
|
||||
"description": "Додається в кінці ваших листів. Кожна адреса має свій.",
|
||||
"saved": "Підпис збережено.",
|
||||
"error": "Не вдалося зберегти підпис. Спробуйте ще раз.",
|
||||
"no_addresses": "Поки немає адреси, з якої можна надсилати."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "ідентичність особа псевдонім аліас відправник адреса відправника адреса від identity alias sender",
|
||||
"signatures": "підпис підпис листа нижній колонтитул відправник адреса від signature",
|
||||
"your_name": "відображуване ім'я повне ім'я ім'я відправника display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Спочатку введіть адресу сервера.",
|
||||
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
|
||||
|
||||
@@ -7158,6 +7158,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {# giờ}}",
|
||||
"desktop_attention_title": "Trên máy tính này"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "Tên của bạn",
|
||||
"description": "Mọi người thấy tên này cạnh địa chỉ của bạn khi bạn gửi thư cho họ.",
|
||||
"saved": "Đã lưu tên của bạn.",
|
||||
"error": "Không lưu được tên của bạn. Hãy thử lại."
|
||||
},
|
||||
"addresses": {
|
||||
"description": "Các địa chỉ bạn có thể gửi thư, mỗi địa chỉ có tên và chữ ký riêng.",
|
||||
"label": "Địa chỉ của bạn",
|
||||
"count": "{count, plural, =0 {Chưa có địa chỉ nào} other {# địa chỉ}}",
|
||||
"manage": "Sửa địa chỉ…",
|
||||
"modal_title": "Địa chỉ email của bạn",
|
||||
"add": "Thêm địa chỉ",
|
||||
"edit": "Sửa địa chỉ",
|
||||
"added": "Đã thêm địa chỉ.",
|
||||
"saved": "Đã lưu địa chỉ.",
|
||||
"deleted": "Đã xóa địa chỉ.",
|
||||
"delete_title": "Xóa địa chỉ này?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Chữ ký",
|
||||
"description": "Được thêm vào cuối thư bạn viết. Mỗi địa chỉ có chữ ký riêng.",
|
||||
"saved": "Đã lưu chữ ký.",
|
||||
"error": "Không lưu được chữ ký. Hãy thử lại.",
|
||||
"no_addresses": "Chưa có địa chỉ nào để gửi."
|
||||
},
|
||||
"search": {
|
||||
"addresses": "danh tính bí danh người gửi địa chỉ người gửi địa chỉ gửi từ identity alias sender",
|
||||
"signatures": "chữ ký chữ ký email chân trang danh tính người gửi địa chỉ gửi từ signature",
|
||||
"your_name": "tên hiển thị họ tên tên người gửi display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.",
|
||||
"server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {# 小時}}",
|
||||
"desktop_attention_title": "在這台電腦上"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "你的名稱",
|
||||
"description": "你寄信給別人時,對方會在你的地址旁看到這個名稱。",
|
||||
"saved": "你的名稱已儲存。",
|
||||
"error": "無法儲存你的名稱。請再試一次。"
|
||||
},
|
||||
"addresses": {
|
||||
"description": "你可以用來寄信的地址,每個地址都有自己的名稱和簽名。",
|
||||
"label": "你的地址",
|
||||
"count": "{count, plural, =0 {還沒有地址} other {# 個地址}}",
|
||||
"manage": "編輯地址…",
|
||||
"modal_title": "你的電子郵件地址",
|
||||
"add": "新增地址",
|
||||
"edit": "編輯地址",
|
||||
"added": "已新增地址。",
|
||||
"saved": "已儲存地址。",
|
||||
"deleted": "已刪除地址。",
|
||||
"delete_title": "要刪除這個地址嗎?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "簽名",
|
||||
"description": "加在你所寫郵件的結尾。每個地址都有自己的簽名。",
|
||||
"saved": "已儲存簽名。",
|
||||
"error": "無法儲存簽名。請再試一次。",
|
||||
"no_addresses": "還沒有可以寄信的地址。"
|
||||
},
|
||||
"search": {
|
||||
"addresses": "身分 別名 寄件者 寄件者地址 寄件地址 identity alias sender",
|
||||
"signatures": "簽名 郵件簽名 頁尾 身分 寄件者 寄件地址 signature",
|
||||
"your_name": "顯示名稱 全名 寄件者名稱 display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "請先輸入伺服器位址。",
|
||||
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
|
||||
|
||||
@@ -7146,6 +7146,39 @@
|
||||
"app_lock_timeout_hours": "{count, plural, other {# 小时}}",
|
||||
"desktop_attention_title": "在这台电脑上"
|
||||
},
|
||||
"uxs2_ident": {
|
||||
"your_name": {
|
||||
"label": "你的名字",
|
||||
"description": "你给别人发邮件时,对方会在你的地址旁看到这个名字。",
|
||||
"saved": "你的名字已保存。",
|
||||
"error": "无法保存你的名字。请重试。"
|
||||
},
|
||||
"addresses": {
|
||||
"description": "你可以用来发信的地址,每个地址都有自己的名字和签名。",
|
||||
"label": "你的地址",
|
||||
"count": "{count, plural, =0 {还没有地址} other {# 个地址}}",
|
||||
"manage": "编辑地址…",
|
||||
"modal_title": "你的电子邮件地址",
|
||||
"add": "添加地址",
|
||||
"edit": "编辑地址",
|
||||
"added": "地址已添加。",
|
||||
"saved": "地址已保存。",
|
||||
"deleted": "地址已删除。",
|
||||
"delete_title": "删除这个地址?"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "签名",
|
||||
"description": "添加在你所写邮件的末尾。每个地址都有自己的签名。",
|
||||
"saved": "签名已保存。",
|
||||
"error": "无法保存签名。请重试。",
|
||||
"no_addresses": "还没有可以发信的地址。"
|
||||
},
|
||||
"search": {
|
||||
"addresses": "身份 别名 发件人 发件人地址 发件地址 identity alias sender",
|
||||
"signatures": "签名 邮件签名 页脚 身份 发件人 发件地址 signature",
|
||||
"your_name": "显示名称 全名 发件人姓名 display name"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"server_needed": "请先输入服务器地址。",
|
||||
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
|
||||
|
||||
@@ -32,35 +32,39 @@ function seedAccount(displayName = 'Old Name', label = displayName): string {
|
||||
}
|
||||
|
||||
// Issue #900: the registry entry is written once by addAccount and never
|
||||
// refreshed, so a Stalwart admin renaming the user ("Full name") was never
|
||||
// reflected in the account switcher / settings header.
|
||||
describe('syncAccountDisplayName (#900)', () => {
|
||||
// refreshed, so a renamed user kept the old name in the account switcher.
|
||||
// settings-p2-2: the name that counts is the primary identity's, the one
|
||||
// recipients see and Settings > Account edits. Stalwart's principal
|
||||
// description is only the fallback for an account without one.
|
||||
describe('syncAccountDisplayName (#900, settings-p2-2)', () => {
|
||||
beforeEach(() => {
|
||||
mockedPrincipal.mockReset();
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||
});
|
||||
|
||||
it('replaces the cached name with the live Stalwart principal name', async () => {
|
||||
it('takes the identity name over the Stalwart principal description', async () => {
|
||||
const id = seedAccount('Old Name');
|
||||
mockedPrincipal.mockResolvedValue('Admin note (throwaway)');
|
||||
|
||||
await syncAccountDisplayName(id, client, 'Sam Settings');
|
||||
|
||||
const account = useAccountStore.getState().getAccountById(id)!;
|
||||
expect(account.displayName).toBe('Sam Settings');
|
||||
expect(account.label).toBe('Sam Settings');
|
||||
expect(mockedPrincipal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the principal name when the identity has no name', async () => {
|
||||
const id = seedAccount('Old Name');
|
||||
mockedPrincipal.mockResolvedValueOnce('New Name');
|
||||
|
||||
await syncAccountDisplayName(id, client, 'Old Name');
|
||||
await syncAccountDisplayName(id, client, ' ');
|
||||
|
||||
const account = useAccountStore.getState().getAccountById(id)!;
|
||||
expect(account.displayName).toBe('New Name');
|
||||
expect(account.label).toBe('New Name');
|
||||
expect(mockedPrincipal).toHaveBeenCalledWith(client, account.cookieSlot);
|
||||
});
|
||||
|
||||
it('falls back to the identity name when the principal is unavailable', async () => {
|
||||
const id = seedAccount('Old Name');
|
||||
mockedPrincipal.mockResolvedValueOnce(null);
|
||||
|
||||
await syncAccountDisplayName(id, client, 'Identity Name');
|
||||
|
||||
expect(useAccountStore.getState().getAccountById(id)!.displayName).toBe('Identity Name');
|
||||
});
|
||||
|
||||
it('keeps the cached name when neither source yields one', async () => {
|
||||
const id = seedAccount('Old Name');
|
||||
mockedPrincipal.mockResolvedValueOnce(null);
|
||||
@@ -70,12 +74,30 @@ describe('syncAccountDisplayName (#900)', () => {
|
||||
expect(useAccountStore.getState().getAccountById(id)!.displayName).toBe('Old Name');
|
||||
});
|
||||
|
||||
it('leaves a diverged label alone', async () => {
|
||||
const id = seedAccount('Old Name', 'Work');
|
||||
it('keeps the name when the caller does not know the identities', async () => {
|
||||
const id = seedAccount('Sam Settings');
|
||||
mockedPrincipal.mockResolvedValue('Admin note (throwaway)');
|
||||
|
||||
await syncAccountDisplayName(id, client);
|
||||
|
||||
expect(useAccountStore.getState().getAccountById(id)!.displayName).toBe('Sam Settings');
|
||||
expect(mockedPrincipal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names an unnamed account from the principal when the identities are unknown', async () => {
|
||||
const id = seedAccount('');
|
||||
mockedPrincipal.mockResolvedValueOnce('New Name');
|
||||
|
||||
await syncAccountDisplayName(id, client);
|
||||
|
||||
expect(useAccountStore.getState().getAccountById(id)!.displayName).toBe('New Name');
|
||||
});
|
||||
|
||||
it('leaves a diverged label alone', async () => {
|
||||
const id = seedAccount('Old Name', 'Work');
|
||||
|
||||
await syncAccountDisplayName(id, client, 'New Name');
|
||||
|
||||
const account = useAccountStore.getState().getAccountById(id)!;
|
||||
expect(account.displayName).toBe('New Name');
|
||||
expect(account.label).toBe('Work');
|
||||
@@ -86,9 +108,8 @@ describe('syncAccountDisplayName (#900)', () => {
|
||||
expect(mockedPrincipal).not.toHaveBeenCalled();
|
||||
|
||||
const id = seedAccount('Same');
|
||||
mockedPrincipal.mockResolvedValueOnce('Same');
|
||||
const before = useAccountStore.getState().getAccountById(id);
|
||||
await syncAccountDisplayName(id, client);
|
||||
await syncAccountDisplayName(id, client, 'Same');
|
||||
expect(useAccountStore.getState().getAccountById(id)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-6
@@ -295,9 +295,9 @@ function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'i
|
||||
*
|
||||
* The name is captured once by `addAccount` (a no-op for an existing entry),
|
||||
* so without this an account keeps whatever name it had at first login
|
||||
* forever. On Stalwart the identity name itself is a one-time snapshot of the
|
||||
* principal "Full name", so the live value has to come from the principal;
|
||||
* elsewhere the primary identity name is the best available source.
|
||||
* forever. The primary identity's name wins: it is what recipients see and
|
||||
* what Settings > Account edits. The Stalwart principal name is only the
|
||||
* fallback for an account without one.
|
||||
*
|
||||
* Best-effort and never throws: a failed lookup keeps the cached name.
|
||||
*/
|
||||
@@ -309,9 +309,16 @@ export async function syncAccountDisplayName(
|
||||
const account = useAccountStore.getState().getAccountById(accountId);
|
||||
if (!account) return;
|
||||
|
||||
const name = (await fetchPrincipalDisplayName(client, account.cookieSlot))
|
||||
|| fallbackName?.trim()
|
||||
|| '';
|
||||
// The name people see on sent mail is the sending identity's, and that is
|
||||
// the one name Settings lets them change (settings-p2-2). Stalwart's
|
||||
// principal description ("Full name") is often an admin's note, so it only
|
||||
// names an account that has no identity name. A caller that doesn't know
|
||||
// the identities keeps the name the account already has.
|
||||
let name = fallbackName?.trim() || '';
|
||||
if (!name) {
|
||||
if (fallbackName === undefined && account.displayName) return;
|
||||
name = (await fetchPrincipalDisplayName(client, account.cookieSlot)) || '';
|
||||
}
|
||||
if (!name) return;
|
||||
|
||||
// Re-read: the entry may have changed (or been removed) during the request.
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function createIdentityWithHooks(client: IJMAPClient, data: Identit
|
||||
export async function updateIdentityWithHooks(
|
||||
client: IJMAPClient,
|
||||
identity: Identity,
|
||||
updates: Omit<IdentityInput, 'email'>,
|
||||
updates: Partial<Omit<IdentityInput, 'email'>>,
|
||||
): Promise<boolean> {
|
||||
const next = identityToView({ ...identity, ...updates } as Identity);
|
||||
if (!(await identityHooks.onBeforeIdentityChange.intercept({ identityId: identity.id, change: 'update', identity: next }))) {
|
||||
@@ -193,6 +193,22 @@ export async function updateIdentityWithHooks(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change some fields of one identity (the Account page's name, a signature
|
||||
* on Composing) and keep the local list in step, without reloading every
|
||||
* identity. Callers then run the auth store's syncIdentities so the From
|
||||
* line and the account switcher follow.
|
||||
*/
|
||||
export async function saveIdentityChanges(
|
||||
client: IJMAPClient,
|
||||
identity: Identity,
|
||||
updates: Partial<Omit<IdentityInput, 'email'>>,
|
||||
): Promise<boolean> {
|
||||
if (!(await updateIdentityWithHooks(client, identity, updates))) return false;
|
||||
useIdentityStore.getState().updateIdentityLocal(identity.id, updates as Partial<Identity>);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteIdentityWithHooks(client: IJMAPClient, identity: Identity): Promise<boolean> {
|
||||
if (!(await identityHooks.onBeforeIdentityChange.intercept({
|
||||
identityId: identity.id, change: 'delete', identity: identityToView(identity),
|
||||
|
||||
Reference in New Issue
Block a user