diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx index 068ad4e40..997c37f43 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-standalone.test.tsx @@ -81,6 +81,17 @@ describe('the desktop app, with a server of one\'s own', () => { expect(screen.queryByTestId('login-server-trouble')).toBeNull(); }, 30_000); + it('makes "Sign in" the main button once a password is typed', async () => { + render(); + const button = await browserButton(); + const submit = email().closest('form')!.querySelector('button[type=submit]')!; + expect(button.className).toContain('bg-primary'); + expect(submit.className).not.toContain('bg-primary'); + fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } }); + await waitFor(() => expect(submit.className).toContain('bg-primary')); + expect(button.className).not.toContain('bg-primary'); + }, 30_000); + it('asks for the address under its field when the browser button is pressed first', async () => { render(); fireEvent.click(await browserButton()); diff --git a/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx index 326e9f2f2..1fcd9f2ea 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-server-lookup.test.tsx @@ -139,10 +139,76 @@ describe('sign-in to a server of one\'s own', () => { fireEvent.submit(form()); expect(await screen.findByTestId('login-server-error')).toHaveTextContent('server_invalid'); expect(serverField()).toHaveAttribute('aria-invalid', 'true'); + // Marked red, not only for screen readers. + expect(serverField()!.className).toContain('aria-invalid:border-destructive'); await waitFor(() => expect(document.activeElement).toBe(serverField())); expect(login).not.toHaveBeenCalled(); }, 30_000); + it('Change keeps the server in use and doesn\'t say it wasn\'t found', async () => { + configOverrides.value = { jmapServerUrl: 'https://default.example.com' }; + render(); + await typeAddress('sam@example.net'); + fireEvent.click(await screen.findByTestId('login-server-change', {}, { timeout: 5000 })); + await waitFor(() => expect(serverField()).not.toBeNull()); + expect(serverField()!.value).toBe('default.example.com'); + expect(screen.queryByTestId('login-server-not-found')).toBeNull(); + expect(screen.getByTestId('login-server-change-hint')).toHaveTextContent('server_change_hint'); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + }, 30_000); + + it('focuses the server field when it opens after the user left the address field', async () => { + render(); + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + fireEvent.change(email(), { target: { value: 'sam@example.net' } }); + // Tab went on to the (still empty) password field before the lookup ended. + (document.getElementById('password') as HTMLInputElement).focus(); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + await waitFor(() => expect(document.activeElement).toBe(serverField())); + }, 30_000); + + it('leaves focus in the address field while the user is still typing there', async () => { + render(); + await waitFor(() => expect(email()).not.toBeNull(), { timeout: 10000 }); + email().focus(); + fireEvent.change(email(), { target: { value: 'sam@example.net' } }); + await waitFor(() => expect(serverField()).not.toBeNull(), { timeout: 5000 }); + expect(document.activeElement).toBe(email()); + }, 30_000); + + it('a wrong password focuses the password field with its text selected', async () => { + found['example.org'] = 'https://mail.example.org'; + login.mockImplementation(async () => { + useAuthStore.setState({ error: 'invalid_credentials', isLoading: false }); + return false; + }); + render(); + await typeAddress('sam@example.org'); + const button = form().querySelector('button[type=submit]')!; + button.focus(); + fireEvent.submit(form()); + const password = document.getElementById('password') as HTMLInputElement; + await waitFor(() => expect(document.activeElement).toBe(password)); + expect(password.selectionStart).toBe(0); + expect(password.selectionEnd).toBe('secret'.length); + expect(password.className).toContain('aria-invalid:border-destructive'); + login.mockImplementation(async () => false); + }, 30_000); + + it('keeps the access token behind "Other ways to sign in"', async () => { + configOverrides.value = { loginShowTokenLogin: true }; + render(); + const toggle = await screen.findByTestId('login-token-toggle', {}, { timeout: 10000 }); + const other = screen.getByTestId('login-other-ways'); + expect(other.tagName).toBe('DETAILS'); + expect(other).toContainElement(toggle); + expect(other.querySelector('summary')).toHaveTextContent('other_ways'); + fireEvent.click(toggle); + // The way back to the password is in sight, not folded away. + await waitFor(() => expect(screen.queryByTestId('login-other-ways')).toBeNull()); + expect(screen.getByTestId('login-token-toggle')).toHaveTextContent('password_toggle'); + }, 30_000); + it('says plainly that Gmail can\'t be reached, and asks nothing', async () => { render(); await typeAddress('sam@gmail.com'); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 29633c489..34ac9f470 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -310,6 +310,10 @@ function LoginPageContent() { const customServerMode = !hasServerList && allowCustomJmapEndpoint; const canLookUpServer = customServerMode && !IS_LITE; const [serverEditing, setServerEditing] = useState(false); + // Change on a found server: another one is wanted, nothing failed. + const [serverChanging, setServerChanging] = useState(false); + // Asks for focus in the server field once it is on screen (the field mounts in the next render). + const [serverFocusRequest, setServerFocusRequest] = useState(0); const [serverLookup, setServerLookup] = useState<{ domain: string; status: "looking" | "found" | "failed"; url: string | null } | null>(null); const [serverError, setServerError] = useState(null); const [emailNeeded, setEmailNeeded] = useState(false); @@ -400,6 +404,7 @@ function LoginPageContent() { const justRemovedSuggestion = useRef(false); const totpInputRef = useRef(null); const tokenInputRef = useRef(null); + const passwordInputRef = useRef(null); const prevError = useRef(null); const themeMenuRef = useRef(null); const themeButtonRef = useRef(null); @@ -510,6 +515,23 @@ function LoginPageContent() { return () => clearTimeout(timer); }, [typedDomain, typedAddress, unsupportedMailProvider, serverLookup?.domain, lookUpServer]); + useEffect(() => { + if (serverFocusRequest) serverInputRef.current?.focus(); + }, [serverFocusRequest]); + + // The field opens by itself when the lookup found nothing, usually after the user + // already left the address field. Focus goes there, unless they are typing elsewhere. + const serverFieldOpenedByLookup = canLookUpServer && lookupForAddress?.status === "failed" && !configuredServerUrl && !unsupportedMailProvider; + useEffect(() => { + if (!serverFieldOpenedByLookup) return; + const active = document.activeElement; + if (active && active.id === "username") return; + if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { + if (!(active.id === "password" && !active.value)) return; + } + serverInputRef.current?.focus(); + }, [serverFieldOpenedByLookup, typedDomain]); + /** * The server to sign in to in custom-server mode, looked up now if need be. * Null after it opened the field and said what is missing there. @@ -529,7 +551,7 @@ function LoginPageContent() { } setServerEditing(true); setServerError(serverFieldOpen && jmapEndpoint.trim() ? "invalid" : "needed"); - setTimeout(() => serverInputRef.current?.focus(), 0); + setServerFocusRequest((n) => n + 1); return null; }; @@ -582,6 +604,19 @@ function LoginPageContent() { prevError.current = error; }, [error]); + // A wrong password or token: focus that field with its text selected, ready to type again. + useEffect(() => { + if (error === "invalid_credentials" && !totpBlamed) { + passwordInputRef.current?.focus(); + passwordInputRef.current?.select(); + } else if (error === "invalid_token") { + tokenInputRef.current?.focus(); + tokenInputRef.current?.select(); + } + // Only on a new error, not when the code field changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); + // Auto-show and focus TOTP field when server requires it useEffect(() => { if (error === 'totp_required') { @@ -1232,6 +1267,10 @@ function LoginPageContent() { }; const browserSignInShown = browserSignInAvailable() && desktopSignInEnabled; + // Once a password (or token) is typed, "Sign in" is what the user means: it becomes the + // main button and "Sign in with your browser" the other way. + const formSignInPreferred = signInWithToken ? accessToken.trim() !== "" : formData.password !== ""; + const browserSignInPrimary = browserSignInShown && !formSignInPreferred; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -1413,7 +1452,10 @@ function LoginPageContent() {
- )} + {loginShowTokenLogin && !isMobileHandoff && !liteSsoRequired && (() => { + const tokenToggle = ( + + ); + // Few people have a token: the way back to the password stays in sight, + // the way to a token waits behind "Other ways to sign in". + return signInWithToken ? tokenToggle : ( +
+ {tLogin("other_ways")} +
{tokenToggle}
+
+ ); + })()} {/* Remember me, for the password and the SSO buttons below */} {showRememberMe && rememberMeBox} @@ -2189,8 +2245,9 @@ function LoginPageContent() { + {quickPluginsOff && ( + + )} + + + )} + > + {quickInstall && (
- ) : null} - confirmText="Install" - cancelText="Cancel" - /> + )} +
{updates && noticeCount > 0 && ( diff --git a/app/(main)/admin/_pages/turn-on-plugins.ts b/app/(main)/admin/_pages/turn-on-plugins.ts index 7788026b5..a04dddeca 100644 --- a/app/(main)/admin/_pages/turn-on-plugins.ts +++ b/app/(main)/admin/_pages/turn-on-plugins.ts @@ -44,6 +44,18 @@ export async function offerToTurnOnPlugins( } } +/** Whether plugins are off for users; null when the policy can't be read. */ +export async function pluginsOffForUsers(): Promise { + try { + const res = await apiFetch('/api/admin/policy'); + if (!res.ok) return null; + const policy = (await res.json()) as SettingsPolicy; + return !policy.features?.pluginsEnabled; + } catch { + return null; + } +} + /** The line added to the "installed" message for each outcome. */ export function turnOnNote(outcome: TurnOnOutcome): string { switch (outcome) { diff --git a/components/auth/login-server-field.tsx b/components/auth/login-server-field.tsx index 9bda8df0c..cf1013c48 100644 --- a/components/auth/login-server-field.tsx +++ b/components/auth/login-server-field.tsx @@ -35,6 +35,8 @@ interface LoginServerFieldProps { value: string; onChange: (value: string) => void; onEdit: () => void; + /** The user pressed Change on a server that was found: they want another one, nothing went wrong. */ + changing?: boolean; /** A provider Bulwark can't reach (Gmail, Outlook.com, iCloud Mail), by name. */ provider: string | null; error: ServerFieldError; @@ -49,13 +51,15 @@ interface LoginServerFieldProps { * field only opens when nothing was found or the user wants another one. */ export const LoginServerField = forwardRef(function LoginServerField( - { lookup, server, editing, value, onChange, onEdit, provider, error, showTrouble, disabled }, + { lookup, server, editing, value, onChange, onEdit, changing, provider, error, showTrouble, disabled }, ref, ) { const t = useTranslations("uxs_login"); const id = useId(); const hintId = `${id}-hint`; const errorId = `${id}-error`; + // After Change the lookup's result no longer matters: the user picks another server. + const notFound = lookup === "failed" && !provider && !changing; const providerNote = provider && (
@@ -112,16 +116,21 @@ export const LoginServerField = forwardRef onChange(e.target.value)} - className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20" placeholder={t("server_placeholder")} aria-invalid={error ? true : undefined} - aria-describedby={[error ? errorId : null, lookup === "failed" && !provider ? hintId : null].filter(Boolean).join(" ") || undefined} + aria-describedby={[error ? errorId : null, notFound || changing ? hintId : null].filter(Boolean).join(" ") || undefined} /> - {lookup === "failed" && !provider && !error && ( + {notFound && !error && (

{t("server_not_found")}

)} + {changing && !provider && !error && ( +

+ {t("server_change_hint")} +

+ )} {error && (

{props.shownCalendars.length > 0 && ( - {t("print.calendars", { names: props.shownCalendars.map((c) => c.name).join(", ") })} + {t("print.calendars", { names: props.shownCalendars.map((c) => shownName.calendar(c)).join(", ") })} )} {props.shownCalendars.length > 0 && ·} {t("print.time_zone", { zone: formatPrintTimeZone(props.timeZone, locale, props.periodDate) })} @@ -634,6 +636,7 @@ function priorityKey(priority: number): string | null { } function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[]; calendars: Calendar[]; timeFormat: TimeFormat }) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const intlFormatter = useDisplayDateFormatter(); if (tasks.length === 0) return

{t("tasks.no_tasks")}

; @@ -661,7 +664,7 @@ function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[] {done ? "✓" : ""}
{task.title || t("tasks.no_title")}
- {calendar &&
{calendar.name}
} + {calendar &&
{shownName.calendar(calendar)}
}
{dueLabel}
{priority ? t(priority) : ""}
@@ -682,6 +685,7 @@ export function EventPrintSheet({ timeFormat: TimeFormat; timeZone: string; }) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const locale = useLocale(); const formatEventDate = useFormatEventDate(); @@ -740,7 +744,7 @@ export function EventPrintSheet({ {meeting && (<>
{t("print.meeting")}
{meeting.uri}
)} {recurrence && (<>
{t("print.repeats")}
{recurrence}
)} {alerts && (<>
{t("tasks.alert")}
{alerts}
)} - {calendar && (<>
{t("tasks.calendar")}
{calendar.name}
)} + {calendar && (<>
{t("tasks.calendar")}
{shownName.calendar(calendar)}
)} {organizer && ( <>
{t("participants.organizer")}
diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index bdacc8f19..98ad798c0 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -26,6 +26,7 @@ import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useCollectionNames } from "@/hooks/use-collection-names"; /** * Split a per-account calendar list into "owned" (the user's own) and @@ -94,6 +95,7 @@ export function CalendarSidebarPanel({ client, multiAccountMode, }: CalendarSidebarPanelProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const tUx = useTranslations("ux_pim_calendar"); const paint = useCvdColor(); @@ -359,8 +361,8 @@ export function CalendarSidebarPanel({ > {isVisible && } - - {cal.name} + + {shownName.calendar(cal)} {isSubscriptionCalendar(cal.id) && ( <> @@ -393,7 +395,7 @@ export function CalendarSidebarPanel({ "opacity-0 group-hover/cal:opacity-100 group-focus-within/cal:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100" )} - label={t('calendar_options', { name: cal.name })} + label={t('calendar_options', { name: shownName.calendar(cal) })} > diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index fe6471667..682519f87 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -16,6 +16,7 @@ import type { Calendar } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { displayNow } from "@/lib/timezone"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useCollectionNames } from "@/hooks/use-collection-names"; interface CalendarToolbarProps { selectedDate: Date; @@ -105,6 +106,7 @@ export function CalendarToolbar({ onCloseSearch, panelToggle, }: CalendarToolbarProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -342,8 +344,8 @@ export function CalendarToolbar({ )} style={isVisible ? { backgroundColor: color, borderColor: color } : undefined} /> - - {cal.name} + + {shownName.calendar(cal)} ); @@ -382,8 +384,8 @@ export function CalendarToolbar({ )} style={isVisible ? { backgroundColor: color, borderColor: color } : undefined} /> - - {cal.name} + + {shownName.calendar(cal)} ); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 8351977ed..a9c6529b0 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -38,6 +38,7 @@ import { isEditableEventTarget } from "@/lib/keyboard"; import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { gateLinkClick } from "@/lib/plugin-hook-result"; +import { useCollectionNames } from "@/hooks/use-collection-names"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -146,6 +147,7 @@ export function EventDetailPopover({ isMobile, pinned = false, }: EventDetailPopoverProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -643,7 +645,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {shownName.calendar(calendar)}
)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 029737861..5fe1e9335 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -58,6 +58,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { gateLinkClick } from "@/lib/plugin-hook-result"; +import { useCollectionNames } from "@/hooks/use-collection-names"; export interface PendingEventPreview { start: Date; @@ -275,6 +276,7 @@ export function EventModal({ maxParticipants = null, isMobile = false, }: EventModalProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); // Ids that tie each visible label to its field. @@ -1527,7 +1529,7 @@ export function EventModal({ > {selectableCalendars.map((cal) => ( ))} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 1d3a4bac9..a66c77c94 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -17,6 +17,7 @@ import { apiFetch } from "@/lib/browser-navigation"; import { explainServerAuthError } from "@/lib/server-auth-status"; import { IS_LITE } from "@/lib/lite"; import { isOfflineNow } from "@/lib/offline/state"; +import { useCollectionNames } from "@/hooks/use-collection-names"; interface ICalImportModalProps { calendars: Calendar[]; @@ -34,6 +35,7 @@ type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar.import"); const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); @@ -465,7 +467,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia > {calendars.map((cal) => ( ))} diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index eeb9202da..9951c9324 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -11,6 +11,7 @@ import { IconButton } from "@/components/ui/icon-button"; import type { Calendar } from "@/lib/jmap/types"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useCollectionNames } from "@/hooks/use-collection-names"; export interface QuickCreateDraft { start: Date; @@ -69,6 +70,7 @@ export function QuickCreatePopover({ onMoreOptions, onClose, }: QuickCreatePopoverProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -207,7 +209,7 @@ export function QuickCreatePopover({ className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden" > {calendars.map((c) => ( - + ))}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index e37ad99f5..8e2391ead 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -12,6 +12,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useCollectionNames } from "@/hooks/use-collection-names"; import { parseTaskDue } from "@/lib/task-due-parser"; interface TaskListViewProps { @@ -220,6 +221,7 @@ export function TaskListView({ selectedTaskId, onQuickCreate, }: TaskListViewProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -307,7 +309,7 @@ export function TaskListView({ {cal && ( - {cal.name} + {shownName.calendar(cal)} )} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 2ed62e2ac..3f85531f8 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -16,6 +16,7 @@ import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { canCreateEventsIn } from "@/lib/calendar-editability"; +import { useCollectionNames } from "@/hooks/use-collection-names"; import { defaultTaskList } from "@/components/calendar/task-list-view"; interface TaskModalProps { @@ -61,6 +62,7 @@ export function TaskModal({ isMobile = false, isSubscriptionCalendar, }: TaskModalProps) { + const shownName = useCollectionNames(); const t = useTranslations("calendar"); const tUx = useTranslations("ux_pim_calendar"); const tCommon = useTranslations("common"); @@ -353,7 +355,7 @@ export function TaskModal({ className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full" > {calendarOptions.map((cal) => ( - + ))}
diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index ec09456d9..ab6fe2786 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -570,12 +570,12 @@ describe('ContactForm', () => { it("names the account of an address book once, not twice", () => { const books = [ - { id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true }, + { id: 'b1', name: 'Personal (usera@example.org)', accountName: 'usera@example.org', isDefault: true }, { id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true }, ] as never; render(); const options = screen.getAllByRole('option').map((o) => o.textContent); - expect(options).toContain('Stalwart Address Book (usera@example.org)'); + expect(options).toContain('Personal (usera@example.org)'); expect(options).toContain('Team (userb@example.org)'); }); }); diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 550966d23..20bdd1594 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { AddressBook } from "@/lib/jmap/types"; +import { useCollectionNames } from "@/hooks/use-collection-names"; interface AddressBookPickerDialogProps { isOpen: boolean; @@ -21,6 +22,7 @@ interface AddressBookPickerDialogProps { /** "Move to…" for contacts: pick the address book they go to. */ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, currentId, count, onPick }: AddressBookPickerDialogProps) { + const shownName = useCollectionNames(); const t = useTranslations("contacts"); const [selected, setSelected] = useState(null); const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null; @@ -64,7 +66,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current )} > - {book.name} + {shownName.addressBook(book)} {book.isShared && book.accountName && ( {book.accountName} )} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 342dc7799..5fd5b28c9 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"; import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format"; import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; +import { useCollectionNames } from "@/hooks/use-collection-names"; // Each row of a list field keeps the `key` of the card's entry it was read // from, so Save can send an entry the user didn't touch back unchanged. @@ -291,6 +292,7 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa } export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) { + const shownName = useCollectionNames(); const t = useTranslations("contacts.form"); const tx = useTranslations("ux_pim_contact"); const locale = useLocale(); @@ -909,7 +911,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress {addressBooks.map((book) => ( ))} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 276c182b2..9886cf576 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -14,6 +14,7 @@ import { import type { AddressBook, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { isOfflineNow } from "@/lib/offline/state"; +import { useCollectionNames } from "@/hooks/use-collection-names"; interface ContactImportDialogProps { existingContacts: ContactCard[]; @@ -53,6 +54,7 @@ export function ContactImportDialog({ defaultAddressBookId, }: ContactImportDialogProps) { const t = useTranslations("contacts"); + const shownName = useCollectionNames(); const tCsv = useTranslations("ux_pim_cimport"); const tLaunch = useTranslations("pwaLaunch"); const fileRef = useRef(null); @@ -354,7 +356,7 @@ export function ContactImportDialog({ > {addressBooks.map((book) => ( ))} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index f59092a32..4e6656441 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -80,6 +80,7 @@ import { DirectoryEntryPrintSheet, DirectoryListPrint, } from "@/components/contacts/contact-print-views"; +import { useCollectionNames } from "@/hooks/use-collection-names"; type View = | "list" @@ -101,6 +102,7 @@ export interface ContactsAppProps { function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('contacts', routeSegments); + const shownName = useCollectionNames(); const t = useTranslations("contacts"); const skeleton = useSkeletonLabels(); // Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note). @@ -490,14 +492,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = try { await moveContactToAddressBook(client, contactIds, addressBook); const msg = contactIds.length === 1 - ? t("address_books.moved", { name: addressBook.name }) - : t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name }); + ? t("address_books.moved", { name: shownName.addressBook(addressBook) }) + : t("address_books.moved_plural", { count: contactIds.length, name: shownName.addressBook(addressBook) }); toast.success(msg); } catch (error) { console.error('Failed to move contacts:', error); toast.error(t("address_books.move_failed")); } - }, [client, moveContactToAddressBook, t]); + }, [client, moveContactToAddressBook, t, shownName]); // Address books contacts can be moved to; "Move to…" needs a second one. const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]); @@ -1379,7 +1381,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => { const ok = await confirmDialog({ title: t("address_books.delete_title"), - message: t("address_books.confirm_delete", { name: book.name }), + message: t("address_books.confirm_delete", { name: shownName.addressBook(book) }), variant: "destructive", confirmText: t("address_books.delete"), }); @@ -1640,12 +1642,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClose={() => setRenamingAddressBook(null)} title={t("address_books.rename")} placeholder={t("address_books.name_label")} - defaultValue={renamingAddressBook?.name ?? ""} + defaultValue={renamingAddressBook ? shownName.addressBook(renamingAddressBook) : ""} confirmText={t("form.save")} cancelText={t("form.cancel")} onSubmit={(newName) => { const book = renamingAddressBook; - if (!client || !book || newName === book.name) return; + // The shown name kept as it was is no rename, also for the server's default name. + if (!client || !book || newName === book.name || newName === shownName.addressBook(book)) return; void (async () => { try { await renameAddressBook(client, book, newName); @@ -1705,7 +1708,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = { diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index ef249c3bb..67060d001 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store"; import { useAccountStore } from "@/stores/account-store"; +import { useCollectionNames } from "@/hooks/use-collection-names"; export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; @@ -124,6 +125,7 @@ export function ContactsSidebar({ className, multiAccountMode, }: ContactsSidebarProps) { + const shownName = useCollectionNames(); const t = useTranslations("contacts"); const tx = useTranslations("ux_pim_contact"); const router = useRouter(); @@ -137,7 +139,7 @@ export function ContactsSidebar({ const bookMenu = (book: AddressBook) => hasBookMenu ? { onContextMenu: (e: React.MouseEvent) => openBookContextMenu(e, book), onOpenMenu: (anchor: HTMLElement) => openBookMenuFrom(anchor, book, "end"), - menuLabel: t("row_actions", { name: book.name }), + menuLabel: t("row_actions", { name: shownName.addressBook(book) }), } : {}; const [collapsed, setCollapsed] = useState>(loadCollapsed); @@ -915,6 +917,7 @@ function AddressBookItem({ onSelect: () => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; }) { + const shownName = useCollectionNames(); const [isDragOver, setIsDragOver] = useState(false); const handleDragOver = useCallback((e: DragEvent) => { @@ -949,7 +952,7 @@ function AddressBookItem({ isActive={isActive} isDragOver={isDragOver} icon={} - label={book.name} + label={shownName.addressBook(book)} count={contactCount} extra={sharedOut ? : undefined} onSelect={onSelect} diff --git a/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx b/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx new file mode 100644 index 000000000..3cacf45ba --- /dev/null +++ b/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; + +import { RichTextEditor } from '../rich-text-editor'; + +// settings-p2-19: a three-line out-of-office reply doesn't need the +// composer's headings, code block and table. The short toolbar keeps bold, +// italic, underline, the lists and link. + +async function setup(toolbar?: 'full' | 'short') { + let ready = false; + render( {}} toolbar={toolbar} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); + return Array.from(screen.getByRole('toolbar', { name: 'formatting' }).querySelectorAll('[data-toolbar-item]')) + .map((el) => el.dataset.toolbarItem); +} + +describe('the short formatting toolbar', () => { + it('has only bold, italic, underline, the lists and link', async () => { + expect(await setup('short')).toEqual(['bold', 'italic', 'underline', 'bullet-list', 'ordered-list', 'link']); + }); + + it('leaves the full toolbar as it was', async () => { + const items = await setup(); + for (const name of ['font-size', 'heading-1', 'code-block', 'table', 'text-color', 'undo']) expect(items).toContain(name); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index f805e7cfc..5928868d5 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -53,6 +53,7 @@ import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; import { useAccountOwner } from '@/components/calendar/answer-bars'; +import { useCollectionNames } from "@/hooks/use-collection-names"; import { resolveTimeZone } from '@/lib/timezone'; interface InvitationChangeItem { @@ -380,6 +381,7 @@ interface CalendarInvitationBannerProps { type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error'; export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) { + const shownName = useCollectionNames(); const t = useTranslations('email_viewer.calendar_invitation'); const tEvent = useTranslations('ux_pim_event'); const describeError = useCalendarErrorMessage(); @@ -1260,7 +1262,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} /> - {cal.name} + {shownName.calendar(cal)} ))}
, diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index e0a9720a3..89835ab3d 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -179,6 +179,11 @@ interface RichTextEditorProps { * away entirely. */ toolbarPlacement?: 'top' | 'bottom' | 'hidden'; + /** + * "short" keeps bold, italic, underline, the lists and link: enough for a + * few lines such as an out-of-office reply. "full" is the composer's. + */ + toolbar?: 'full' | 'short'; /** The editing area's accessible name ("Message"). */ ariaLabel?: string; /** The body failed validation: exposed as aria-invalid on the editing area. */ @@ -479,6 +484,7 @@ export function RichTextEditor({ onMentionAdd, collapseToolbarOnMobile = false, toolbarPlacement = 'top', + toolbar = 'full', ariaLabel, ariaInvalid, ariaDescribedBy, @@ -928,7 +934,9 @@ export function RichTextEditor({ // A group that doesn't fit renders in the "More formatting" panel instead, in row order. const folded = new Set(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id)); const overflow: React.ReactNode[] = []; + const short = toolbar === 'short'; const place = (group: FoldableGroup, node: React.ReactNode) => { + if (short) return null; if (!folded.has(group)) return node; overflow.push({node}); return null; @@ -1033,6 +1041,7 @@ export function RichTextEditor({ > {/* Font size - two baseline-aligned A's read as "size" at a glance; once a size is chosen the button shows that number instead. */} + {!short && <>
+ } + {!short && <> )}
+ } diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx index 217031697..d39b0123f 100644 --- a/components/email/structured-mail/booking-calendar-actions.tsx +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -17,6 +17,7 @@ import { isDocumentRTL } from '@/i18n/direction'; import type { Email } from '@/lib/jmap/types'; import type { StructuredCard } from '@/lib/structured-mail'; import { useBookingCalendar } from './use-booking-calendar'; +import { useCollectionNames } from "@/hooks/use-collection-names"; const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50'; const PICKER_WIDTH = 208; @@ -47,6 +48,7 @@ function pickerPosition(rect: DOMRect, count: number): PickerPosition { * action (see useBookingCalendar). */ export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick }) { + const shownName = useCollectionNames(); const t = useTranslations('structured_mail_calendar'); const calendar = useBookingCalendar(booking, email); const { status, calendars, busy, error, notice } = calendar; @@ -186,7 +188,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]" > - {cal.name} + {shownName.calendar(cal)} ))} , diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 710a88785..028550883 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useRef } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { sortFolderTree } from "@/lib/move-menu"; import { Button } from "@/components/ui/button"; @@ -41,6 +41,16 @@ interface FilterRuleModalProps { suggestions?: RuleSuggestion[]; /** Offer "Also apply to existing messages in this folder". */ offerApplyToExisting?: boolean; + /** + * The folder the offer is about, by name ("Inbox"), when the dialog isn't + * opened from that folder (Settings › Filters). + */ + applyExistingFolder?: string; + /** + * How many messages in that folder the rule as typed would change. The + * offer says the number, and is greyed when it is 0. + */ + countExisting?: (rule: FilterRule) => Promise; mailboxes: Mailbox[]; /** Server cap on redirects per message (Sieve `maxNumberRedirects`). */ maxRedirects?: number | null; @@ -198,6 +208,8 @@ export function FilterRuleModal({ initialRule, suggestions = [], offerApplyToExisting = false, + applyExistingFolder, + countExisting, mailboxes, maxRedirects, forwardsBefore = 0, @@ -345,6 +357,30 @@ export function FilterRuleModal({ return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; }, [offerApplyToExisting, validConditions, validActions, periodOn]); + // How many messages the rule would change there, asked of the server a + // moment after the conditions and actions stop changing. null: not known + // (still asking, no action yet, or the server didn't answer). + const [existingCount, setExistingCount] = useState(null); + const countKey = canApplyToExisting && countExisting && validActions.length > 0 + ? JSON.stringify([matchType, validConditions, validActions]) + : null; + const countRef = useRef(countExisting); + countRef.current = countExisting; + useEffect(() => { + setExistingCount(null); + if (!countKey || !countRef.current) return; + const [type, conds, acts] = JSON.parse(countKey) as [FilterRule["matchType"], FilterCondition[], FilterAction[]]; + let stale = false; + const timer = setTimeout(() => { + countRef.current!({ id: "count", name: "", enabled: true, matchType: type, conditions: conds, actions: acts, stopProcessing: false }) + .then((count) => { if (!stale) setExistingCount(count); }) + .catch(() => { /* the offer stays, without a number */ }); + }, 400); + return () => { stale = true; clearTimeout(timer); }; + }, [countKey]); + const noneToApply = existingCount === 0; + const canTickApply = canApplyToExisting && !noneToApply; + // What keeps the rule from being saved, said under the part it is about // once Save was pressed, and updated as the user fixes it. const [showErrors, setShowErrors] = useState(false); @@ -427,8 +463,8 @@ export function FilterRuleModal({ includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, activeFrom: activeFrom ?? undefined, activeUntil: activeUntil ?? undefined, - }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]); + }, { applyToExisting: applyToExisting && canTickApply }); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canTickApply]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -954,22 +990,28 @@ export function FilterRuleModal({ setApplyToExisting(e.target.checked)} className="rounded border-input disabled:opacity-50" - aria-describedby={canApplyToExisting ? undefined : "applyToExistingHint"} + aria-describedby={canTickApply ? undefined : "applyToExistingHint"} /> - {!canApplyToExisting && ( + {!canTickApply && (

- {periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")} + {!canApplyToExisting + ? (periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")) + : t("apply_existing_none", { folder: applyExistingFolder ?? "" })}

)} diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index e48c83d54..b1717b352 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -34,6 +34,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden const t = useTranslations('identities.form'); const tValidation = useTranslations('identities.validation_errors'); const tOrg = useTranslations('org_signature'); + const tName = useTranslations('uxs2_ident.your_name'); const isEditing = !!identity; // Composing > Plain text only: the signature is plain text too. const plainTextMode = useSettingsStore((s) => s.plainTextMode); @@ -156,8 +157,9 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
{/* Name */}
+ {/* "Your name", like the Account page: the one name recipients see. */} setFormData({ ...formData, name: e.target.value })} - placeholder={t('name_placeholder')} disabled={isSubmitting} className={errors.name ? 'border-destructive' : ''} - aria-describedby={errors.name ? 'name-error' : undefined} + aria-describedby={errors.name ? 'name-error' : 'identity-name-hint'} aria-invalid={!!errors.name} /> +

{tName('description')}

{errors.name && (
-

{t('create_new')}

+

{tAddr('add')}

void cancelForm()} @@ -247,7 +248,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr className="mb-6 w-full sm:w-auto" > - {t('create_new')} + {tAddr('add')} )} @@ -261,7 +262,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr {editingId === identity.id ? (

- {t('edit_identity')} + {tAddr('edit')}

)} setEditingId(identity.id)} disabled={!!editingId || isCreating} > @@ -358,7 +359,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr {identities.length === 0 && !isCreating && (
-

{t('no_identities')}

+

{tAddr('count', { count: 0 })}

)}
diff --git a/components/layout/__tests__/account-switcher-remove.test.tsx b/components/layout/__tests__/account-switcher-remove.test.tsx index f287edb60..a93fdcacd 100644 --- a/components/layout/__tests__/account-switcher-remove.test.tsx +++ b/components/layout/__tests__/account-switcher-remove.test.tsx @@ -1,9 +1,8 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; -// Removing an account from the popover: the app's confirm dialog instead of -// the browser's, a control a touch screen can reach, and sign-out rows in -// the menu's plain text colour. +// The popover's rows: no remove button (settings-p2-11), whole addresses, +// and sign-out rows in the menu's plain text colour. const { AccountSwitcher } = await import('../account-switcher'); const { useAccountStore } = await import('@/stores/account-store'); @@ -18,35 +17,34 @@ function account(id: string) { }; } -const removeAccount = vi.fn(); - beforeEach(() => { - removeAccount.mockClear(); useAccountStore.setState({ accounts: [account('a'), account('b')] } as never); - useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never); + useAuthStore.setState({ activeAccountId: 'a' } as never); useSettingsStore.setState({ interfaceLayout: 'default' } as never); }); describe('removing an account from the popover', () => { - it('asks with the app dialog, not the browser, and removes on confirm', async () => { - const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + // settings-p2-11: each row had a hidden X next to "Sign out of …" below. + // An account you're not in is removed under Settings > Account, where + // "Manage accounts…" leads. + it('has no remove button on the rows', () => { render(); fireEvent.click(screen.getByTestId('account-switcher')); - fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' })); - - expect(browserConfirm).not.toHaveBeenCalled(); - const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' }); - expect(removeAccount).not.toHaveBeenCalled(); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); }); - expect(removeAccount).toHaveBeenCalledWith('b'); - expect(dialog).not.toBeInTheDocument(); - browserConfirm.mockRestore(); + expect(screen.queryByRole('menuitem', { name: 'remove_account' })).toBeNull(); + expect(screen.getAllByTestId('account-option')).toHaveLength(2); }); - it('keeps the remove control visible on a touch screen', () => { + // The address is what tells two same-named accounts apart, and it was cut + // ("dso-ux-settings@example…"). + it('shows the whole address, wrapped, with the full text as a tooltip', () => { + const long = (id: string, email: string) => ({ ...account(id), email, username: email, label: 'Sam', displayName: 'Sam' }); + useAccountStore.setState({ accounts: [long('a', 'dso-ux-settings@example.org'), long('b', 'dso-ux-settings-b@example.org')] } as never); render(); fireEvent.click(screen.getByTestId('account-switcher')); - expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100'); + const title = screen.getByText('dso-ux-settings-b@example.org'); + expect(title.className).not.toContain('truncate'); + expect(title.className).toContain('wrap-anywhere'); + expect(title.getAttribute('title')).toBe('dso-ux-settings-b@example.org'); }); it('draws "sign out of all accounts" like the other rows, start-aligned', () => { diff --git a/components/layout/__tests__/sidebar-vacation-banner.test.tsx b/components/layout/__tests__/sidebar-vacation-banner.test.tsx index 1d2c8ff25..a34168eed 100644 --- a/components/layout/__tests__/sidebar-vacation-banner.test.tsx +++ b/components/layout/__tests__/sidebar-vacation-banner.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; // "Vacation responder is active" opens Settings > Out of Office, not @@ -21,10 +21,13 @@ vi.mock('@/hooks/use-mailbox-drop', () => ({ })); vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) })); vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); const { Sidebar } = await import('../sidebar'); const { useVacationStore } = await import('@/stores/vacation-store'); const { buildSettingsPath } = await import('@/lib/deep-links'); +const { useAuthStore } = await import('@/stores/auth-store'); describe('vacation banner', () => { it('opens the Out of Office settings', () => { @@ -34,4 +37,25 @@ describe('vacation banner', () => { expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation')); expect(buildSettingsPath('vacation')).not.toBe('/settings'); }); + + // settings-p2-12: the banner says "Out of office is on", as the setting is + // called, and turns it off without a trip to Settings, like Gmail's "End now". + it('turns out of office off on the server', async () => { + const update = vi.fn(async () => true); + useAuthStore.setState({ client: {} as never }); + useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' })); + await waitFor(() => expect(update).toHaveBeenCalledWith(expect.anything(), { isEnabled: false }, undefined)); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('says so when turning it off fails', async () => { + const update = vi.fn(async () => { throw new Error('down'); }); + useAuthStore.setState({ client: {} as never }); + useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacation_save_failed')); + }); }); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 57e4df9a9..aa1292cde 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -12,7 +12,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { HelpMenuItems } from "@/components/layout/help-links"; -import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName } from "@/lib/account-utils"; +import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName, accountSharesAddress } from "@/lib/account-utils"; import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; @@ -20,9 +20,6 @@ import { useRouter } from "@/i18n/navigation"; import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state"; import { Avatar } from "@/components/ui/avatar"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; -import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { IconButton } from "@/components/ui/icon-button"; import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn"; import { buildSettingsPath } from "@/lib/deep-links"; @@ -118,9 +115,7 @@ export function AccountSwitcher({ const activeAccount = accounts.find((a) => a.id === activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); - const removeAccount = useAuthStore((s) => s.removeAccount); const logoutAll = useAuthStore((s) => s.logoutAll); - const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); const tWipe = useTranslations("offlineWipe"); // An account that signed out by itself beside this one: its data waits. const [, noteWaitsChanged] = useState(0); @@ -218,20 +213,6 @@ export function AccountSwitcher({ router.push(buildSettingsPath("account", "browser") as never); }; - const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => { - e.stopPropagation(); - const label = toUnicodeEmail(account.email || account.username); - // The popover closes first: the dialog is the only thing asking now. - setOpen(false); - const ok = await confirm({ - title: t("remove_account_title"), - message: t("remove_account_confirm", { account: label }), - confirmText: t("remove_account"), - variant: "destructive", - }); - if (ok) removeAccount(account.id); - }; - const handleLogout = () => { setOpen(false); logout(); @@ -316,9 +297,7 @@ export function AccountSwitcher({ const unreadFor = (account: AccountEntry) => mvPopover || severalAccounts ? inboxUnread(unreadByAccount[account.id]) : undefined; // The server is only worth a line when it is what tells two rows apart. - const addressKey = (account: AccountEntry) => toUnicodeEmail(account.email || account.username).trim().toLowerCase(); - const sharesAddress = (account: AccountEntry) => - accounts.some((other) => other.id !== account.id && addressKey(other) === addressKey(account)); + const sharesAddress = (account: AccountEntry) => accountSharesAddress(account, accounts); const connectedIds = accounts.filter((account) => account.isConnected).map((account) => account.id); const allInboxesUnread = totalInboxUnread(connectedIds, unreadByAccount); // What waits in the accounts not on screen: a dot on the avatar says so. @@ -341,7 +320,7 @@ export function AccountSwitcher({ : "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0", className )} - title={variant !== "expanded" ? [displayName, othersUnreadText].filter(Boolean).join("\n") : undefined} + title={[displayName, displaySubtitle, variant !== "expanded" ? othersUnreadText : null].filter(Boolean).join("\n")} aria-label={othersUnreadText ? `${t("switch_account")}, ${othersUnreadText}` : t("switch_account")} aria-expanded={open} aria-haspopup="menu" @@ -414,8 +393,8 @@ export function AccountSwitcher({
-

{displayName}

- {displaySubtitle &&

{displaySubtitle}

} +

{displayName}

+ {displaySubtitle &&

{displaySubtitle}

}
)} @@ -479,7 +458,7 @@ export function AccountSwitcher({ className={cn( "w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors", isActive ? "bg-accent/50" : "hover:bg-muted", - (!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7") + isDraggable && "pe-7" )} role="menuitem" disabled={isActive} @@ -494,7 +473,7 @@ export function AccountSwitcher({
- + {toUnicodeEmail(text.title)} {account.isDefault && accounts.length > 1 && ( @@ -505,7 +484,7 @@ export function AccountSwitcher({
{text.subtitle && ( -

+

{toUnicodeEmail(text.subtitle)}

)} @@ -524,25 +503,11 @@ export function AccountSwitcher({ {isDraggable && ( )} - {!isActive && !account.isDefault && ( - // Shown on hover and focus, and always on a touch screen (R7). - void handleRemove(e, account)} - role="menuitem" - label={t("remove_account")} - className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity group-hover/acct:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:bg-destructive/10 hover:text-destructive" - > - - - )}
); })} @@ -718,7 +683,6 @@ export function AccountSwitcher({
, document.body )} - ); } diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 64ede6c8f..b07adb2d4 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -77,6 +77,7 @@ import { sidebarFolded, useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { unifiedLoadProgress } from "@/lib/unified-mailbox"; import { useVacationStore } from "@/stores/vacation-store"; +import { useManagedAccountStore } from "@/stores/managed-account-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store"; @@ -1106,26 +1107,59 @@ function DemoBanner({ ruled }: { ruled: boolean }) { ); } -function VacationBanner() { +export function VacationBanner() { const t = useTranslations('sidebar'); + const tNotifications = useTranslations('notifications'); + const tFilters = useTranslations('settings.filters'); const router = useRouter(); - const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported }))); + const { isEnabled, isSupported, isSaving } = useVacationStore(useShallow((state) => ({ + isEnabled: state.isEnabled, isSupported: state.isSupported, isSaving: state.isSaving, + }))); + // The store holds the reply of the account Settings last loaded it for. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + const client = useAuthStore((s) => s.client); + const [turningOff, setTurningOff] = useState(false); if (!isSupported || !isEnabled) return null; + // Like Gmail's "End now": off on the server, without opening Settings. + const turnOff = async () => { + if (!client || turningOff) return; + setTurningOff(true); + try { + await useVacationStore.getState().updateVacationResponse(client, { isEnabled: false }, managedAccountId ?? undefined); + } catch { + toast.error(tNotifications('vacation_save_failed')); + } finally { + setTurningOff(false); + } + }; + return ( - + + + ); } diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index af504abb9..7833a66e2 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -128,7 +128,7 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments"; import { useSearchHistoryStore } from "@/stores/search-history-store"; -import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner"; +import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { FirstStartNoticeBar } from "@/components/ui/first-start-notice"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { AppMain } from "@/components/layout/app-landmarks"; @@ -288,7 +288,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const linkSegments = useLiteLinkSegments('mail', routeSegments); const t = useTranslations(); // First-start notices wait until the welcome tips are closed: one thing at a time. - const welcomeDue = useWelcomeBannerDue(); // The page skeleton the gates below draw: the same one the server sent. const skeletonLabels = useSkeletonLabels(); const tNotifications = useTranslations('notifications'); @@ -6186,7 +6185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { unless an enabled plugin registered tabs via api.tabs.set. */} {!isScheduledView && } {!emptyPaneBesideList && } -