mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Merge branch 'ux/contacts-files2'
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AddressBookPickerDialog } from '../address-book-picker-dialog';
|
||||
import type { AddressBook } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl is mocked globally (t returns the key).
|
||||
const books = [
|
||||
{ id: 'b1', name: 'Personal' },
|
||||
{ id: 'b2', name: 'Work' },
|
||||
] as unknown as AddressBook[];
|
||||
|
||||
describe('AddressBookPickerDialog', () => {
|
||||
it('says the contacts are being moved, not added', () => {
|
||||
render(<AddressBookPickerDialog isOpen onClose={vi.fn()} addressBooks={books} currentId="b1" count={1} onPick={vi.fn()} />);
|
||||
expect(screen.getByText('bulk.moving_contacts')).toBeInTheDocument();
|
||||
expect(screen.queryByText('bulk.adding_contacts')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ContactForm } from '../contact-form';
|
||||
import { ContactGroupForm } from '../contact-group-form';
|
||||
import type { ContactCard, ContactMedia } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { findContactPhotoEntry } from '@/stores/contact-store';
|
||||
@@ -86,6 +87,43 @@ describe('ContactForm', () => {
|
||||
expect(savedData.media).toBeNull();
|
||||
});
|
||||
|
||||
it('adds a category typed without Enter when the contact is saved, in the spelling of an existing one', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm contact={existingContact} allKeywords={['Friends']} onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('categories'));
|
||||
fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'friends, Gym ' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
|
||||
expect(onSave.mock.calls[0][0].keywords).toEqual({ Friends: true, Gym: true });
|
||||
});
|
||||
|
||||
it('counts a typed category as a change, so leaving asks first', () => {
|
||||
const onDirtyChange = vi.fn();
|
||||
render(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} onDirtyChange={onDirtyChange} />);
|
||||
fireEvent.click(screen.getByText('categories'));
|
||||
fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'Gym' } });
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['contact form', () => <ContactForm onSave={vi.fn()} onCancel={vi.fn()} />],
|
||||
['group form', () => <ContactGroupForm individuals={[]} onSave={vi.fn()} onCancel={vi.fn()} />],
|
||||
])('keeps toasts off the %s footer by registering it as a bottom bar', (_name, form) => {
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ left: 0, top: window.innerHeight - 72, width: 600, height: 72, right: 600, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect,
|
||||
);
|
||||
try {
|
||||
const { unmount } = render(form());
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('72px');
|
||||
unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
|
||||
} finally {
|
||||
rect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('photos (P0-3)', () => {
|
||||
// The same PHOTO as ContactCard/get returns it on each version.
|
||||
const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactListItem } from '../contact-list-item';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
@@ -82,4 +82,24 @@ describe('ContactListItem', () => {
|
||||
const { container } = render(<ContactListItem contact={contact} {...baseProps} density="extra-compact" />);
|
||||
expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the row menu on long-press without leaking the press state onto the row', () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onLongPress = vi.fn();
|
||||
const { container } = render(<ContactListItem contact={contact} {...baseProps} onLongPress={onLongPress} />);
|
||||
const row = container.firstElementChild!;
|
||||
const touchStart = new Event('touchstart', { bubbles: true });
|
||||
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
|
||||
act(() => { row.dispatchEvent(touchStart); });
|
||||
act(() => { vi.advanceTimersByTime(600); });
|
||||
expect(onLongPress).toHaveBeenCalledWith(row);
|
||||
const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed')));
|
||||
expect(unknownProp).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ function getRegisteredGroups(): CommandGroup[] {
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { interceptLeave } from '@/lib/leave-guard';
|
||||
|
||||
export const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
@@ -131,6 +132,44 @@ describe('ContactsApp', () => {
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
|
||||
});
|
||||
|
||||
it('adds the open contact to the selection on Ctrl+click, as Files does', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('Bob Jones'), { ctrlKey: true });
|
||||
expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-alice', 'c-bob']);
|
||||
});
|
||||
|
||||
it('selects from the open contact on Shift+click', () => {
|
||||
const carol: ContactCard = { ...bob, id: 'c-carol', name: { full: 'Carol King' }, emails: { e0: { address: 'carol@example.com' } } };
|
||||
renderContacts([alice, bob, carol]);
|
||||
fireEvent.click(screen.getByText('Bob Jones'));
|
||||
fireEvent.click(screen.getByText('Carol King'), { shiftKey: true });
|
||||
expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']);
|
||||
});
|
||||
|
||||
it('has the rail and tab bar ask before they leave a contact form with changes', async () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
|
||||
|
||||
// A rail link wired as the real one is: interceptLeave first.
|
||||
const navigated = vi.fn();
|
||||
const link = document.createElement('a');
|
||||
link.addEventListener('click', (e) => { if (!interceptLeave(e)) navigated(); });
|
||||
document.body.appendChild(link);
|
||||
try {
|
||||
act(() => { link.click(); });
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument();
|
||||
expect(navigated).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'form.discard' }));
|
||||
await waitFor(() => expect(navigated).toHaveBeenCalledOnce());
|
||||
} finally {
|
||||
link.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an untouched contact form without asking', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The delete confirms must name what goes (R3), so this file's t() shows the
|
||||
// values it was given: "delete_named_title {"name":"Alice Smith"}".
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
const bob: ContactCard = {
|
||||
id: 'c-bob',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
emails: { e0: { address: 'bob@example.com' } },
|
||||
};
|
||||
const team = {
|
||||
id: 'g-team',
|
||||
kind: 'group',
|
||||
addressBookIds: {},
|
||||
name: { full: 'Team' },
|
||||
members: {},
|
||||
} as unknown as ContactCard;
|
||||
|
||||
const FAKE_CLIENT = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
|
||||
function renderContacts(selected: string[] = []) {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
|
||||
useContactStore.setState({
|
||||
contacts: [alice, bob, team],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
selectedContactIds: new Set(selected),
|
||||
searchQuery: '',
|
||||
supportsSync: false,
|
||||
contactsLoaded: true,
|
||||
});
|
||||
});
|
||||
return render(<ContactsApp />);
|
||||
}
|
||||
|
||||
describe('Contacts delete confirms', () => {
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
|
||||
});
|
||||
});
|
||||
|
||||
it('names the contact and says what is lost', async () => {
|
||||
renderContacts();
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.delete'));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('delete_named_title {"name":"Alice Smith"}')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts the contacts when several are selected', async () => {
|
||||
renderContacts(['c-alice', 'c-bob']);
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'bulk.delete' }));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('bulk.delete_confirm {"count":2}')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the group and says its contacts are kept', async () => {
|
||||
renderContacts();
|
||||
fireEvent.contextMenu(screen.getAllByText('Team')[0]);
|
||||
fireEvent.click(screen.getByText('groups.delete'));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('delete_named_title {"name":"Team"}')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('groups.delete_message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,20 @@ function renderSidebar(onComposeGroup = vi.fn()) {
|
||||
return onComposeGroup;
|
||||
}
|
||||
|
||||
// First in the file: React warns about an unknown DOM prop only once per run.
|
||||
describe('ContactsSidebar — row props', () => {
|
||||
it('keeps the long-press state off the row element', () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
renderSidebar();
|
||||
const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed')));
|
||||
expect(unknownProp).toBe(false);
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactsSidebar — compose to group', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -45,9 +59,9 @@ describe('ContactsSidebar — compose to group', () => {
|
||||
renderSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||
// and the existing Edit/Delete entries still render
|
||||
// and the existing Edit/Delete entries still render, Delete named as in the group's own ⋮
|
||||
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||
expect(screen.getByText('groups.delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("address_books.move_title")}
|
||||
description={t("bulk.adding_contacts", { count })}
|
||||
description={t("bulk.moving_contacts", { count })}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!choice) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
|
||||
import type { ContactSaveOptions } from "@/lib/contact-media";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
@@ -398,6 +399,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const [keywordsStr, setKeywordsStr] = useState(
|
||||
contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : ""
|
||||
);
|
||||
// Text typed in the Categories field but not added with Enter yet: Save
|
||||
// adds it, so it isn't lost.
|
||||
const [categoryDraft, setCategoryDraft] = useState("");
|
||||
|
||||
const [note, setNote] = useState(
|
||||
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
||||
@@ -455,11 +459,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const fields = JSON.stringify([
|
||||
prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization,
|
||||
department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries,
|
||||
keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
|
||||
keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
|
||||
]);
|
||||
const [initialFields] = useState(fields);
|
||||
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
const footerRef = useBottomControls();
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -614,11 +619,14 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
});
|
||||
|
||||
const keywordsMap: Record<string, boolean> = {};
|
||||
if (keywordsStr.trim()) {
|
||||
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
|
||||
keywordsMap[k] = true;
|
||||
});
|
||||
}
|
||||
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
|
||||
keywordsMap[k] = true;
|
||||
});
|
||||
// A typed category that matches an existing one takes its spelling.
|
||||
categoryDraft.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
|
||||
const existing = (allKeywords || []).find(kw => kw.toLowerCase() === k.toLowerCase());
|
||||
keywordsMap[existing ?? k] = true;
|
||||
});
|
||||
|
||||
// Only send `kind` when this form owns the answer: switching a card between
|
||||
// person and organization. Leave other kinds (group, location, ...) untouched.
|
||||
@@ -696,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
<div className="@container px-6 py-4 max-w-3xl">
|
||||
{error && (
|
||||
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md mb-4">
|
||||
{error}
|
||||
@@ -816,10 +824,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
{/* Two columns in a narrow pane (title and first name, last name and
|
||||
suffix), one row once there is room, so no name gets cut. */}
|
||||
<div className="grid grid-cols-2 @lg:grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="@lg:w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("given_name")}</label>
|
||||
@@ -831,7 +841,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="@lg:w-20" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Either name is enough (or an organization), so neither is starred. */}
|
||||
@@ -1126,6 +1136,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
inputValue={categoryDraft}
|
||||
onInputChange={setCategoryDraft}
|
||||
allKeywords={allKeywords || []}
|
||||
placeholder={t("categories_placeholder")}
|
||||
hint={t("categories_hint")}
|
||||
@@ -1186,7 +1198,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
|
||||
{/* Toasts sit above Save and Cancel, not on them. */}
|
||||
<div ref={footerRef} className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
@@ -1201,6 +1214,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
function CategoryComboBox({
|
||||
keywordsStr,
|
||||
onChange,
|
||||
inputValue,
|
||||
onInputChange: setInputValue,
|
||||
allKeywords,
|
||||
placeholder,
|
||||
hint,
|
||||
@@ -1208,6 +1223,9 @@ function CategoryComboBox({
|
||||
}: {
|
||||
keywordsStr: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The text typed but not added yet; the form adds it on Save. */
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
allKeywords: string[];
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
@@ -1215,7 +1233,6 @@ function CategoryComboBox({
|
||||
}) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse current keywords from comma-separated string
|
||||
@@ -1243,7 +1260,7 @@ function CategoryComboBox({
|
||||
const next = [...currentKeywords, trimmed].join(", ");
|
||||
onChange(next);
|
||||
setInputValue("");
|
||||
}, [currentKeywords, onChange]);
|
||||
}, [currentKeywords, onChange, setInputValue]);
|
||||
|
||||
const removeKeyword = useCallback((keyword: string) => {
|
||||
const next = currentKeywords.filter(k => k !== keyword).join(", ");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function ContactGroupForm({
|
||||
const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") }));
|
||||
const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members;
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
const footerRef = useBottomControls();
|
||||
|
||||
const filteredIndividuals = useMemo(() => {
|
||||
if (!memberSearch) return individuals;
|
||||
@@ -195,7 +197,8 @@ export function ContactGroupForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
{/* Toasts sit above Save and Cancel, not on them. */}
|
||||
<div ref={footerRef} className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
{...(onLongPress ? longPress : {})}
|
||||
{...(onLongPress ? longPress.handlers : {})}
|
||||
role="option"
|
||||
aria-selected={isSelected || isChecked}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
|
||||
@@ -720,11 +720,17 @@ export function ContactList({
|
||||
density={density}
|
||||
selectedContactIds={selectedContactIds}
|
||||
onClick={(e) => {
|
||||
// The open contact counts as selected, as a clicked file does
|
||||
// in Files: Ctrl/⌘+click and Shift+click build on it.
|
||||
const startFromOpen = !hasSelection && !!selectedContactId
|
||||
&& selectedContactId !== contact.id && sortedIds.includes(selectedContactId);
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
if (startFromOpen) onToggleSelection(selectedContactId);
|
||||
onToggleSelection(contact.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (startFromOpen) onToggleSelection(selectedContactId);
|
||||
onSelectRangeContacts(contact.id, sortedIds);
|
||||
} else {
|
||||
if (hasSelection) onClearSelection();
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useLeaveGuard } from "@/lib/leave-guard";
|
||||
import { useDeleteCategory } from "@/hooks/use-delete-category";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
@@ -172,18 +173,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
useEffect(() => {
|
||||
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
|
||||
}, [view, setEditorDirty]);
|
||||
const askDiscard = useCallback(() => confirmDialog({
|
||||
title: t("form.discard_title"),
|
||||
message: t("form.discard_message"),
|
||||
confirmText: t("form.discard"),
|
||||
cancelText: t("form.keep_editing"),
|
||||
variant: "destructive",
|
||||
}), [confirmDialog, t]);
|
||||
const confirmDiscard = useCallback(async () => {
|
||||
if (!editorDirtyRef.current) return true;
|
||||
const discard = await confirmDialog({
|
||||
title: t("form.discard_title"),
|
||||
message: t("form.discard_message"),
|
||||
confirmText: t("form.discard"),
|
||||
cancelText: t("form.keep_editing"),
|
||||
variant: "destructive",
|
||||
});
|
||||
const discard = await askDiscard();
|
||||
if (discard) setEditorDirty(false);
|
||||
return discard;
|
||||
}, [confirmDialog, t, setEditorDirty]);
|
||||
}, [askDiscard, setEditorDirty]);
|
||||
// The rail and the phone tab bar ask too, before they leave Contacts. Only
|
||||
// ask there: leaving unmounts the form, and clearing it first would let the
|
||||
// Back guard below take its history step back over the navigation.
|
||||
useLeaveGuard(() => editorDirtyRef.current, askDiscard);
|
||||
const guard = <A extends unknown[]>(action: (...args: A) => unknown) => (...args: A): void => {
|
||||
if (!editorDirtyRef.current) {
|
||||
action(...args);
|
||||
@@ -529,14 +535,31 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
// R3: the confirm names what goes ("Delete Alice Smith?", "Delete 3
|
||||
// contacts?") and says what is lost with it.
|
||||
const confirmDeleteContacts = useCallback((ids: string[]) => {
|
||||
const only = ids.length === 1 ? contacts.find((c) => c.id === ids[0]) : undefined;
|
||||
const name = only ? getContactDisplayName(only).trim() : "";
|
||||
return confirmDialog({
|
||||
title: name ? t("delete_named_title", { name }) : t("bulk.delete_confirm", { count: ids.length }),
|
||||
message: t("delete_details_message"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}, [contacts, confirmDialog, t]);
|
||||
const confirmDeleteGroup = useCallback((groupId: string) => {
|
||||
const group = contacts.find((c) => c.id === groupId);
|
||||
const name = group ? getContactDisplayName(group).trim() : "";
|
||||
return confirmDialog({
|
||||
title: name ? t("delete_named_title", { name }) : t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_message"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}, [contacts, confirmDialog, t]);
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
if (!(await confirmDeleteContacts([contactId]))) return;
|
||||
|
||||
try {
|
||||
const deleted = supportsSync && client
|
||||
@@ -550,7 +573,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
}, [confirmDeleteContacts, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
@@ -773,13 +796,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}, [openComposeInApp]);
|
||||
|
||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
if (!(await confirmDeleteGroup(groupId))) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||
@@ -793,18 +810,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
console.error('Failed to delete group:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||
}, [confirmDeleteGroup, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return;
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
if (!(await confirmDeleteGroup(selectedGroup.id))) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
|
||||
@@ -855,13 +865,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedContactIds.size === 0) return;
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("bulk.delete_confirm_title"),
|
||||
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
|
||||
confirmText: t("bulk.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
if (!(await confirmDeleteContacts(Array.from(selectedContactIds)))) return;
|
||||
|
||||
try {
|
||||
if (!(await bulkDeleteContacts(
|
||||
|
||||
@@ -738,7 +738,7 @@ export function ContactsSidebar({
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("form.delete")}
|
||||
label={t("groups.delete")}
|
||||
onClick={() => {
|
||||
closeGroupContextMenu();
|
||||
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||
@@ -788,7 +788,7 @@ function SidebarRow({
|
||||
ref={rowRef}
|
||||
{...rowProps}
|
||||
{...dropProps}
|
||||
{...(onOpenMenu ? longPress : {})}
|
||||
{...(onOpenMenu ? longPress.handlers : {})}
|
||||
onContextMenu={onContextMenu}
|
||||
className={cn(
|
||||
// 32px rows, 44px on touch (R1).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
let appLocale = 'ar';
|
||||
vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale }));
|
||||
|
||||
import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts';
|
||||
|
||||
// The browser here is en-US; the app's language must win.
|
||||
describe('Files list dates and sizes', () => {
|
||||
afterEach(() => { appLocale = 'ar'; });
|
||||
|
||||
it('writes dates in the app language, not the browser one', () => {
|
||||
const now = new Date('2026-10-27T12:00:00Z');
|
||||
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/);
|
||||
expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/);
|
||||
appLocale = 'de';
|
||||
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/);
|
||||
});
|
||||
|
||||
it('keeps the number before the unit in a right-to-left layout', () => {
|
||||
const { container } = render(<div dir="rtl"><FileSize bytes={6861} /></div>);
|
||||
const size = container.querySelector('bdi')!;
|
||||
expect(size).toHaveAttribute('dir', 'ltr');
|
||||
expect(size.textContent).toBe('6.7 KB');
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,12 @@ import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import {
|
||||
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
|
||||
interface FileDetailsPanelProps {
|
||||
@@ -91,7 +91,7 @@ export function FileDetailsPanel({
|
||||
<p className="text-sm text-muted-foreground">{t("folder_summary", { folders, files })}</p>
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label={t("size")} value={formatFileSize(totalSize(items))} />
|
||||
<Row label={t("size")} value={<FileSize bytes={totalSize(items)} />} />
|
||||
{selection.length <= 1 && <Row label={t("path")} value={path} mono />}
|
||||
</dl>
|
||||
{selection.length <= 1 && (
|
||||
@@ -160,7 +160,7 @@ export function FileDetailsPanel({
|
||||
label={t("type")}
|
||||
value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))}
|
||||
/>
|
||||
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={formatFileSize(resource.contentLength)} />}
|
||||
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={<FileSize bytes={resource.contentLength} />} />}
|
||||
{resource.lastModified && <Row label={t("modified")} value={formatFullDate(resource.lastModified)} />}
|
||||
<Row label={t("sharing")} value={sharing} />
|
||||
<Row label={t("path")} value={path} mono />
|
||||
@@ -170,7 +170,7 @@ export function FileDetailsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Share2, Users } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { getEffectiveLocale } from "@/i18n/detect-locale";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons";
|
||||
|
||||
@@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A size that keeps its number before its unit ("6.7 KB") in a right-to-left
|
||||
* layout too, where the bidi algorithm would otherwise show "KB 6.7".
|
||||
*/
|
||||
export function FileSize({ bytes }: { bytes: number }) {
|
||||
return <bdi dir="ltr">{formatFileSize(bytes)}</bdi>;
|
||||
}
|
||||
|
||||
/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */
|
||||
function dateLocale(): { locale: string; hour12: boolean } {
|
||||
const locale = getEffectiveLocale();
|
||||
return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" };
|
||||
}
|
||||
|
||||
/** Full date and time, for tooltips and the details panel. */
|
||||
export function formatFullDate(value: string): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
const { locale, hour12 } = dateLocale();
|
||||
return new Date(value).toLocaleString(locale, {
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
@@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string {
|
||||
const timeZone = getEffectiveTimeZone();
|
||||
const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone });
|
||||
try {
|
||||
const { locale, hour12 } = dateLocale();
|
||||
if (dayKey(date) === dayKey(now)) {
|
||||
return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" });
|
||||
return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 });
|
||||
}
|
||||
const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" })
|
||||
=== now.toLocaleDateString("en-CA", { timeZone, year: "numeric" });
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return date.toLocaleDateString(locale, {
|
||||
timeZone,
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
|
||||
@@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
<span className="block truncate">{node.name}</span>
|
||||
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</span>
|
||||
<bdi dir="ltr" className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</bdi>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import {
|
||||
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
@@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{note && <>{note} · </>}
|
||||
{formatShortDate(resource.lastModified)}
|
||||
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
|
||||
{!resource.isDirectory && !resource.isSymlink && <> · <FileSize bytes={resource.contentLength} /></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
{!touch && formatShortDate(resource.lastModified)}
|
||||
</div>
|
||||
<div role="gridcell" className="hidden sm:block text-muted-foreground tabular-nums text-end">
|
||||
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))}
|
||||
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : <FileSize bytes={resource.contentLength} />)}
|
||||
</div>
|
||||
<div role="gridcell" className="flex items-center justify-end gap-0.5 w-[4.75rem]">
|
||||
{!touch && !resource.isDirectory && !resource.isSymlink && (
|
||||
@@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">
|
||||
{resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)}
|
||||
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
|
||||
</span>
|
||||
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// An editor with unsaved changes registers a leave guard; the rail and the
|
||||
// phone tab bar ask it before they leave the app (R5).
|
||||
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/contacts',
|
||||
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
|
||||
}));
|
||||
|
||||
const { NavigationRail } = await import('../navigation-rail');
|
||||
const { useUpdateStore } = await import('@/stores/update-store');
|
||||
const { registerLeaveGuard } = await import('@/lib/leave-guard');
|
||||
|
||||
let off: (() => void) | null = null;
|
||||
beforeEach(() => {
|
||||
useUpdateStore.setState({ startPolling: vi.fn() } as never);
|
||||
});
|
||||
afterEach(() => { off?.(); off = null; });
|
||||
|
||||
describe.each([
|
||||
['desktop rail', { orientation: 'vertical', collapsed: true }],
|
||||
['phone tab bar', { orientation: 'horizontal', collapsed: false }],
|
||||
] as const)('%s', (_name, props) => {
|
||||
it('asks before leaving an editor with unsaved changes, and stays on "Keep editing"', async () => {
|
||||
const confirm = vi.fn(async () => false);
|
||||
off = registerLeaveGuard({ isDirty: () => true, confirm });
|
||||
render(<NavigationRail {...props} />);
|
||||
const mail = screen.getAllByRole('link', { name: 'mail' })[0];
|
||||
const click = fireEvent.click(mail);
|
||||
// fireEvent returns false when the default (navigating) was prevented.
|
||||
expect(click).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves without asking when nothing is unsaved', () => {
|
||||
const confirm = vi.fn(async () => false);
|
||||
off = registerLeaveGuard({ isDirty: () => false, confirm });
|
||||
render(<NavigationRail {...props} />);
|
||||
fireEvent.click(screen.getAllByRole('link', { name: 'mail' })[0]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { interceptLeave } from "@/lib/leave-guard";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
@@ -351,6 +352,7 @@ export function NavigationRail({
|
||||
|
||||
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
|
||||
(e: React.MouseEvent) => {
|
||||
if (interceptLeave(e)) return;
|
||||
if (onNavigate) {
|
||||
const intercepted = onNavigate(itemId);
|
||||
if (intercepted !== false) {
|
||||
@@ -421,7 +423,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={`plugin-page:${page.pluginId}:${page.id}`}
|
||||
href={pluginPageHref(page)}
|
||||
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
|
||||
onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
title={page.pluginName}
|
||||
className={cn(
|
||||
@@ -634,7 +636,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={`plugin-page:${page.pluginId}:${page.id}`}
|
||||
href={pluginPageHref(page)}
|
||||
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
|
||||
onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
|
||||
@@ -22,4 +22,15 @@ describe('ContextMenu rows on a touch screen', () => {
|
||||
expect(row.className).not.toMatch(/(^|\s)min-h-11/);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides key hints under a coarse pointer only (R8)', () => {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
<ContextMenuItem label="Rename" shortcut="F2" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
const hint = screen.getByText('F2');
|
||||
expect(hint.className).toContain('pointer-coarse:hidden');
|
||||
expect(hint.className).not.toMatch(/(^|\s)hidden/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,13 @@ describe('toast layout', () => {
|
||||
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
|
||||
});
|
||||
|
||||
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
|
||||
render(<Toasts />);
|
||||
const container = screen.getByRole('status');
|
||||
expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]');
|
||||
expect(container).not.toHaveClass('bottom-5');
|
||||
});
|
||||
|
||||
it('names the close button and hides it on a phone', () => {
|
||||
render(<Toasts />);
|
||||
act(() => { toast.success('Saved'); });
|
||||
|
||||
@@ -560,8 +560,9 @@ export function ContextMenuItem({
|
||||
) : (
|
||||
<span className="flex-1">{label}</span>
|
||||
)}
|
||||
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
|
||||
{shortcut && (
|
||||
<span className="text-xs text-muted-foreground ms-auto">{shortcut}</span>
|
||||
<span className="text-xs text-muted-foreground ms-auto pointer-coarse:hidden">{shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -317,8 +317,9 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
|
||||
<div
|
||||
className={cn(
|
||||
// Newest at the bottom, at the end corner (bottom left in RTL);
|
||||
// lifted over an open composer's footer.
|
||||
"fixed bottom-5 end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
|
||||
// lifted over a registered bottom bar (a form's Save/Cancel footer)
|
||||
// and over an open composer's footer.
|
||||
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
|
||||
// Phone: full width with 12px margins, above the bottom controls
|
||||
// (the tab bar, or whichever bar registered with useBottomControls).
|
||||
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Bars pinned to the bottom of the screen on a phone (the tab bar, a
|
||||
* viewer's action bar) register here. While any is on screen,
|
||||
* Bars pinned to the bottom of the screen register here: on a phone the tab
|
||||
* bar and a viewer's action bar, on any screen an editor's Save/Cancel
|
||||
* footer. While any is on screen,
|
||||
* `--bottom-controls-height` on <html> says how far from the bottom of the
|
||||
* window the topmost of them reaches, and toasts sit above it.
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,14 @@ const STATE_KEY = "__closeOnBack";
|
||||
// would pop it and close the layer at once.
|
||||
let pendingHistoryBack: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Resolves once the Back a closed layer took to remove its history entry has
|
||||
* landed. A navigation started before then would be undone by it.
|
||||
*/
|
||||
export function historySettled(): Promise<void> {
|
||||
return pendingHistoryBack ?? Promise.resolve();
|
||||
}
|
||||
|
||||
const currentEntry = () => (window.history.state as Record<string, unknown> | null)?.[STATE_KEY];
|
||||
|
||||
interface UseCloseOnBackOptions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
const LONG_PRESS_DURATION = 500;
|
||||
const MOVE_THRESHOLD = 10;
|
||||
@@ -69,5 +69,12 @@ export function useLongPress(
|
||||
firedRef.current = false;
|
||||
}, [cancel]);
|
||||
|
||||
return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed };
|
||||
// `handlers` holds only the touch handlers, safe to spread onto a DOM
|
||||
// element; spreading the whole result would put `isPressed` on it.
|
||||
const handlers = useMemo(
|
||||
() => ({ onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }),
|
||||
[onTouchStart, onTouchEnd, onTouchMove, onTouchCancel]
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed, handlers };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DemoJMAPClient } from '@/lib/demo/demo-client';
|
||||
|
||||
// A move, its Undo and a restore from the trash are all parentId updates;
|
||||
// none of them may make a file look edited.
|
||||
describe('DemoJMAPClient files', () => {
|
||||
it('keeps a file\'s Modified time when it is moved or renamed', async () => {
|
||||
const client = new DemoJMAPClient();
|
||||
const folder = await client.createFileDirectory('Target', null);
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const original = nodes.find((n) => n.blobId != null && n.modified)!;
|
||||
const before = original.modified;
|
||||
|
||||
await client.updateFileNodes({ [original.id]: { parentId: folder.id } });
|
||||
await client.updateFileNode(original.id, { name: 'renamed.txt' });
|
||||
|
||||
const after = (await client.getFileNodes([original.id]))[0];
|
||||
expect(after.parentId).toBe(folder.id);
|
||||
expect(after.name).toBe('renamed.txt');
|
||||
expect(after.modified).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useCloseOnBack } from '@/hooks/use-close-on-back';
|
||||
import { confirmLeave, hasUnsavedChanges, interceptLeave, registerLeaveGuard } from '@/lib/leave-guard';
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { cleanups.splice(0).forEach((off) => off()); });
|
||||
|
||||
function guard(dirty: { value: boolean }, answer: boolean) {
|
||||
const confirm = vi.fn(async () => {
|
||||
if (answer) dirty.value = false;
|
||||
return answer;
|
||||
});
|
||||
cleanups.push(registerLeaveGuard({ isDirty: () => dirty.value, confirm }));
|
||||
return confirm;
|
||||
}
|
||||
|
||||
function link() {
|
||||
const a = document.createElement('a');
|
||||
const onClick = vi.fn((e: MouseEvent) => { interceptLeave(e as unknown as { preventDefault(): void; currentTarget: EventTarget | null }); });
|
||||
a.addEventListener('click', onClick);
|
||||
document.body.appendChild(a);
|
||||
cleanups.push(() => a.remove());
|
||||
return { a, onClick };
|
||||
}
|
||||
|
||||
describe('leave guard', () => {
|
||||
it('lets a click through when nothing is unsaved', () => {
|
||||
guard({ value: false }, true);
|
||||
const e = { preventDefault: vi.fn(), currentTarget: null };
|
||||
expect(interceptLeave(e)).toBe(false);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the click, asks, and clicks again once the changes are let go', async () => {
|
||||
const confirm = guard({ value: true }, true);
|
||||
const { a, onClick } = link();
|
||||
const click = new MouseEvent('click', { cancelable: true });
|
||||
a.dispatchEvent(click);
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
await vi.waitFor(() => expect(onClick).toHaveBeenCalledTimes(2));
|
||||
expect(hasUnsavedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays put when the user keeps editing', async () => {
|
||||
const confirm = guard({ value: true }, false);
|
||||
const { a, onClick } = link();
|
||||
a.dispatchEvent(new MouseEvent('click', { cancelable: true }));
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce());
|
||||
await Promise.resolve();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(hasUnsavedChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves only once the asking dialog has taken its history entry back off', async () => {
|
||||
// The confirm dialog holds a history entry while it is open (Back closes
|
||||
// it) and steps Back off it as it closes. Leaving before that Back lands
|
||||
// would be undone by it.
|
||||
const dialog = renderHook((props: { open: boolean }) => useCloseOnBack({ onBack: () => {}, active: props.open }), {
|
||||
initialProps: { open: true },
|
||||
});
|
||||
await vi.waitFor(() => expect((window.history.state as Record<string, unknown> | null)?.__closeOnBack).toBeTruthy());
|
||||
const order: string[] = [];
|
||||
window.addEventListener('popstate', () => order.push('back landed'), { once: true });
|
||||
cleanups.push(registerLeaveGuard({
|
||||
isDirty: () => true,
|
||||
confirm: async () => { dialog.rerender({ open: false }); return true; },
|
||||
}));
|
||||
const a = document.createElement('a');
|
||||
a.addEventListener('click', (e) => { if (!interceptLeave(e)) order.push('left'); });
|
||||
document.body.appendChild(a);
|
||||
cleanups.push(() => { a.remove(); dialog.unmount(); });
|
||||
a.click();
|
||||
await vi.waitFor(() => expect(order).toContain('left'));
|
||||
expect(order).toEqual(['back landed', 'left']);
|
||||
});
|
||||
|
||||
it('asks only the editors that have changes', async () => {
|
||||
const clean = guard({ value: false }, true);
|
||||
const dirty = guard({ value: true }, true);
|
||||
expect(await confirmLeave()).toBe(true);
|
||||
expect(clean).not.toHaveBeenCalled();
|
||||
expect(dirty).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1411,8 +1411,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||
// A rename or move leaves `modified` alone, as on Stalwart: it dates the
|
||||
// content, and a move or its Undo shouldn't make a file look edited.
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) Object.assign(node, updates, { modified: new Date().toISOString() });
|
||||
if (node) Object.assign(node, updates);
|
||||
}
|
||||
|
||||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||
@@ -1420,7 +1422,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
for (const [id, patch] of Object.entries(updates)) {
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) {
|
||||
Object.assign(node, patch, { modified: new Date().toISOString() });
|
||||
Object.assign(node, patch);
|
||||
updated.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { historySettled } from "@/hooks/use-close-on-back";
|
||||
|
||||
/**
|
||||
* Leave guard (R5): an editor with unsaved changes registers here, and the
|
||||
* shell's own navigation (the desktop rail, the phone tab bar) asks it
|
||||
* before leaving. The editor's in-app exits (Cancel, another item, Back)
|
||||
* ask through the editor's own code; this covers what the editor can't see.
|
||||
*/
|
||||
export interface LeaveGuard {
|
||||
/** True while there is something to lose. Read on every navigation. */
|
||||
isDirty: () => boolean;
|
||||
/**
|
||||
* Ask the user; resolves true when the changes may be dropped. Only ask:
|
||||
* the navigation that follows unmounts the editor, and clearing its state
|
||||
* first can run its own clean-up (a Back guard's history step) before the
|
||||
* navigation lands. The guard isn't asked again for that navigation.
|
||||
*/
|
||||
confirm: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const guards = new Set<LeaveGuard>();
|
||||
// Set while the guarded click is replayed, so it isn't stopped again.
|
||||
let replaying = false;
|
||||
let asking = false;
|
||||
|
||||
export function registerLeaveGuard(guard: LeaveGuard): () => void {
|
||||
guards.add(guard);
|
||||
return () => { guards.delete(guard); };
|
||||
}
|
||||
|
||||
export function hasUnsavedChanges(): boolean {
|
||||
for (const guard of guards) if (guard.isDirty()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Asks each editor with changes in turn; true when all of them may be left. */
|
||||
export async function confirmLeave(): Promise<boolean> {
|
||||
for (const guard of [...guards]) {
|
||||
if (guard.isDirty() && !(await guard.confirm())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the click handler of a link or button that leaves the current app:
|
||||
* when an editor has unsaved changes, stops the click, asks, and clicks the
|
||||
* same element again once the user lets the changes go. Returns true when
|
||||
* it stopped the click; the handler should then do nothing else.
|
||||
*/
|
||||
export function interceptLeave(e: { preventDefault(): void; currentTarget: EventTarget | null }): boolean {
|
||||
if (replaying || !hasUnsavedChanges()) return false;
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null;
|
||||
if (asking) return true;
|
||||
asking = true;
|
||||
void confirmLeave().then(async (leave) => {
|
||||
asking = false;
|
||||
if (!leave || !target) return;
|
||||
// The dialog that asked steps Back off its own history entry as it
|
||||
// closes; a navigation started before that lands is undone by it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await historySettled();
|
||||
replaying = true;
|
||||
try { target.click(); } finally { replaying = false; }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Registers the calling editor's guard while it is mounted. */
|
||||
export function useLeaveGuard(isDirty: () => boolean, confirm: () => Promise<boolean>): void {
|
||||
const latest = useRef({ isDirty, confirm });
|
||||
useEffect(() => { latest.current = { isDirty, confirm }; });
|
||||
useEffect(() => registerLeaveGuard({
|
||||
isDirty: () => latest.current.isDirty(),
|
||||
confirm: () => latest.current.confirm(),
|
||||
}), []);
|
||||
}
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "حاول تعديل المرشحات أو مسحها",
|
||||
"clear_search": "مسح البحث",
|
||||
"import_vcard": "استيراد vCard",
|
||||
"delete_confirm_title": "حذف جهة الاتصال",
|
||||
"delete_confirm": "هل أنت متأكد أنك تريد حذف جهة الاتصال هذه؟",
|
||||
"delete_named_title": "حذف {name}؟",
|
||||
"delete_details_message": "ستُحذف عناوين البريد الإلكتروني وأرقام الهواتف والملاحظات وكل التفاصيل الأخرى. لا يمكنك التراجع عن ذلك.",
|
||||
"local_mode": "تُخزَّن جهات الاتصال محليًا (الخادم لا يدعم جهات اتصال JMAP)",
|
||||
"back_to_contacts": "العودة إلى جهات الاتصال",
|
||||
"back_to_email": "العودة إلى البريد",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "تعديل المجموعة",
|
||||
"empty": "لا توجد مجموعات بعد",
|
||||
"delete_confirm_title": "حذف المجموعة",
|
||||
"delete_confirm": "هل أنت متأكد أنك تريد حذف هذه المجموعة؟",
|
||||
"delete_message": "ستُحذف المجموعة. تبقى جهات الاتصال التي فيها.",
|
||||
"name_label": "اسم المجموعة",
|
||||
"name_placeholder": "مثال: فريق، عائلة",
|
||||
"name_required": "اسم المجموعة مطلوب",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {تم تحديد واحدة} other {تم تحديد #}}",
|
||||
"select_all": "تحديد الكل",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف جهات الاتصال",
|
||||
"delete_confirm": "حذف {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}؟",
|
||||
"deleted": "{count, plural, one {تم حذف جهة اتصال واحدة} other {تم حذف # جهة اتصال}}",
|
||||
"add_to_group": "إضافة إلى مجموعة",
|
||||
"choose_group": "اختر مجموعة",
|
||||
"new_group": "مجموعة جديدة…",
|
||||
"adding_contacts": "جارٍ إضافة {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
|
||||
"moving_contacts": "جارٍ نقل {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
|
||||
"added_to_group": "تمت إضافة جهات الاتصال إلى المجموعة",
|
||||
"export": "تصدير",
|
||||
"clear": "إلغاء التحديد"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Proveu d'ajustar o netejar els filtres",
|
||||
"clear_search": "Neteja la cerca",
|
||||
"import_vcard": "Importa vCard",
|
||||
"delete_confirm_title": "Suprimeix el contacte",
|
||||
"delete_confirm": "Segur que voleu suprimir aquest contacte?",
|
||||
"delete_named_title": "Voleu suprimir {name}?",
|
||||
"delete_details_message": "Se suprimiran les adreces electròniques, els números de telèfon, les notes i la resta de dades. No es pot desfer.",
|
||||
"local_mode": "Els contactes es desen localment (el servidor no admet JMAP Contacts)",
|
||||
"back_to_contacts": "Torna als contactes",
|
||||
"back_to_email": "Torna al correu",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Edita el grup",
|
||||
"empty": "Encara no hi ha grups",
|
||||
"delete_confirm_title": "Suprimeix el grup",
|
||||
"delete_confirm": "Segur que voleu suprimir aquest grup?",
|
||||
"delete_message": "Se suprimirà el grup. Els contactes que conté es conserven.",
|
||||
"name_label": "Nom del grup",
|
||||
"name_placeholder": "p. ex., Equip, Família",
|
||||
"name_required": "El nom del grup és obligatori",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}",
|
||||
"select_all": "Selecciona-ho tot",
|
||||
"delete": "Suprimeix",
|
||||
"delete_confirm_title": "Suprimeix els contactes",
|
||||
"delete_confirm": "Voleu suprimir {count, plural, one {1 contacte} other {# contactes}}?",
|
||||
"deleted": "{count, plural, one {1 contacte suprimit} other {# contactes suprimits}}",
|
||||
"add_to_group": "Afegeix al grup",
|
||||
"choose_group": "Trieu un grup",
|
||||
"new_group": "Grup nou…",
|
||||
"adding_contacts": "Afegint {count, plural, one {1 contacte} other {# contactes}}",
|
||||
"moving_contacts": "Movent {count, plural, one {1 contacte} other {# contactes}}",
|
||||
"added_to_group": "Contactes afegits al grup",
|
||||
"export": "Exporta",
|
||||
"clear": "Neteja la selecció"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Zkuste filtry upravit nebo zrušit",
|
||||
"clear_search": "Vymazat hledání",
|
||||
"import_vcard": "Importovat vCard",
|
||||
"delete_confirm_title": "Odstranit kontakt",
|
||||
"delete_confirm": "Opravdu chcete odstranit tento kontakt?",
|
||||
"delete_named_title": "Odstranit {name}?",
|
||||
"delete_details_message": "Smažou se e-mailové adresy, telefonní čísla, poznámky a všechny další údaje. Tuto akci nelze vrátit.",
|
||||
"local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)",
|
||||
"back_to_contacts": "Zpět na kontakty",
|
||||
"back_to_email": "Zpět na e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Upravit skupinu",
|
||||
"empty": "Žádné skupiny",
|
||||
"delete_confirm_title": "Odstranit skupinu",
|
||||
"delete_confirm": "Opravdu chcete odstranit tuto skupinu?",
|
||||
"delete_message": "Skupina bude smazána. Kontakty v ní zůstanou zachovány.",
|
||||
"name_label": "Název skupiny",
|
||||
"name_placeholder": "např. Tým, Rodina",
|
||||
"name_required": "Název skupiny je vyžadován",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}",
|
||||
"select_all": "Vybrat vše",
|
||||
"delete": "Odstranit",
|
||||
"delete_confirm_title": "Odstranit kontakty",
|
||||
"delete_confirm": "Odstranit {count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}?",
|
||||
"deleted": "{count, plural, one {Odstraněn 1 kontakt} few {Odstraněny # kontakty} other {Odstraněno # kontaktů}}",
|
||||
"add_to_group": "Přidat do skupiny",
|
||||
"choose_group": "Vyberte skupinu",
|
||||
"new_group": "Nová skupina…",
|
||||
"adding_contacts": "Přidávání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}",
|
||||
"moving_contacts": "Přesouvání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}",
|
||||
"added_to_group": "Kontakty byly přidány do skupiny",
|
||||
"export": "Exportovat",
|
||||
"clear": "Zrušit výběr"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Prøv at justere eller rydde filtre",
|
||||
"clear_search": "Ryd søgning",
|
||||
"import_vcard": "Importér vCard",
|
||||
"delete_confirm_title": "Slet kontakt",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne kontakt?",
|
||||
"delete_named_title": "Slet {name}?",
|
||||
"delete_details_message": "E-mailadresser, telefonnumre, noter og alle andre oplysninger bliver slettet. Det kan ikke fortrydes.",
|
||||
"local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)",
|
||||
"back_to_contacts": "Tilbage til kontakter",
|
||||
"back_to_email": "Tilbage til e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Redigér gruppe",
|
||||
"empty": "Ingen grupper endnu",
|
||||
"delete_confirm_title": "Slet gruppe",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne gruppe?",
|
||||
"delete_message": "Gruppen bliver slettet. Kontakterne i den bevares.",
|
||||
"name_label": "Gruppenavn",
|
||||
"name_placeholder": "f.eks. Team, Familie",
|
||||
"name_required": "Gruppenavn er påkrævet",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 valgt} other {# valgte}}",
|
||||
"select_all": "Vælg alle",
|
||||
"delete": "Slet",
|
||||
"delete_confirm_title": "Slet kontakter",
|
||||
"delete_confirm": "Slet {count, plural, one {1 kontakt} other {# kontakter}}?",
|
||||
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
|
||||
"add_to_group": "Tilføj til gruppe",
|
||||
"choose_group": "Vælg en gruppe",
|
||||
"new_group": "Ny gruppe…",
|
||||
"adding_contacts": "Tilføjer {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"added_to_group": "Kontakter tilføjet til gruppe",
|
||||
"export": "Eksportér",
|
||||
"clear": "Ryd markering"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
|
||||
"clear_search": "Suche löschen",
|
||||
"import_vcard": "vCard importieren",
|
||||
"delete_confirm_title": "Kontakt löschen",
|
||||
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
|
||||
"delete_named_title": "{name} löschen?",
|
||||
"delete_details_message": "E-Mail-Adressen, Telefonnummern, Notizen und alle weiteren Angaben werden gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
|
||||
"back_to_contacts": "Zurück zu Kontakten",
|
||||
"back_to_email": "Zurück zur E-Mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Gruppe bearbeiten",
|
||||
"empty": "Keine Gruppen",
|
||||
"delete_confirm_title": "Gruppe löschen",
|
||||
"delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?",
|
||||
"delete_message": "Die Gruppe wird gelöscht. Die Kontakte darin bleiben erhalten.",
|
||||
"name_label": "Gruppenname",
|
||||
"name_placeholder": "z.B. Team, Familie",
|
||||
"name_required": "Gruppenname ist erforderlich",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
|
||||
"select_all": "Alle auswählen",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm_title": "Kontakte löschen",
|
||||
"delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?",
|
||||
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
|
||||
"add_to_group": "Zur Gruppe hinzufügen",
|
||||
"choose_group": "Gruppe wählen",
|
||||
"new_group": "Neue Gruppe…",
|
||||
"adding_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} hinzufügen",
|
||||
"moving_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} verschieben",
|
||||
"added_to_group": "Kontakte zur Gruppe hinzugefügt",
|
||||
"export": "Exportieren",
|
||||
"clear": "Auswahl aufheben"
|
||||
|
||||
+12
-12
@@ -2608,20 +2608,20 @@
|
||||
"description": "Configure how files and folders are displayed"
|
||||
},
|
||||
"default_view": {
|
||||
"label": "Default View",
|
||||
"label": "Default view",
|
||||
"description": "Choose between grid and list layout",
|
||||
"list": "List",
|
||||
"grid": "Grid"
|
||||
},
|
||||
"default_sort": {
|
||||
"label": "Default Sort",
|
||||
"label": "Default sort",
|
||||
"description": "Choose the default sorting for files",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified"
|
||||
},
|
||||
"sort_direction": {
|
||||
"label": "Sort Direction",
|
||||
"label": "Sort direction",
|
||||
"description": "Choose ascending or descending order",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
@@ -2631,15 +2631,15 @@
|
||||
"description": "Configure file icon appearance"
|
||||
},
|
||||
"show_icons": {
|
||||
"label": "Show File Icons",
|
||||
"label": "Show file icons",
|
||||
"description": "Display icons next to files and folders"
|
||||
},
|
||||
"colored_icons": {
|
||||
"label": "Colored Icons",
|
||||
"label": "Colored icons",
|
||||
"description": "Use colorful icons instead of monochrome"
|
||||
},
|
||||
"show_thumbnails": {
|
||||
"label": "Show Thumbnails",
|
||||
"label": "Show thumbnails",
|
||||
"description": "Display image previews instead of icons for image files"
|
||||
},
|
||||
"behavior": {
|
||||
@@ -2647,7 +2647,7 @@
|
||||
"description": "Configure file browser behavior"
|
||||
},
|
||||
"show_hidden": {
|
||||
"label": "Show Hidden Files",
|
||||
"label": "Show hidden files",
|
||||
"description": "Display files and folders that start with a dot"
|
||||
},
|
||||
"preview": {
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Try adjusting or clearing filters",
|
||||
"clear_search": "Clear search",
|
||||
"import_vcard": "Import vCard",
|
||||
"delete_confirm_title": "Delete contact",
|
||||
"delete_confirm": "Are you sure you want to delete this contact?",
|
||||
"delete_named_title": "Delete {name}?",
|
||||
"delete_details_message": "Email addresses, phone numbers, notes and all other details will be deleted. You can't undo this.",
|
||||
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
|
||||
"back_to_contacts": "Back to contacts",
|
||||
"back_to_email": "Back to email",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Edit group",
|
||||
"empty": "No groups yet",
|
||||
"delete_confirm_title": "Delete group",
|
||||
"delete_confirm": "Are you sure you want to delete this group?",
|
||||
"delete_message": "The group will be deleted. The contacts in it are kept.",
|
||||
"name_label": "Group name",
|
||||
"name_placeholder": "e.g., Team, Family",
|
||||
"name_required": "Group name is required",
|
||||
@@ -3531,7 +3531,7 @@
|
||||
"file_too_large": "File is too large (max 5 MB)"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Contacts",
|
||||
"title": "Export contacts",
|
||||
"success": "{count, plural, one {1 contact exported} other {# contacts exported}}",
|
||||
"exporting": "Exporting..."
|
||||
},
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 selected} other {# selected}}",
|
||||
"select_all": "Select all",
|
||||
"delete": "Delete",
|
||||
"delete_confirm_title": "Delete contacts",
|
||||
"delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?",
|
||||
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
|
||||
"add_to_group": "Add to group",
|
||||
"choose_group": "Choose a group",
|
||||
"new_group": "New group…",
|
||||
"adding_contacts": "Adding {count, plural, one {1 contact} other {# contacts}}",
|
||||
"moving_contacts": "Moving {count, plural, one {1 contact} other {# contacts}}",
|
||||
"added_to_group": "Contacts added to group",
|
||||
"export": "Export",
|
||||
"clear": "Clear selection"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Ajusta o limpia los filtros",
|
||||
"clear_search": "Borrar búsqueda",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Eliminar contacto",
|
||||
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
|
||||
"delete_named_title": "¿Eliminar {name}?",
|
||||
"delete_details_message": "Se eliminarán las direcciones de correo, los teléfonos, las notas y todos los demás datos. No se puede deshacer.",
|
||||
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
|
||||
"back_to_contacts": "Volver a contactos",
|
||||
"back_to_email": "Volver al correo",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Editar grupo",
|
||||
"empty": "No hay grupos",
|
||||
"delete_confirm_title": "Eliminar grupo",
|
||||
"delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?",
|
||||
"delete_message": "Se eliminará el grupo. Los contactos que contiene se conservan.",
|
||||
"name_label": "Nombre del grupo",
|
||||
"name_placeholder": "ej. Equipo, Familia",
|
||||
"name_required": "El nombre del grupo es obligatorio",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
|
||||
"select_all": "Seleccionar todo",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar contactos",
|
||||
"delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?",
|
||||
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
|
||||
"add_to_group": "Agregar al grupo",
|
||||
"choose_group": "Elegir un grupo",
|
||||
"new_group": "Nuevo grupo…",
|
||||
"adding_contacts": "Agregando {count, plural, one {1 contacto} other {# contactos}}",
|
||||
"moving_contacts": "Moviendo {count, plural, one {1 contacto} other {# contactos}}",
|
||||
"added_to_group": "Contactos agregados al grupo",
|
||||
"export": "Exportar",
|
||||
"clear": "Limpiar selección"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Proovi filtreid muuta või need tühjendada",
|
||||
"clear_search": "Tühjenda otsing",
|
||||
"import_vcard": "Impordi vCard",
|
||||
"delete_confirm_title": "Kustuta kontakt",
|
||||
"delete_confirm": "Kas oled kindel, et soovid selle kontakti kustutada?",
|
||||
"delete_named_title": "Kustutada {name}?",
|
||||
"delete_details_message": "E-posti aadressid, telefoninumbrid, märkmed ja kõik muud andmed kustutatakse. Seda ei saa tagasi võtta.",
|
||||
"local_mode": "Kontaktid salvestatakse kohalikult (server ei toeta JMAP Contacts funktsiooni)",
|
||||
"back_to_contacts": "Tagasi kontaktide juurde",
|
||||
"back_to_email": "Tagasi e-posti juurde",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Muuda gruppi",
|
||||
"empty": "Gruppe veel pole",
|
||||
"delete_confirm_title": "Kustuta grupp",
|
||||
"delete_confirm": "Kas oled kindel, et soovid selle grupi kustutada?",
|
||||
"delete_message": "Grupp kustutatakse. Selles olevad kontaktid jäävad alles.",
|
||||
"name_label": "Grupi nimi",
|
||||
"name_placeholder": "nt meeskond, pere",
|
||||
"name_required": "Grupi nimi on kohustuslik",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 valitud} other {# valitud}}",
|
||||
"select_all": "Vali kõik",
|
||||
"delete": "Kustuta",
|
||||
"delete_confirm_title": "Kustuta kontaktid",
|
||||
"delete_confirm": "Kustutada {count, plural, one {1 kontakt} other {# kontakti}}?",
|
||||
"deleted": "{count, plural, one {Kustutati 1 kontakt} other {Kustutati # kontakti}}",
|
||||
"add_to_group": "Lisa gruppi",
|
||||
"choose_group": "Vali grupp",
|
||||
"new_group": "Uus rühm…",
|
||||
"adding_contacts": "Lisamine: {count, plural, one {1 kontakt} other {# kontakti}}",
|
||||
"moving_contacts": "Teisaldamine: {count, plural, one {1 kontakt} other {# kontakti}}",
|
||||
"added_to_group": "Kontaktid lisati gruppi",
|
||||
"export": "Eksport",
|
||||
"clear": "Tühista valik"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "فیلترها را تنظیم یا پاک کنید",
|
||||
"clear_search": "پاک کردن جستجو",
|
||||
"import_vcard": "وارد کردن vCard",
|
||||
"delete_confirm_title": "حذف مخاطب",
|
||||
"delete_confirm": "آیا از حذف این مخاطب اطمینان دارید؟",
|
||||
"delete_named_title": "{name} حذف شود؟",
|
||||
"delete_details_message": "نشانیهای ایمیل، شمارهتلفنها، یادداشتها و همهٔ جزئیات دیگر حذف میشوند. این کار قابل بازگشت نیست.",
|
||||
"local_mode": "مخاطبین به صورت محلی ذخیره میشوند (سرور از JMAP Contacts پشتیبانی نمیکند)",
|
||||
"back_to_contacts": "بازگشت به مخاطبین",
|
||||
"back_to_email": "بازگشت به ایمیل",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "ویرایش گروه",
|
||||
"empty": "هنوز گروهی نیست",
|
||||
"delete_confirm_title": "حذف گروه",
|
||||
"delete_confirm": "آیا از حذف این گروه اطمینان دارید؟",
|
||||
"delete_message": "گروه حذف میشود. مخاطبان داخل آن باقی میمانند.",
|
||||
"name_label": "نام گروه",
|
||||
"name_placeholder": "مثال: تیم، خانواده",
|
||||
"name_required": "نام گروه الزامی است",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {۱ انتخاب شده} other {# انتخاب شده}}",
|
||||
"select_all": "انتخاب همه",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف مخاطبین",
|
||||
"delete_confirm": "حذف {count, plural, one {۱ مخاطب} other {# مخاطب}}؟",
|
||||
"deleted": "{count, plural, one {۱ مخاطب حذف شد} other {# مخاطب حذف شد}}",
|
||||
"add_to_group": "افزودن به گروه",
|
||||
"choose_group": "انتخاب گروه",
|
||||
"new_group": "گروه جدید…",
|
||||
"adding_contacts": "افزودن {count, plural, one {۱ مخاطب} other {# مخاطب}}",
|
||||
"moving_contacts": "انتقال {count, plural, one {۱ مخاطب} other {# مخاطب}}",
|
||||
"added_to_group": "مخاطبین به گروه اضافه شدند",
|
||||
"export": "خروجی",
|
||||
"clear": "پاک کردن انتخاب"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Ajustez ou effacez les filtres",
|
||||
"clear_search": "Effacer la recherche",
|
||||
"import_vcard": "Importer vCard",
|
||||
"delete_confirm_title": "Supprimer le contact",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
|
||||
"delete_named_title": "Supprimer {name} ?",
|
||||
"delete_details_message": "Les adresses e-mail, numéros de téléphone, notes et toutes les autres informations seront supprimés. Cette action est irréversible.",
|
||||
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
|
||||
"back_to_contacts": "Retour aux contacts",
|
||||
"back_to_email": "Retour à l'e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Modifier le groupe",
|
||||
"empty": "Aucun groupe",
|
||||
"delete_confirm_title": "Supprimer le groupe",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
|
||||
"delete_message": "Le groupe sera supprimé. Les contacts qu'il contient sont conservés.",
|
||||
"name_label": "Nom du groupe",
|
||||
"name_placeholder": "ex. Équipe, Famille",
|
||||
"name_required": "Le nom du groupe est requis",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
|
||||
"select_all": "Tout sélectionner",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm_title": "Supprimer les contacts",
|
||||
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
|
||||
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
|
||||
"add_to_group": "Ajouter au groupe",
|
||||
"choose_group": "Choisir un groupe",
|
||||
"new_group": "Nouveau groupe…",
|
||||
"adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}",
|
||||
"moving_contacts": "Déplacement de {count, plural, one {1 contact} other {# contacts}}",
|
||||
"added_to_group": "Contacts ajoutés au groupe",
|
||||
"export": "Exporter",
|
||||
"clear": "Effacer la sélection"
|
||||
|
||||
@@ -3172,8 +3172,8 @@
|
||||
"empty_search_hint": "נסה מונח חיפוש אחר",
|
||||
"clear_search": "נקה חיפוש",
|
||||
"import_vcard": "ייבוא vCard",
|
||||
"delete_confirm_title": "מחק איש קשר",
|
||||
"delete_confirm": "האם אתה בטוח שברצונך למחוק איש קשר זה?",
|
||||
"delete_named_title": "למחוק את {name}?",
|
||||
"delete_details_message": "כתובות הדוא״ל, מספרי הטלפון, ההערות וכל שאר הפרטים יימחקו. לא ניתן לבטל פעולה זו.",
|
||||
"local_mode": "אנשי הקשר מאוחסנים באופן מקומי (שרת אינו תומך באנשי קשר ב-JMAP)",
|
||||
"back_to_contacts": "חזור לאנשי קשר",
|
||||
"tabs": {
|
||||
@@ -3399,7 +3399,7 @@
|
||||
"edit": "ערוך קבוצה",
|
||||
"empty": "עדיין אין קבוצות",
|
||||
"delete_confirm_title": "מחק את הקבוצה",
|
||||
"delete_confirm": "האם אתה בטוח שברצונך למחוק את הקבוצה הזו?",
|
||||
"delete_message": "הקבוצה תימחק. אנשי הקשר שבה יישמרו.",
|
||||
"name_label": "שם הקבוצה",
|
||||
"name_placeholder": "למשל, צוות, משפחה",
|
||||
"name_required": "נדרש שם הקבוצה",
|
||||
@@ -3441,13 +3441,13 @@
|
||||
"selected": "{count, plural, one {נבחר אחד} other {נבחרו #}}",
|
||||
"select_all": "בחר הכל",
|
||||
"delete": "לִמְחוֹק",
|
||||
"delete_confirm_title": "מחק אנשי קשר",
|
||||
"delete_confirm": "למחוק {count, plural, one {איש קשר אחד} other {# אנשי קשר}}?",
|
||||
"deleted": "{count, plural, one {נמחק איש קשר אחד} other {נמחקו # אנשי קשר}}",
|
||||
"add_to_group": "הוסף לקבוצה",
|
||||
"choose_group": "בחר קבוצה",
|
||||
"new_group": "קבוצה חדשה…",
|
||||
"adding_contacts": "מוסיף {count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
|
||||
"moving_contacts": "מעביר {count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
|
||||
"added_to_group": "אנשי קשר נוספו לקבוצה",
|
||||
"export": "ייצוא",
|
||||
"clear": "נקה בחירה"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Próbáld módosítani vagy törölni a szűrőket",
|
||||
"clear_search": "Keresés törlése",
|
||||
"import_vcard": "vCard importálása",
|
||||
"delete_confirm_title": "Névjegy törlése",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd ezt a névjegyet?",
|
||||
"delete_named_title": "Törlöd: {name}?",
|
||||
"delete_details_message": "Az e-mail-címek, telefonszámok, jegyzetek és minden más adat törlődik. Ez nem vonható vissza.",
|
||||
"local_mode": "A névjegyek helyben tárolódnak (a szerver nem támogatja a JMAP Névjegyeket)",
|
||||
"back_to_contacts": "Vissza a névjegyekhez",
|
||||
"back_to_email": "Vissza az e-mailekhez",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Csoport szerkesztése",
|
||||
"empty": "Még nincsenek csoportok",
|
||||
"delete_confirm_title": "Csoport törlése",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd ezt a csoportot?",
|
||||
"delete_message": "A csoport törlődik. A benne lévő névjegyek megmaradnak.",
|
||||
"name_label": "Csoport neve",
|
||||
"name_placeholder": "pl. Csapat, Család",
|
||||
"name_required": "A csoport neve kötelező",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 kijelölve} other {# kijelölve}}",
|
||||
"select_all": "Összes kijelölése",
|
||||
"delete": "Törlés",
|
||||
"delete_confirm_title": "Névjegyek törlése",
|
||||
"delete_confirm": "Törlöd {count, plural, one {az 1 névjegyet} other {a # névjegyet}}?",
|
||||
"deleted": "{count, plural, one {1 névjegy törölve} other {# névjegy törölve}}",
|
||||
"add_to_group": "Hozzáadás a csoporthoz",
|
||||
"choose_group": "Csoport kiválasztása",
|
||||
"new_group": "Új csoport…",
|
||||
"adding_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} hozzáadása",
|
||||
"moving_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} áthelyezése",
|
||||
"added_to_group": "Névjegyek hozzáadva a csoporthoz",
|
||||
"export": "Exportálás",
|
||||
"clear": "Kijelölés megszüntetése"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Modifica o cancella i filtri",
|
||||
"clear_search": "Cancella ricerca",
|
||||
"import_vcard": "Importa vCard",
|
||||
"delete_confirm_title": "Elimina contatto",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
|
||||
"delete_named_title": "Eliminare {name}?",
|
||||
"delete_details_message": "Indirizzi email, numeri di telefono, note e tutti gli altri dati verranno eliminati. L'operazione non può essere annullata.",
|
||||
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
|
||||
"back_to_contacts": "Torna ai contatti",
|
||||
"back_to_email": "Torna all'e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Modifica gruppo",
|
||||
"empty": "Nessun gruppo",
|
||||
"delete_confirm_title": "Elimina gruppo",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo gruppo?",
|
||||
"delete_message": "Il gruppo verrà eliminato. I contatti che contiene vengono mantenuti.",
|
||||
"name_label": "Nome del gruppo",
|
||||
"name_placeholder": "es. Team, Famiglia",
|
||||
"name_required": "Il nome del gruppo è obbligatorio",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
|
||||
"select_all": "Seleziona tutto",
|
||||
"delete": "Elimina",
|
||||
"delete_confirm_title": "Elimina contatti",
|
||||
"delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?",
|
||||
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
|
||||
"add_to_group": "Aggiungi al gruppo",
|
||||
"choose_group": "Scegli un gruppo",
|
||||
"new_group": "Nuovo gruppo…",
|
||||
"adding_contacts": "Aggiunta di {count, plural, one {1 contatto} other {# contatti}}",
|
||||
"moving_contacts": "Spostamento di {count, plural, one {1 contatto} other {# contatti}}",
|
||||
"added_to_group": "Contatti aggiunti al gruppo",
|
||||
"export": "Esporta",
|
||||
"clear": "Cancella selezione"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
|
||||
"clear_search": "検索をクリア",
|
||||
"import_vcard": "vCardをインポート",
|
||||
"delete_confirm_title": "連絡先を削除",
|
||||
"delete_confirm": "この連絡先を削除してもよろしいですか?",
|
||||
"delete_named_title": "「{name}」を削除しますか?",
|
||||
"delete_details_message": "メールアドレス、電話番号、メモなど、すべての情報が削除されます。この操作は元に戻せません。",
|
||||
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
|
||||
"back_to_contacts": "連絡先に戻る",
|
||||
"back_to_email": "メールに戻る",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "グループを編集",
|
||||
"empty": "グループがありません",
|
||||
"delete_confirm_title": "グループを削除",
|
||||
"delete_confirm": "このグループを削除してもよろしいですか?",
|
||||
"delete_message": "グループは削除されます。グループ内の連絡先は残ります。",
|
||||
"name_label": "グループ名",
|
||||
"name_placeholder": "例:チーム、家族",
|
||||
"name_required": "グループ名は必須です",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, other {#件選択中}}",
|
||||
"select_all": "すべて選択",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "連絡先を削除",
|
||||
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
|
||||
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
|
||||
"add_to_group": "グループに追加",
|
||||
"choose_group": "グループを選択",
|
||||
"new_group": "新しいグループ…",
|
||||
"adding_contacts": "{count, plural, other {#件の連絡先}}を追加",
|
||||
"moving_contacts": "{count, plural, other {#件の連絡先}}を移動",
|
||||
"added_to_group": "連絡先をグループに追加しました",
|
||||
"export": "エクスポート",
|
||||
"clear": "選択を解除"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
|
||||
"clear_search": "검색 지우기",
|
||||
"import_vcard": "vCard 가져오기",
|
||||
"delete_confirm_title": "연락처 삭제",
|
||||
"delete_confirm": "정말 이 연락처를 삭제할까요?",
|
||||
"delete_named_title": "{name}을(를) 삭제할까요?",
|
||||
"delete_details_message": "이메일 주소, 전화번호, 메모 등 모든 정보가 삭제됩니다. 되돌릴 수 없습니다.",
|
||||
"local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)",
|
||||
"back_to_contacts": "연락처로 돌아가기",
|
||||
"back_to_email": "이메일로 돌아가기",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "그룹 수정",
|
||||
"empty": "아직 그룹이 없어요",
|
||||
"delete_confirm_title": "그룹 삭제",
|
||||
"delete_confirm": "정말 이 그룹을 삭제할까요?",
|
||||
"delete_message": "그룹이 삭제됩니다. 그룹에 있던 연락처는 유지됩니다.",
|
||||
"name_label": "그룹 이름",
|
||||
"name_placeholder": "예: 팀, 가족",
|
||||
"name_required": "그룹 이름이 필요해요",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count}개 선택됨",
|
||||
"select_all": "모두 선택",
|
||||
"delete": "삭제",
|
||||
"delete_confirm_title": "연락처 삭제",
|
||||
"delete_confirm": "선택한 {count}개의 연락처를 삭제할까요?",
|
||||
"deleted": "{count}개의 연락처가 삭제되었어요",
|
||||
"add_to_group": "그룹에 추가",
|
||||
"choose_group": "그룹 선택",
|
||||
"new_group": "새 그룹…",
|
||||
"adding_contacts": "{count}개의 연락처를 추가하는 중...",
|
||||
"moving_contacts": "연락처 {count}개 이동",
|
||||
"added_to_group": "연락처가 그룹에 추가되었어요",
|
||||
"export": "내보내기",
|
||||
"clear": "선택 취소"
|
||||
|
||||
@@ -3248,8 +3248,8 @@
|
||||
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
|
||||
"clear_search": "Notīrīt meklēšanu",
|
||||
"import_vcard": "vCard imports",
|
||||
"delete_confirm_title": "Dzēst kontaktu",
|
||||
"delete_confirm": "Vai tiešām vēlaties dzēst šo kontaktu?",
|
||||
"delete_named_title": "Vai dzēst {name}?",
|
||||
"delete_details_message": "Tiks dzēstas e-pasta adreses, tālruņa numuri, piezīmes un visa pārējā informācija. To nevar atsaukt.",
|
||||
"local_mode": "Kontakti tiek glabāti lokāli (serveris neatbalsta JMAP Contacts)",
|
||||
"back_to_contacts": "Atpakaļ pie kontaktiem",
|
||||
"back_to_email": "Atpakaļ pie e-pasta",
|
||||
@@ -3487,7 +3487,7 @@
|
||||
"edit": "Rediģēt grupu",
|
||||
"empty": "Grupu vēl nav",
|
||||
"delete_confirm_title": "Dzēst grupu",
|
||||
"delete_confirm": "Vai tiešām vēlaties dzēst šo grupu?",
|
||||
"delete_message": "Grupa tiks dzēsta. Tajā esošie kontakti tiks saglabāti.",
|
||||
"name_label": "Grupas nosaukums",
|
||||
"name_placeholder": "piem., Komanda, Ģimene",
|
||||
"name_required": "Grupas nosaukums ir obligāts",
|
||||
@@ -3529,13 +3529,13 @@
|
||||
"selected": "Atlasīts {count, plural, one {1 kontakts} other {# kontakti}}",
|
||||
"select_all": "Atlasīt visus",
|
||||
"delete": "Dzēst",
|
||||
"delete_confirm_title": "Dzēst kontaktus",
|
||||
"delete_confirm": "Vai dzēst {count, plural, one {1 kontaktu} other {# kontaktus}}?",
|
||||
"deleted": "Izdzēsts {count, plural, one {1 kontakts} other {# kontakti}}",
|
||||
"add_to_group": "Pievienot grupai",
|
||||
"choose_group": "Izvēlēties grupu",
|
||||
"new_group": "Jauna grupa…",
|
||||
"adding_contacts": "Pievieno {count, plural, one {1 kontaktu} other {# kontaktus}}",
|
||||
"moving_contacts": "Pārvieto {count, plural, one {1 kontaktu} other {# kontaktus}}",
|
||||
"added_to_group": "Kontakti pievienoti grupai",
|
||||
"export": "Eksportēt",
|
||||
"clear": "Notīrīt atlasi"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Шүүлтүүрийг тохируулж эсвэл цэвэрлэж үзнэ үү",
|
||||
"clear_search": "Хайлтыг арилгах",
|
||||
"import_vcard": "vCard импортлох",
|
||||
"delete_confirm_title": "Харилцагчийг устгах",
|
||||
"delete_confirm": "Та энэ харилцагчийг устгахдаа итгэлтэй байна уу?",
|
||||
"delete_named_title": "{name}-г устгах уу?",
|
||||
"delete_details_message": "Имэйл хаяг, утасны дугаар, тэмдэглэл болон бусад бүх мэдээлэл устгагдана. Үүнийг буцаах боломжгүй.",
|
||||
"local_mode": "Харилцагчдыг дотооддоо хадгалдаг (сервер нь JMAP Contacts-ийг дэмждэггүй)",
|
||||
"back_to_contacts": "Харилцагч руу буцах",
|
||||
"back_to_email": "Имэйл рүү буцах",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Засварлах групп",
|
||||
"empty": "Одоохондоо бүлэг байхгүй",
|
||||
"delete_confirm_title": "Бүлгийг устгах",
|
||||
"delete_confirm": "Та энэ бүлгийг устгахдаа итгэлтэй байна уу?",
|
||||
"delete_message": "Бүлэг устгагдана. Доторх харилцагчид хадгалагдана.",
|
||||
"name_label": "Бүлгийн нэр",
|
||||
"name_placeholder": "жишээ нь, Баг, Гэр бүл",
|
||||
"name_required": "Бүлгийн нэр шаардлагатай",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 сонгогдсон} other {# сонгогдсон}}",
|
||||
"select_all": "Бүгдийг нь сонго",
|
||||
"delete": "Устгах",
|
||||
"delete_confirm_title": "Харилцагчдыг устгах",
|
||||
"delete_confirm": "{count, plural, one {1 контакт} other {# контакт}} устгах уу?",
|
||||
"deleted": "{count, plural, one {1 контакт устгагдлаа} other {# контакт устгагдлаа}}",
|
||||
"add_to_group": "Бүлэгт нэмэх",
|
||||
"choose_group": "Бүлэг сонгох",
|
||||
"new_group": "Шинэ бүлэг…",
|
||||
"adding_contacts": "{count, plural, one {1 контакт} other {# контакт}} нэмэж байна",
|
||||
"moving_contacts": "{count, plural, one {1 контакт} other {# контакт}} зөөж байна",
|
||||
"added_to_group": "Харилцагчдыг группт нэмсэн",
|
||||
"export": "Экспорт хийх",
|
||||
"clear": "Сонголтыг арилгах"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Prøv å endre eller fjerne filtrene",
|
||||
"clear_search": "Tøm søket",
|
||||
"import_vcard": "Importer vCard",
|
||||
"delete_confirm_title": "Slett kontakt",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne kontakten?",
|
||||
"delete_named_title": "Vil du slette {name}?",
|
||||
"delete_details_message": "E-postadresser, telefonnumre, notater og alle andre opplysninger blir slettet. Dette kan ikke angres.",
|
||||
"local_mode": "Kontakter lagres lokalt (serveren støtter ikke JMAP-kontakter)",
|
||||
"back_to_contacts": "Tilbake til kontaktene",
|
||||
"back_to_email": "Tilbake til e-post",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Rediger gruppe",
|
||||
"empty": "Ingen grupper ennå",
|
||||
"delete_confirm_title": "Slett gruppe",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne gruppen?",
|
||||
"delete_message": "Gruppen blir slettet. Kontaktene i den beholdes.",
|
||||
"name_label": "Gruppenavn",
|
||||
"name_placeholder": "f.eks. team, familie",
|
||||
"name_required": "Gruppenavn må fylles ut",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 valgt} other {# valgte}}",
|
||||
"select_all": "Velg alle",
|
||||
"delete": "Slett",
|
||||
"delete_confirm_title": "Slett kontakter",
|
||||
"delete_confirm": "Vil du slette {count, plural, one {1 kontakt} other {# kontakter}}?",
|
||||
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
|
||||
"add_to_group": "Legg til i gruppe",
|
||||
"choose_group": "Velg en gruppe",
|
||||
"new_group": "Ny gruppe…",
|
||||
"adding_contacts": "Legger til {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"added_to_group": "Kontaktene er lagt til i gruppen",
|
||||
"export": "Eksporter",
|
||||
"clear": "Fjern valg"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Pas filters aan of wis ze",
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"import_vcard": "vCard importeren",
|
||||
"delete_confirm_title": "Contact verwijderen",
|
||||
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
|
||||
"delete_named_title": "{name} verwijderen?",
|
||||
"delete_details_message": "E-mailadressen, telefoonnummers, notities en alle andere gegevens worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
|
||||
"back_to_contacts": "Terug naar contacten",
|
||||
"back_to_email": "Terug naar e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Groep bewerken",
|
||||
"empty": "Geen groepen",
|
||||
"delete_confirm_title": "Groep verwijderen",
|
||||
"delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?",
|
||||
"delete_message": "De groep wordt verwijderd. De contacten erin blijven bewaard.",
|
||||
"name_label": "Groepsnaam",
|
||||
"name_placeholder": "bijv. Team, Familie",
|
||||
"name_required": "Groepsnaam is vereist",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
|
||||
"select_all": "Alles selecteren",
|
||||
"delete": "Verwijderen",
|
||||
"delete_confirm_title": "Contacten verwijderen",
|
||||
"delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?",
|
||||
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
|
||||
"add_to_group": "Aan groep toevoegen",
|
||||
"choose_group": "Kies een groep",
|
||||
"new_group": "Nieuwe groep…",
|
||||
"adding_contacts": "{count, plural, one {1 contact} other {# contacten}} toevoegen",
|
||||
"moving_contacts": "{count, plural, one {1 contact} other {# contacten}} verplaatsen",
|
||||
"added_to_group": "Contacten aan groep toegevoegd",
|
||||
"export": "Exporteren",
|
||||
"clear": "Selectie wissen"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"import_vcard": "Importuj vCard",
|
||||
"delete_confirm_title": "Usuń kontakt",
|
||||
"delete_confirm": "Czy na pewno chcesz usunąć ten kontakt?",
|
||||
"delete_named_title": "Usunąć {name}?",
|
||||
"delete_details_message": "Adresy e-mail, numery telefonów, notatki i wszystkie inne dane zostaną usunięte. Tej operacji nie można cofnąć.",
|
||||
"local_mode": "Kontakty są przechowywane lokalnie (serwer nie obsługuje JMAP Contacts)",
|
||||
"back_to_contacts": "Powrót do kontaktów",
|
||||
"back_to_email": "Powrót do wiadomości",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Edytuj grupę",
|
||||
"empty": "Brak grup",
|
||||
"delete_confirm_title": "Usuń grupę",
|
||||
"delete_confirm": "Czy na pewno chcesz usunąć tę grupę?",
|
||||
"delete_message": "Grupa zostanie usunięta. Kontakty w niej pozostaną.",
|
||||
"name_label": "Nazwa grupy",
|
||||
"name_placeholder": "np. Zespół, Rodzina",
|
||||
"name_required": "Nazwa grupy jest wymagana",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 zaznaczony} other {# zaznaczonych}}",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"delete": "Usuń",
|
||||
"delete_confirm_title": "Usuń kontakty",
|
||||
"delete_confirm": "Usunąć {count, plural, one {1 kontakt} other {# kontaktów}}?",
|
||||
"deleted": "{count, plural, one {Usunięto 1 kontakt} other {Usunięto # kontaktów}}",
|
||||
"add_to_group": "Dodaj do grupy",
|
||||
"choose_group": "Wybierz grupę",
|
||||
"new_group": "Nowa grupa…",
|
||||
"adding_contacts": "Dodawanie {count, plural, one {1 kontaktu} other {# kontaktów}}",
|
||||
"moving_contacts": "Przenoszenie {count, plural, one {1 kontaktu} other {# kontaktów}}",
|
||||
"added_to_group": "Kontakty dodano do grupy",
|
||||
"export": "Eksportuj",
|
||||
"clear": "Wyczyść zaznaczenie"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Ajuste ou limpe os filtros",
|
||||
"clear_search": "Limpar pesquisa",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Excluir contato",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
|
||||
"delete_named_title": "Excluir {name}?",
|
||||
"delete_details_message": "Endereços de e-mail, telefones, notas e todos os outros dados serão excluídos. Não é possível desfazer.",
|
||||
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
|
||||
"back_to_contacts": "Voltar aos contatos",
|
||||
"back_to_email": "Voltar ao e-mail",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Editar grupo",
|
||||
"empty": "Nenhum grupo",
|
||||
"delete_confirm_title": "Excluir grupo",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir este grupo?",
|
||||
"delete_message": "O grupo será excluído. Os contatos nele são mantidos.",
|
||||
"name_label": "Nome do grupo",
|
||||
"name_placeholder": "ex. Equipe, Família",
|
||||
"name_required": "O nome do grupo é obrigatório",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
|
||||
"select_all": "Selecionar tudo",
|
||||
"delete": "Excluir",
|
||||
"delete_confirm_title": "Excluir contatos",
|
||||
"delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?",
|
||||
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
|
||||
"add_to_group": "Adicionar ao grupo",
|
||||
"choose_group": "Escolher um grupo",
|
||||
"new_group": "Novo grupo…",
|
||||
"adding_contacts": "Adicionando {count, plural, one {1 contato} other {# contatos}}",
|
||||
"moving_contacts": "Movendo {count, plural, one {1 contato} other {# contatos}}",
|
||||
"added_to_group": "Contatos adicionados ao grupo",
|
||||
"export": "Exportar",
|
||||
"clear": "Limpar seleção"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Încercați să modificați sau să ștergeți filtrele",
|
||||
"clear_search": "Șterge căutarea",
|
||||
"import_vcard": "ImportvCard",
|
||||
"delete_confirm_title": "Șterge contactul",
|
||||
"delete_confirm": "Sunteți sigur că doriți să ștergeți acest contact?",
|
||||
"delete_named_title": "Șterge {name}?",
|
||||
"delete_details_message": "Adresele de e-mail, numerele de telefon, notițele și toate celelalte detalii vor fi șterse. Acțiunea nu poate fi anulată.",
|
||||
"local_mode": "Contactele sunt stocate local (serverul nu acceptă contactele dJMAP)",
|
||||
"back_to_contacts": "Înapoi la contacte",
|
||||
"back_to_email": "Înapoi la e-mail",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Editare grup",
|
||||
"empty": "Nu există încă grupuri",
|
||||
"delete_confirm_title": "Șterge grupul",
|
||||
"delete_confirm": "Sunteți sigur că doriți să ștergeți acest grup?",
|
||||
"delete_message": "Grupul va fi șters. Contactele din el se păstrează.",
|
||||
"name_label": "Numele grupului",
|
||||
"name_placeholder": "de exemplu, Echipa, Familia",
|
||||
"name_required": "Numele grupului este obligatoriu",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}",
|
||||
"select_all": "Selectează tot",
|
||||
"delete": "Șterge",
|
||||
"delete_confirm_title": "Ștergeți contactele",
|
||||
"delete_confirm": "Șterge {count, plural, one {1 contact} few {# contacte} other {# de contacte}}?",
|
||||
"deleted": "{count, plural, one {1 contact șters} few {# contacte șterse} other {# de contacte șterse}}",
|
||||
"add_to_group": "Adaugă la grup",
|
||||
"choose_group": "Alegeți un grup",
|
||||
"new_group": "Grup nou…",
|
||||
"adding_contacts": "Adăugare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}",
|
||||
"moving_contacts": "Mutare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}",
|
||||
"added_to_group": "Contacte adăugate la grup",
|
||||
"export": "Export",
|
||||
"clear": "Șterge selecția"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Измените или очистите фильтры",
|
||||
"clear_search": "Очистить поиск",
|
||||
"import_vcard": "Импорт vCard",
|
||||
"delete_confirm_title": "Удалить контакт",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить этот контакт?",
|
||||
"delete_named_title": "Удалить {name}?",
|
||||
"delete_details_message": "Адреса электронной почты, номера телефонов, заметки и все остальные данные будут удалены. Это действие нельзя отменить.",
|
||||
"local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)",
|
||||
"back_to_contacts": "Вернуться к контактам",
|
||||
"back_to_email": "Вернуться к письму",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Редактировать группу",
|
||||
"empty": "Групп пока нет",
|
||||
"delete_confirm_title": "Удалить группу",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить эту группу?",
|
||||
"delete_message": "Группа будет удалена. Контакты из неё сохранятся.",
|
||||
"name_label": "Название группы",
|
||||
"name_placeholder": "напр., Команда, Семья",
|
||||
"name_required": "Название группы обязательно",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 выбран} other {# выбрано}}",
|
||||
"select_all": "Выбрать все",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm_title": "Удалить контакты",
|
||||
"delete_confirm": "Удалить {count, plural, one {1 контакт} other {# контактов}}?",
|
||||
"deleted": "{count, plural, one {1 контакт удалён} other {# контактов удалено}}",
|
||||
"add_to_group": "Добавить в группу",
|
||||
"choose_group": "Выбрать группу",
|
||||
"new_group": "Новая группа…",
|
||||
"adding_contacts": "Добавляем {count, plural, one {1 контакт} other {# контактов}}",
|
||||
"moving_contacts": "Перемещение {count, plural, one {# контакта} other {# контактов}}",
|
||||
"added_to_group": "Контакты добавлены в группу",
|
||||
"export": "Экспорт",
|
||||
"clear": "Очистить выбор"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Skúste filtre upraviť alebo zrušiť",
|
||||
"clear_search": "Vymazať hľadanie",
|
||||
"import_vcard": "Importovať vCard",
|
||||
"delete_confirm_title": "Odstrániť kontakt",
|
||||
"delete_confirm": "Naozaj chcete odstrániť tento kontakt?",
|
||||
"delete_named_title": "Odstrániť {name}?",
|
||||
"delete_details_message": "Odstránia sa e-mailové adresy, telefónne čísla, poznámky a všetky ďalšie údaje. Túto akciu nie je možné vrátiť.",
|
||||
"local_mode": "Kontakty sú uložené lokálne (server nepodporuje JMAP Contacts)",
|
||||
"back_to_contacts": "Späť na kontakty",
|
||||
"back_to_email": "Späť na e-mail",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "Upraviť skupinu",
|
||||
"empty": "Žiadne skupiny",
|
||||
"delete_confirm_title": "Odstrániť skupinu",
|
||||
"delete_confirm": "Naozaj chcete odstrániť túto skupinu?",
|
||||
"delete_message": "Skupina sa odstráni. Kontakty v nej zostanú zachované.",
|
||||
"name_label": "Názov skupiny",
|
||||
"name_placeholder": "napr. Tím, Rodina",
|
||||
"name_required": "Názov skupiny je povinný",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {1 vybraný} other {# vybraných}}",
|
||||
"select_all": "Vybrať všetko",
|
||||
"delete": "Odstrániť",
|
||||
"delete_confirm_title": "Odstrániť kontakty",
|
||||
"delete_confirm": "Odstrániť {count, plural, one {1 kontakt} other {# kontaktov}}?",
|
||||
"deleted": "{count, plural, one {Odstránený 1 kontakt} other {Odstránených # kontaktov}}",
|
||||
"add_to_group": "Pridať do skupiny",
|
||||
"choose_group": "Vyberte skupinu",
|
||||
"new_group": "Nová skupina…",
|
||||
"adding_contacts": "Pridávanie {count, plural, one {1 kontaktu} other {# kontaktov}}",
|
||||
"moving_contacts": "Presúvanie {count, plural, one {1 kontaktu} other {# kontaktov}}",
|
||||
"added_to_group": "Kontakty boli pridané do skupiny",
|
||||
"export": "Exportovať",
|
||||
"clear": "Zrušiť výber"
|
||||
|
||||
@@ -2380,8 +2380,8 @@
|
||||
"empty_filtered_hint": "ลองปรับเปลี่ยนหรือล้างตัวกรอง",
|
||||
"clear_search": "ล้างการค้นหา",
|
||||
"import_vcard": "นำเข้า vCard",
|
||||
"delete_confirm_title": "ลบผู้ติดต่อ",
|
||||
"delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ติดต่อนี้?",
|
||||
"delete_named_title": "ลบ {name} หรือไม่?",
|
||||
"delete_details_message": "ที่อยู่อีเมล หมายเลขโทรศัพท์ บันทึก และรายละเอียดอื่นทั้งหมดจะถูกลบ คุณไม่สามารถเลิกทำได้",
|
||||
"local_mode": "ผู้ติดต่อจะถูกจัดเก็บไว้ในเครื่อง (เซิร์ฟเวอร์ไม่รองรับการซิงค์สมุดที่อยู่)",
|
||||
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
|
||||
"back_to_email": "กลับไปยังอีเมล",
|
||||
@@ -2621,7 +2621,7 @@
|
||||
"edit": "แก้ไขกลุ่ม",
|
||||
"empty": "ยังไม่มีกลุ่มผู้ติดต่อ",
|
||||
"delete_confirm_title": "ลบกลุ่ม",
|
||||
"delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบกลุ่มนี้?",
|
||||
"delete_message": "กลุ่มจะถูกลบ ผู้ติดต่อในกลุ่มจะยังคงอยู่",
|
||||
"name_label": "ชื่อกลุ่ม",
|
||||
"name_placeholder": "เช่น ทีมงาน, ครอบครัว",
|
||||
"name_required": "จำเป็นต้องระบุชื่อกลุ่ม",
|
||||
@@ -2663,13 +2663,13 @@
|
||||
"selected": "{count, plural, one {เลือกไว้ 1 รายการ} other {เลือกไว้ # รายการ}}",
|
||||
"select_all": "เลือกทั้งหมด",
|
||||
"delete": "ลบ",
|
||||
"delete_confirm_title": "ลบผู้ติดต่อ",
|
||||
"delete_confirm": "ลบผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}} หรือไม่?",
|
||||
"deleted": "{count, plural, one {ลบผู้ติดต่อแล้ว 1 รายการ} other {ลบผู้ติดต่อแล้ว # รายการ}}",
|
||||
"add_to_group": "เพิ่มในกลุ่ม",
|
||||
"choose_group": "เลือกกลุ่ม",
|
||||
"new_group": "กลุ่มใหม่…",
|
||||
"adding_contacts": "กำลังเพิ่มผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}",
|
||||
"moving_contacts": "กำลังย้ายผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}",
|
||||
"added_to_group": "เพิ่มผู้ติดต่อในกลุ่มแล้ว",
|
||||
"export": "ส่งออก",
|
||||
"clear": "ล้างการเลือก"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Filtreleri ayarlamayı veya temizlemeyi deneyin",
|
||||
"clear_search": "Aramayı temizle",
|
||||
"import_vcard": "vCard İçe Aktar",
|
||||
"delete_confirm_title": "Kişiyi sil",
|
||||
"delete_confirm": "Bu kişiyi silmek istediğinizden emin misiniz?",
|
||||
"delete_named_title": "{name} silinsin mi?",
|
||||
"delete_details_message": "E-posta adresleri, telefon numaraları, notlar ve diğer tüm bilgiler silinecek. Bu işlem geri alınamaz.",
|
||||
"local_mode": "Kişiler yerel olarak saklanıyor (sunucu JMAP Kişilerini desteklemiyor)",
|
||||
"back_to_contacts": "Kişilere geri dön",
|
||||
"back_to_email": "E-postaya geri dön",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Grubu Düzenle",
|
||||
"empty": "Henüz grup yok",
|
||||
"delete_confirm_title": "Grubu sil",
|
||||
"delete_confirm": "Bu grubu silmek istediğinizden emin misiniz?",
|
||||
"delete_message": "Grup silinecek. İçindeki kişiler korunur.",
|
||||
"name_label": "Grup Adı",
|
||||
"name_placeholder": "ör. Takım, Aile",
|
||||
"name_required": "Grup adı gerekli",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 seçildi} other {# seçildi}}",
|
||||
"select_all": "Tümünü seç",
|
||||
"delete": "Sil",
|
||||
"delete_confirm_title": "Kişileri sil",
|
||||
"delete_confirm": "{count, plural, one {1 kişi} other {# kişi}} silinsin mi?",
|
||||
"deleted": "{count, plural, one {1 kişi silindi} other {# kişi silindi}}",
|
||||
"add_to_group": "Gruba ekle",
|
||||
"choose_group": "Grup seçin",
|
||||
"new_group": "Yeni grup…",
|
||||
"adding_contacts": "{count, plural, one {1 kişi} other {# kişi}} ekleniyor",
|
||||
"moving_contacts": "{count, plural, one {1 kişi} other {# kişi}} taşınıyor",
|
||||
"added_to_group": "Kişiler gruba eklendi",
|
||||
"export": "Dışa Aktar",
|
||||
"clear": "Seçimi temizle"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "Змініть або очистіть фільтри",
|
||||
"clear_search": "Очистити пошук",
|
||||
"import_vcard": "Імпорт vCard",
|
||||
"delete_confirm_title": "Видалити контакт",
|
||||
"delete_confirm": "Ви впевнені, що хочете видалити цей контакт?",
|
||||
"delete_named_title": "Видалити {name}?",
|
||||
"delete_details_message": "Адреси електронної пошти, номери телефонів, нотатки та всі інші дані буде видалено. Цю дію не можна скасувати.",
|
||||
"local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)",
|
||||
"back_to_contacts": "Назад до контактів",
|
||||
"back_to_email": "Назад до листа",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "Редагувати групу",
|
||||
"empty": "Груп ще немає",
|
||||
"delete_confirm_title": "Видалити групу",
|
||||
"delete_confirm": "Ви впевнені, що хочете видалити цю групу?",
|
||||
"delete_message": "Групу буде видалено. Контакти в ній збережуться.",
|
||||
"name_label": "Назва групи",
|
||||
"name_placeholder": "наприклад, Команда, Сім’я",
|
||||
"name_required": "Потрібно вказати назву групи",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {1 вибрано} few {# вибрано} many {# вибрано} other {# вибрано}}",
|
||||
"select_all": "Вибрати все",
|
||||
"delete": "Видалити",
|
||||
"delete_confirm_title": "Видалити контакти",
|
||||
"delete_confirm": "Видалити {count, plural, one {1 contact} other {# contacts}}?",
|
||||
"delete_confirm": "Видалити {count, plural, one {# контакт} few {# контакти} many {# контактів} other {# контакту}}?",
|
||||
"deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}",
|
||||
"add_to_group": "Додати до групи",
|
||||
"choose_group": "Виберіть групу",
|
||||
"new_group": "Нова група…",
|
||||
"adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}",
|
||||
"adding_contacts": "Додавання {count, plural, one {# контакту} other {# контактів}}",
|
||||
"moving_contacts": "Переміщення {count, plural, one {# контакту} other {# контактів}}",
|
||||
"added_to_group": "Контакти додано до групи",
|
||||
"export": "Експорт",
|
||||
"clear": "Очистити вибір"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "請調整或清除篩選條件",
|
||||
"clear_search": "清除搜尋",
|
||||
"import_vcard": "匯入 vCard",
|
||||
"delete_confirm_title": "刪除聯絡人",
|
||||
"delete_confirm": "您確定要刪除此聯絡人嗎?",
|
||||
"delete_named_title": "刪除「{name}」?",
|
||||
"delete_details_message": "電子郵件地址、電話號碼、備註及所有其他資訊都將被刪除。此動作無法復原。",
|
||||
"local_mode": "聯絡人儲存在本機(伺服器不支援 JMAP 聯絡人)",
|
||||
"back_to_contacts": "回到聯絡人",
|
||||
"back_to_email": "回到郵件",
|
||||
@@ -3497,7 +3497,7 @@
|
||||
"edit": "編輯群組",
|
||||
"empty": "尚無群組",
|
||||
"delete_confirm_title": "刪除群組",
|
||||
"delete_confirm": "您確定要刪除該群組嗎?",
|
||||
"delete_message": "此群組將被刪除。其中的聯絡人會保留。",
|
||||
"name_label": "群組名稱",
|
||||
"name_placeholder": "例如,團隊、家庭",
|
||||
"name_required": "群組名稱為必填欄位",
|
||||
@@ -3539,13 +3539,13 @@
|
||||
"selected": "{count, plural, one {已選取 1 項} other {已選取 # 項}}",
|
||||
"select_all": "全部選取",
|
||||
"delete": "刪除",
|
||||
"delete_confirm_title": "刪除聯絡人",
|
||||
"delete_confirm": "刪除{count, plural, one {1 位聯絡人} other {# 位聯絡人}}?",
|
||||
"deleted": "{count, plural, one {已刪除 1 位聯絡人} other {已刪除 # 位聯絡人}}",
|
||||
"add_to_group": "新增至群組",
|
||||
"choose_group": "選擇群組",
|
||||
"new_group": "新增群組…",
|
||||
"adding_contacts": "正在新增{count, plural, one {1 位聯絡人} other {# 位聯絡人}}",
|
||||
"moving_contacts": "正在移動{count, plural, one {1 位聯絡人} other {# 位聯絡人}}",
|
||||
"added_to_group": "聯絡人已新增至群組",
|
||||
"export": "匯出",
|
||||
"clear": "清除選取"
|
||||
|
||||
@@ -3256,8 +3256,8 @@
|
||||
"empty_filtered_hint": "请调整或清除筛选条件",
|
||||
"clear_search": "清除搜索",
|
||||
"import_vcard": "导入 vCard",
|
||||
"delete_confirm_title": "删除联系人",
|
||||
"delete_confirm": "您确定要删除此联系人吗?",
|
||||
"delete_named_title": "删除“{name}”?",
|
||||
"delete_details_message": "电子邮件地址、电话号码、备注及所有其他信息都将被删除。此操作无法撤销。",
|
||||
"local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)",
|
||||
"back_to_contacts": "返回联系人",
|
||||
"back_to_email": "返回邮件",
|
||||
@@ -3495,7 +3495,7 @@
|
||||
"edit": "编辑群组",
|
||||
"empty": "还没有群组",
|
||||
"delete_confirm_title": "删除群组",
|
||||
"delete_confirm": "您确定要删除该群组吗?",
|
||||
"delete_message": "该群组将被删除。其中的联系人会保留。",
|
||||
"name_label": "群组名称",
|
||||
"name_placeholder": "例如,团队、家庭",
|
||||
"name_required": "群组名称为必填项",
|
||||
@@ -3537,13 +3537,13 @@
|
||||
"selected": "{count, plural, one {已选择 1 项} other {已选择 # 项}}",
|
||||
"select_all": "全选",
|
||||
"delete": "删除",
|
||||
"delete_confirm_title": "删除联系人",
|
||||
"delete_confirm": "删除{count, plural, one {1 位联系人} other {# 位联系人}}?",
|
||||
"deleted": "{count, plural, one {已删除 1 位联系人} other {已删除 # 位联系人}}",
|
||||
"add_to_group": "添加到群组",
|
||||
"choose_group": "选择一个组",
|
||||
"new_group": "新建群组…",
|
||||
"adding_contacts": "正在添加{count, plural, one {1 位联系人} other {# 位联系人}}",
|
||||
"moving_contacts": "正在移动{count, plural, one {1 位联系人} other {# 位联系人}}",
|
||||
"added_to_group": "联系人已添加至群组",
|
||||
"export": "导出",
|
||||
"clear": "清除选择"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { jmapError } from '@/lib/jmap/jmap-error';
|
||||
import { fileErrorKind } from '@/lib/file-errors';
|
||||
import { TRASH_LOCATION } from '@/lib/files-trash';
|
||||
|
||||
// Minimal in-memory FileNode backend that models the JMAP hierarchy via
|
||||
// parentId links (the behaviour issue #379 asks for).
|
||||
@@ -854,6 +855,46 @@ describe('file-store folder tree and moves', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('breadcrumb trail', () => {
|
||||
const tree = () => makeMockClient([
|
||||
dir('docs', 'Documents', null),
|
||||
dir('photos', 'Photos', null),
|
||||
dir('trips', 'Trips', 'photos'),
|
||||
dir('trash', '.Trash', null),
|
||||
]);
|
||||
const trail = () => useFileStore.getState().pathStack.map(s => s.name);
|
||||
|
||||
it('follows the real ancestors when a folder is picked in the tree, not the folder open before', async () => {
|
||||
useFileStore.getState().initClient(tree());
|
||||
await useFileStore.getState().navigate(null);
|
||||
await useFileStore.getState().navigate('docs', 'Documents');
|
||||
await useFileStore.getState().navigate('trips', 'Trips');
|
||||
expect(useFileStore.getState().currentPath).toBe('/Photos/Trips');
|
||||
expect(trail()).toEqual(['', 'Photos', 'Trips']);
|
||||
|
||||
// Alt+Up goes to the real parent.
|
||||
await useFileStore.getState().navigateUp();
|
||||
expect(useFileStore.getState().currentParentId).toBe('photos');
|
||||
expect(useFileStore.getState().currentPath).toBe('/Photos');
|
||||
});
|
||||
|
||||
it('leaves the Trash out of the trail of a folder opened from it', async () => {
|
||||
useFileStore.getState().initClient(tree());
|
||||
await useFileStore.getState().navigate(null);
|
||||
await useFileStore.getState().navigate(TRASH_LOCATION, '.Trash');
|
||||
await useFileStore.getState().navigate('photos', 'Photos');
|
||||
expect(useFileStore.getState().currentPath).toBe('/Photos');
|
||||
expect(trail()).toEqual(['', 'Photos']);
|
||||
});
|
||||
|
||||
it('sets the full trail once the listing arrives, when the folder list was not loaded yet', async () => {
|
||||
useFileStore.getState().initClient(tree());
|
||||
await useFileStore.getState().navigate('trips', 'Trips');
|
||||
expect(useFileStore.getState().currentPath).toBe('/Photos/Trips');
|
||||
expect(useFileStore.getState().pathStack.map(s => s.id)).toEqual([null, 'photos', 'trips']);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every folder from the listing for the tree, at any depth', async () => {
|
||||
const client = makeMockClient([
|
||||
dir('a', 'A', null),
|
||||
|
||||
+55
-5
@@ -501,6 +501,37 @@ function isUnder(path: string, base: string): boolean {
|
||||
return path === base || path.startsWith(base + '/');
|
||||
}
|
||||
|
||||
type PathStep = { id: string | null; name: string };
|
||||
|
||||
/**
|
||||
* The breadcrumb trail of an own-drive folder from its real ancestors: Home,
|
||||
* then each folder down to `id`. null when the chain can't be followed (a
|
||||
* parent isn't in `folders`) or runs through the hidden trash folder, which
|
||||
* never shows in the breadcrumb.
|
||||
*/
|
||||
function ancestorStack(
|
||||
folders: readonly { id: string; name: string; parentId?: string | null }[],
|
||||
id: string,
|
||||
trashId?: string,
|
||||
): PathStep[] | null {
|
||||
const byId = new Map(folders.map(f => [f.id, f]));
|
||||
const trail: PathStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
let at = byId.get(id);
|
||||
while (at) {
|
||||
if (seen.has(at.id) || at.id === trashId) return null;
|
||||
seen.add(at.id);
|
||||
trail.unshift({ id: at.id, name: at.name });
|
||||
if (at.parentId == null) return [{ id: null, name: '' }, ...trail];
|
||||
at = byId.get(at.parentId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameStack(a: PathStep[], b: PathStep[]): boolean {
|
||||
return a.length === b.length && a.every((s, i) => s.id === b[i].id && s.name === b[i].name);
|
||||
}
|
||||
|
||||
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
|
||||
if (stack.length <= 1) return '/';
|
||||
return '/' + stack.slice(1).map(s => s.name).join('/');
|
||||
@@ -842,7 +873,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
},
|
||||
|
||||
navigate: async (parentId: string | null, name?: string) => {
|
||||
const { client, pathStack, currentParentId: previousParentId, hasListed } = get();
|
||||
const { client, pathStack, currentParentId: previousParentId, hasListed, allFolders: knownFolders } = get();
|
||||
if (!client) return;
|
||||
// A refresh lists the same folder again; plugins only hear about a new one.
|
||||
const opensFolder = !hasListed || parentId !== previousParentId;
|
||||
@@ -859,16 +890,23 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
} else if (isTrashLocation(parentId)) {
|
||||
newStack = [{ id: null, name: '' }, { id: TRASH_LOCATION, name: TRASH_FOLDER_NAME }];
|
||||
} else {
|
||||
// Check if navigating to a parent in the stack
|
||||
// An own-drive folder: the trail is its real ancestors, wherever it was
|
||||
// opened from (the tree, a favorite, the Trash). A folder of a shared
|
||||
// drive builds on the trail openSharedNode set up.
|
||||
const known = isCrossAccountId(parentId) ? null : ancestorStack(knownFolders, parentId);
|
||||
const existingIdx = pathStack.findIndex(s => s.id === parentId);
|
||||
if (existingIdx >= 0) {
|
||||
if (known) {
|
||||
newStack = known;
|
||||
} else if (existingIdx >= 0) {
|
||||
newStack = pathStack.slice(0, existingIdx + 1);
|
||||
} else {
|
||||
newStack = [...pathStack, { id: parentId, name: name || parentId }];
|
||||
// The Trash and shared-files pages are no folder to be inside of.
|
||||
const base = pathStack.filter(s => s.id === null || (!isTrashLocation(s.id) && sharedFilesOwner(s.id) === null));
|
||||
newStack = [...base, { id: parentId, name: name || parentId }];
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = buildPathFromStack(newStack);
|
||||
let newPath = buildPathFromStack(newStack);
|
||||
set({ pathStack: newStack, currentPath: newPath });
|
||||
|
||||
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
|
||||
@@ -907,6 +945,18 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
// The owner's folders above a shared one stay out of the tree as well.
|
||||
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource);
|
||||
|
||||
// The folder list was stale or empty (first visit, a link): set the
|
||||
// trail again from the fresh one, unless another folder opened since.
|
||||
if (parentId !== null && !isCrossAccountId(parentId) && !isTrashLocation(parentId)
|
||||
&& sharedFilesOwner(parentId) === null && get().currentParentId === parentId) {
|
||||
const fresh = ancestorStack(allNodes.filter(n => isFolder(n)), parentId, trash?.id);
|
||||
if (fresh && !sameStack(fresh, get().pathStack)) {
|
||||
newPath = buildPathFromStack(fresh);
|
||||
set({ pathStack: fresh, currentPath: newPath });
|
||||
try { localStorage.setItem('files-path-stack', JSON.stringify(fresh)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Prune recent files whose backing node no longer exists on the server
|
||||
// or is in the trash.
|
||||
const { recentFiles } = get();
|
||||
|
||||
Reference in New Issue
Block a user