Files
SRCmail/components/calendar/participant-input.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
2026-02-17 01:09:57 +01:00

164 lines
5.8 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
name: string;
email: string;
}
interface ParticipantInputProps {
participants: Participant[];
onAdd: (participant: Participant) => void;
onRemove: (email: string) => void;
disabled?: boolean;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
const t = useTranslations("calendar.participants");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateSuggestions = useCallback((q: string) => {
if (q.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
const results = useContactStore.getState().getAutocomplete(q);
const existing = new Set(participants.map(p => p.email.toLowerCase()));
const filtered = results.filter(r => !existing.has(r.email.toLowerCase()));
setSuggestions(filtered.slice(0, 8));
setShowSuggestions(filtered.length > 0);
setActiveIndex(-1);
}, [participants]);
const handleInputChange = useCallback((value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => updateSuggestions(value), 200);
}, [updateSuggestions]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const addParticipant = useCallback((p: Participant) => {
if (!p.email || !EMAIL_REGEX.test(p.email)) return;
if (participants.some(e => e.email.toLowerCase() === p.email.toLowerCase())) return;
onAdd(p);
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
inputRef.current?.focus();
}, [participants, onAdd]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" && showSuggestions) {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp" && showSuggestions) {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < suggestions.length) {
addParticipant(suggestions[activeIndex]);
} else if (query.trim() && EMAIL_REGEX.test(query.trim())) {
addParticipant({ name: "", email: query.trim() });
}
} else if (e.key === "Escape") {
setShowSuggestions(false);
}
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
setTimeout(() => setShowSuggestions(false), 200);
}, []);
return (
<div className="space-y-2">
<div className="relative">
<Input
ref={inputRef}
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }}
onBlur={handleBlur}
placeholder={t("email_placeholder")}
disabled={disabled}
role="combobox"
aria-expanded={showSuggestions}
aria-controls="participant-suggestions"
aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined}
aria-autocomplete="list"
/>
{showSuggestions && (
<ul
ref={listRef}
id="participant-suggestions"
role="listbox"
className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
>
{suggestions.map((s, i) => (
<li
key={s.email}
id={`suggestion-${i}`}
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</li>
))}
</ul>
)}
</div>
{participants.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{participants.map((p) => (
<span
key={p.email}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
<span className="truncate">{p.name || p.email}</span>
{!disabled && (
<button
type="button"
onClick={() => onRemove(p.email)}
className="flex-shrink-0 p-0.5 rounded-full hover:bg-muted-foreground/20 transition-colors min-w-[20px] min-h-[20px] flex items-center justify-center"
aria-label={`${t("remove")} ${p.name || p.email}`}
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
</div>
);
}