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() {
,
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 && (