mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: let "Add sender manually" open the address field when there are no trusted senders yet
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user