mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import { Search, X, RefreshCw, Loader2, Lock, AlertCircle, BookUser, CheckSquare, Square, ChevronLeft, ChevronRight, Menu, Users, MapPin, Box } from "@/components/icons";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { cn } from "@/lib/utils";
|
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
|
import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
|
|
|
|
export const DIRECTORY_PAGE_SIZE = 50;
|
|
|
|
/** Case-insensitive key of a directory entry, used for selection. */
|
|
export function directoryKey(entry: DirectoryEntry): string {
|
|
return entry.email.toLowerCase();
|
|
}
|
|
|
|
export function filterDirectory(entries: DirectoryEntry[], query: string, locale?: string): DirectoryEntry[] {
|
|
const q = query.trim().toLowerCase();
|
|
const matched = q
|
|
? entries.filter((e) =>
|
|
e.name.toLowerCase().includes(q) ||
|
|
e.email.toLowerCase().includes(q) ||
|
|
(e.description?.toLowerCase().includes(q) ?? false))
|
|
: entries;
|
|
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
|
return [...matched].sort((a, b) => collator.compare(a.name, b.name) || collator.compare(a.email, b.email));
|
|
}
|
|
|
|
function TypeIcon({ type }: { type: DirectoryEntry["type"] }) {
|
|
if (type === "group") return <Users className="w-3.5 h-3.5" />;
|
|
if (type === "location") return <MapPin className="w-3.5 h-3.5" />;
|
|
if (type === "resource") return <Box className="w-3.5 h-3.5" />;
|
|
return null;
|
|
}
|
|
|
|
interface DirectoryBrowserProps {
|
|
client: IJMAPClient | null;
|
|
/** Keys (lowercased addresses) of the checked entries. */
|
|
selected: Set<string>;
|
|
onSelectedChange: (next: Set<string>) => void;
|
|
/** Contacts view: clicking a row opens it. Without this, clicking toggles it. */
|
|
onOpen?: (entry: DirectoryEntry) => void;
|
|
activeKey?: string | null;
|
|
onMenuClick?: () => void;
|
|
autoFocusSearch?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* The company directory (RFC 9670 principals the server lists for this
|
|
* account), read-only: search, pages, multi-select. Used by the Contacts
|
|
* "Directory" entry and the composer's recipient picker.
|
|
*/
|
|
export function DirectoryBrowser({
|
|
client,
|
|
selected,
|
|
onSelectedChange,
|
|
onOpen,
|
|
activeKey,
|
|
onMenuClick,
|
|
autoFocusSearch,
|
|
className,
|
|
}: DirectoryBrowserProps) {
|
|
const t = useTranslations("contacts.directory");
|
|
const tContacts = useTranslations("contacts");
|
|
const locale = useLocale();
|
|
const entries = useContactStore((s) => s.directoryPrincipals);
|
|
const status = useContactStore((s) => s.directoryStatus);
|
|
const fetchDirectory = useContactStore((s) => s.fetchDirectory);
|
|
const [query, setQuery] = useState("");
|
|
const [page, setPage] = useState(0);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
// The directory loads at sign-in; fetch here only if that never ran.
|
|
useEffect(() => {
|
|
if (client && status === "idle") void fetchDirectory(client);
|
|
}, [client, status, fetchDirectory]);
|
|
|
|
useEffect(() => {
|
|
if (autoFocusSearch) searchRef.current?.focus();
|
|
}, [autoFocusSearch]);
|
|
|
|
const filtered = useMemo(() => filterDirectory(entries, query, locale), [entries, query, locale]);
|
|
const pageCount = Math.max(1, Math.ceil(filtered.length / DIRECTORY_PAGE_SIZE));
|
|
const currentPage = Math.min(page, pageCount - 1);
|
|
const pageEntries = filtered.slice(currentPage * DIRECTORY_PAGE_SIZE, (currentPage + 1) * DIRECTORY_PAGE_SIZE);
|
|
|
|
const loading = status === "loading" || (status === "idle" && !!client);
|
|
const refresh = () => { if (client) void fetchDirectory(client); };
|
|
|
|
const toggle = (entry: DirectoryEntry) => {
|
|
const next = new Set(selected);
|
|
const key = directoryKey(entry);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
onSelectedChange(next);
|
|
};
|
|
|
|
const pageAllSelected = pageEntries.length > 0 && pageEntries.every((e) => selected.has(directoryKey(e)));
|
|
const togglePage = () => {
|
|
const next = new Set(selected);
|
|
for (const e of pageEntries) {
|
|
if (pageAllSelected) next.delete(directoryKey(e));
|
|
else next.add(directoryKey(e));
|
|
}
|
|
onSelectedChange(next);
|
|
};
|
|
|
|
let placeholder: React.ReactNode = null;
|
|
if (entries.length === 0) {
|
|
if (loading) {
|
|
placeholder = (
|
|
<StatePanel icon={<Loader2 className="w-6 h-6 animate-spin" />} title={t("loading")} />
|
|
);
|
|
} else if (status === "unavailable" || !client) {
|
|
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={t("unavailable_title")} hint={t("unavailable_hint")} />;
|
|
} else if (status === "forbidden") {
|
|
placeholder = <StatePanel icon={<Lock className="w-6 h-6" />} title={t("forbidden_title")} hint={t("forbidden_hint")} onRetry={refresh} retryLabel={t("retry")} />;
|
|
} else if (status === "error") {
|
|
placeholder = <StatePanel icon={<AlertCircle className="w-6 h-6" />} title={t("error_title")} hint={t("error_hint")} onRetry={refresh} retryLabel={t("retry")} />;
|
|
} else {
|
|
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={t("empty_title")} hint={t("empty_hint")} />;
|
|
}
|
|
} else if (filtered.length === 0) {
|
|
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={t("no_match")} />;
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex flex-col h-full min-h-0", className)}>
|
|
<div className="border-b border-border bg-background px-3 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
{onMenuClick && (
|
|
<button
|
|
type="button"
|
|
onClick={onMenuClick}
|
|
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
aria-label={tContacts("open_categories")}
|
|
>
|
|
<Menu className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={togglePage}
|
|
disabled={pageEntries.length === 0}
|
|
className={cn(
|
|
"flex-shrink-0 p-2 rounded-md transition-colors disabled:opacity-50",
|
|
selected.size > 0 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
)}
|
|
title={pageAllSelected ? t("deselect_page") : t("select_page")}
|
|
aria-label={pageAllSelected ? t("deselect_page") : t("select_page")}
|
|
>
|
|
{pageAllSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
|
</button>
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
ref={searchRef}
|
|
type="text"
|
|
placeholder={t("search_placeholder")}
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
|
className={cn("ps-9 h-9", query && "pe-8")}
|
|
aria-label={t("search_placeholder")}
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setQuery(""); setPage(0); }}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={tContacts("clear_search")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
disabled={!client || status === "loading" || status === "unavailable"}
|
|
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
|
title={t("refresh")}
|
|
aria-label={t("refresh")}
|
|
>
|
|
<RefreshCw className={cn("w-4 h-4", status === "loading" && "animate-spin")} />
|
|
</button>
|
|
</div>
|
|
{entries.length > 0 && status === "error" && (
|
|
<p role="alert" className="mt-2 text-xs text-destructive">{t("refresh_failed")}</p>
|
|
)}
|
|
{/* A refused listing (Stalwart 1.0 with directory queries off) still
|
|
holds the principals the user knows; nothing failed to refresh. */}
|
|
{entries.length > 0 && status === "forbidden" && (
|
|
<p role="status" className="mt-2 text-xs text-muted-foreground">{t("forbidden_hint")}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={t("title")}>
|
|
{placeholder ?? pageEntries.map((entry) => {
|
|
const key = directoryKey(entry);
|
|
const checked = selected.has(key);
|
|
const showEmail = entry.name !== entry.email;
|
|
return (
|
|
<div
|
|
key={key}
|
|
role="listitem"
|
|
onClick={() => (onOpen ? onOpen(entry) : toggle(entry))}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-4 py-2 border-b border-border cursor-pointer select-none transition-colors",
|
|
activeKey === key ? "bg-selection" : "hover:bg-muted",
|
|
checked && activeKey !== key && "bg-selection/60",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
aria-checked={checked}
|
|
aria-label={t("select_entry", { name: entry.name })}
|
|
onClick={(e) => { e.stopPropagation(); toggle(entry); }}
|
|
className={cn("p-1 rounded flex-shrink-0 hover:bg-muted/50", checked ? "text-primary" : "text-muted-foreground")}
|
|
>
|
|
{checked ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
|
</button>
|
|
<Avatar name={entry.name} email={entry.email} size="sm" className="flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium">
|
|
<span className="truncate">{entry.name}</span>
|
|
{entry.type && entry.type !== "individual" && (
|
|
<span className="text-muted-foreground flex-shrink-0" title={t(`type.${entry.type}`)}>
|
|
<TypeIcon type={entry.type} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showEmail && <div className="text-xs text-muted-foreground truncate">{entry.email}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{filtered.length > 0 && (
|
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border text-xs text-muted-foreground">
|
|
<span className="tabular-nums">
|
|
{t("range", {
|
|
from: currentPage * DIRECTORY_PAGE_SIZE + 1,
|
|
to: currentPage * DIRECTORY_PAGE_SIZE + pageEntries.length,
|
|
total: filtered.length,
|
|
})}
|
|
</span>
|
|
{pageCount > 1 && (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage(currentPage - 1)}
|
|
disabled={currentPage === 0}
|
|
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
aria-label={t("previous_page")}
|
|
>
|
|
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
|
|
</button>
|
|
<span className="tabular-nums">{t("page", { page: currentPage + 1, count: pageCount })}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage(currentPage + 1)}
|
|
disabled={currentPage >= pageCount - 1}
|
|
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
aria-label={t("next_page")}
|
|
>
|
|
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatePanel({ icon, title, hint, onRetry, retryLabel }: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
hint?: string;
|
|
onRetry?: () => void;
|
|
retryLabel?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center text-center gap-2 px-6 py-12 text-muted-foreground">
|
|
{icon}
|
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
|
{hint && <p className="text-xs max-w-xs">{hint}</p>}
|
|
{onRetry && (
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="mt-1 text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{retryLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|