fix: let "Add sender manually" open the address field when there are no trusted senders yet

This commit is contained in:
Linus Rath
2026-10-28 03:17:33 +01:00
parent 6c5aead20b
commit 11c8c8e1a8
2 changed files with 147 additions and 191 deletions
@@ -0,0 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from '@/stores/settings-store';
import { TrustedSendersModal } from '../trusted-senders-modal';
beforeEach(() => {
useSettingsStore.setState({ trustedSenders: [], trustedSendersAddressBook: false } as never);
});
describe('trusted senders', () => {
it('offers an address field when the list is empty', () => {
render(<TrustedSendersModal isOpen onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'add_manually' }));
const field = screen.getByRole('textbox', { name: 'add_placeholder' });
fireEvent.change(field, { target: { value: 'News@Example.org' } });
fireEvent.keyDown(field, { key: 'Enter' });
expect(useSettingsStore.getState().trustedSenders).toEqual(['news@example.org']);
});
it('closes on the first Escape after "Add sender manually"', () => {
const onClose = vi.fn();
render(<TrustedSendersModal isOpen onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'add_manually' }));
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+120 -191
View File
@@ -2,8 +2,13 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "@/components/icons";
import { ShieldCheck, Search, Trash2, Plus } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { IconButton } from "@/components/ui/icon-button";
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -16,7 +21,6 @@ interface TrustedSendersModalProps {
export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProps) {
const t = useTranslations("settings.email_behavior.trusted_senders");
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore();
@@ -57,40 +61,6 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
// Show search only when 5+ senders
const showSearch = activeSenders.length >= 5;
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (isAdding) {
setIsAdding(false);
setNewEmail("");
setEmailError("");
} else {
onClose();
}
}
};
if (isOpen) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, isAdding, onClose]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen, onClose]);
// Focus input when adding mode is enabled
useEffect(() => {
if (isAdding && inputRef.current) {
@@ -113,6 +83,14 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
return emailRegex.test(email);
};
const addSender = async (email: string) => {
if (trustedSendersAddressBook && client) {
await addToTrustedSendersBook(client, email);
} else {
addTrustedSender(email);
}
};
const handleAddSender = async () => {
const trimmedEmail = newEmail.trim().toLowerCase();
@@ -133,11 +111,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
setIsSubmitting(true);
try {
if (trustedSendersAddressBook && client) {
await addToTrustedSendersBook(client, trimmedEmail);
} else {
addTrustedSender(trimmedEmail);
}
await addSender(trimmedEmail);
setNewEmail("");
setIsAdding(false);
setEmailError("");
@@ -156,162 +130,117 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleAddSender();
}
};
const addRow = isAdding ? (
<div className="space-y-2">
<div className="flex gap-2">
<input
ref={inputRef}
type="email"
placeholder={t("add_placeholder")}
aria-label={t("add_placeholder")}
aria-invalid={!!emailError}
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setEmailError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddSender();
}
}}
className={cn(
"flex-1 min-w-0 px-3 py-2 text-sm bg-background border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50",
emailError ? "border-destructive" : "border-border"
)}
/>
<Button size="sm" onClick={() => void handleAddSender()} disabled={isSubmitting}>
{isSubmitting ? <Spinner size="sm" /> : t("add_button")}
</Button>
</div>
{emailError && (
<p className="text-xs text-destructive">{emailError}</p>
)}
</div>
) : (
<Button variant="ghost" size="sm" className="-ms-3" onClick={() => setIsAdding(true)}>
<Plus className="w-4 h-4" />
{t("add_manually")}
</Button>
);
if (!isOpen) return null;
const isEmpty = !isLoading && activeSenders.length === 0;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="trusted-senders-title"
className={cn(
"bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-md max-h-[60vh] overflow-hidden flex flex-col",
"animate-in zoom-in-95 duration-200"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<ShieldCheck className="w-5 h-5 text-primary" />
<h2 id="trusted-senders-title" className="text-lg font-semibold text-foreground">
{t("modal_title")}
</h2>
<Dialog
isOpen={isOpen}
onClose={onClose}
title={t("modal_title")}
dirty={isAdding && newEmail.trim() !== ""}
className="max-h-[60vh]"
bodyClassName="px-0 pb-0 mt-4 border-y border-border"
footer={!isLoading && (!isEmpty || isAdding) ? <div className="w-full pt-4">{addRow}</div> : undefined}
>
{/* Search (only when 5+ senders) */}
{showSearch && (
<div className="px-6 py-3 border-b border-border">
<div className="relative">
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={t("search_placeholder")}
aria-label={t("search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full ps-9 pe-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<button
onClick={onClose}
aria-label={t("close")}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
)}
{/* Search (only when 5+ senders) */}
{showSearch && (
<div className="px-6 py-3 border-b border-border flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full ps-9 pe-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : activeSenders.length === 0 ? (
/* Empty State */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground/50 mb-4" />
<h3 className="text-base font-medium text-foreground mb-2">
{t("empty_title")}
</h3>
<p className="text-sm text-muted-foreground max-w-[280px] mb-6">
{t("empty_description")}
</p>
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
{isLoading ? (
<PaneSpinner />
) : isEmpty ? (
// The add row sits in the footer once "Add sender manually" is picked,
// empty list or not.
<EmptyState
compact
icon={ShieldCheck}
title={t("empty_title")}
description={t("empty_description")}
actions={!isAdding ? (
<Button size="sm" onClick={() => setIsAdding(true)}>
<Plus className="w-4 h-4" />
{t("add_manually")}
</Button>
) : undefined}
/>
) : filteredSenders.length === 0 ? (
<EmptyState compact icon={Search} title={t("no_results")} />
) : (
<ul className="divide-y divide-border">
{filteredSenders.map((email) => (
<li
key={email}
className="flex items-center gap-3 px-6 py-3 hover:bg-muted/50 transition-colors group"
>
<Avatar email={email} size="sm" />
<span className="flex-1 text-sm text-foreground truncate" title={email}>
{email}
</span>
<IconButton
label={`${t("remove")} ${email}`}
title={t("remove")}
onClick={() => void handleRemoveSender(email)}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
>
<Plus className="w-4 h-4" />
{t("add_manually")}
</button>
</div>
) : filteredSenders.length === 0 ? (
/* No search results */
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results")}
</p>
</div>
) : (
/* Sender list */
<div className="divide-y divide-border">
{filteredSenders.map((email) => (
<div
key={email}
className="flex items-center gap-3 px-6 py-3 hover:bg-muted/50 transition-colors group"
>
<Avatar email={email} size="sm" />
<span className="flex-1 text-sm text-foreground truncate">
{email}
</span>
<button
onClick={() => handleRemoveSender(email)}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label={`${t("remove")} ${email}`}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Footer - Add sender */}
{!isLoading && activeSenders.length > 0 && (
<div className="px-6 py-4 border-t border-border flex-shrink-0">
{isAdding ? (
<div className="space-y-2">
<div className="flex gap-2">
<input
ref={inputRef}
type="email"
placeholder={t("add_placeholder")}
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setEmailError("");
}}
onKeyDown={handleKeyDown}
className={cn(
"flex-1 px-3 py-2 text-sm bg-background border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50",
emailError ? "border-destructive" : "border-border"
)}
/>
<button
onClick={handleAddSender}
disabled={isSubmitting}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : t("add_button")}
</button>
</div>
{emailError && (
<p className="text-xs text-destructive">{emailError}</p>
)}
</div>
) : (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="w-4 h-4" />
{t("add_manually")}
</button>
)}
</div>
)}
</div>
</div>
<Trash2 />
</IconButton>
</li>
))}
</ul>
)}
</Dialog>
);
}