Merge branch 'ux/contacts-files2'

This commit is contained in:
Linus Rath
2026-10-28 11:04:04 +01:00
62 changed files with 902 additions and 215 deletions
@@ -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;
+30 -13
View File
@@ -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(", ");
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -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}
+6
View File
@@ -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();
+41 -37
View File
@@ -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(
+2 -2
View File
@@ -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');
});
});
+5 -5
View File
@@ -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>
+23 -4
View File
@@ -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",
+1 -1
View File
@@ -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>
);
};
+5 -5
View File
@@ -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();
});
});
+4 -2
View File
@@ -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'); });
+2 -1
View File
@@ -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>
);
+3 -2
View File
@@ -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 -2
View File
@@ -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.
*/
+8
View File
@@ -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 {
+9 -2
View File
@@ -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 };
}
+22
View File
@@ -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);
});
});
+86
View File
@@ -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();
});
});
+4 -2
View File
@@ -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);
}
}
+78
View File
@@ -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(),
}), []);
}
+4 -4
View File
@@ -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": "إلغاء التحديد"
+4 -4
View File
@@ -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ó"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "پاک کردن انتخاب"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "נקה בחירה"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "選択を解除"
+4 -4
View File
@@ -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": "선택 취소"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "Сонголтыг арилгах"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "Очистить выбор"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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": "ล้างการเลือก"
+4 -4
View File
@@ -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"
+6 -6
View File
@@ -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": "Очистити вибір"
+4 -4
View File
@@ -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": "清除選取"
+4 -4
View File
@@ -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": "清除选择"
+41
View File
@@ -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
View File
@@ -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();