feat: add address book, fix email layout, update dependencies
- Address book with JMAP sync and local fallback (contacts CRUD, search/filter, composer autocomplete, i18n for 8 languages) - Fix email layout: remove horizontal scroll, left-side clipping, and empty spaces from blocked external images in newsletters - Update all dependencies to latest compatible versions - Expand i18n from 3 to 8 languages (added ES, IT, DE, NL, PT) - Upgrade Next.js to 16.1.6 for security patches
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
|
||||
@@ -108,6 +109,70 @@ export function EmailComposer({
|
||||
const [subAddressTag, setSubAddressTag] = useState<string>('');
|
||||
|
||||
const { client, identities, primaryIdentity } = useAuthStore();
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
}
|
||||
|
||||
const lastPart = value.split(',').pop()?.trim() || '';
|
||||
if (lastPart.length < 1) {
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
||||
const results = getAutocomplete(lastPart);
|
||||
setAutocompleteResults(results);
|
||||
setActiveAutoField(results.length > 0 ? field : null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}, 200);
|
||||
}, [getAutocomplete]);
|
||||
|
||||
const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
|
||||
|
||||
const parts = getter.split(',');
|
||||
parts.pop();
|
||||
parts.push(` ${email}`);
|
||||
setter(parts.join(',').replace(/^,\s*/, ''));
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
|
||||
const ref = field === 'to' ? toInputRef : field === 'cc' ? ccInputRef : bccInputRef;
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => {
|
||||
if (!activeAutoField || autocompleteResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setAutoSelectedIndex((prev) => Math.min(prev + 1, autocompleteResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
|
||||
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field);
|
||||
} else if (e.key === 'Escape') {
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -258,6 +323,14 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
|
||||
}, [to, cc, bcc, subject, body, attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
@@ -421,15 +494,31 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('to_placeholder')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={toInputRef}
|
||||
type="email"
|
||||
placeholder={t('to_placeholder')}
|
||||
value={to}
|
||||
onChange={(e) => {
|
||||
setTo(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'to');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'to' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -451,28 +540,60 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{showCc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('cc_placeholder')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={ccInputRef}
|
||||
type="email"
|
||||
placeholder={t('cc_placeholder')}
|
||||
value={cc}
|
||||
onChange={(e) => {
|
||||
setCc(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'cc');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'cc' ? 'autocomplete-cc' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'cc' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('bcc_placeholder')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={bccInputRef}
|
||||
type="email"
|
||||
placeholder={t('bcc_placeholder')}
|
||||
value={bcc}
|
||||
onChange={(e) => {
|
||||
setBcc(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'bcc');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'bcc' ? 'autocomplete-bcc' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'bcc' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -569,4 +690,43 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutocompleteDropdown({
|
||||
id,
|
||||
results,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: {
|
||||
id: string;
|
||||
results: Array<{ name: string; email: string }>;
|
||||
selectedIndex: number;
|
||||
onSelect: (email: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
id={`autocomplete-option-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
|
||||
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(r.email);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium truncate">{r.name || r.email}</span>
|
||||
{r.name && (
|
||||
<span className="text-muted-foreground truncate"><{r.email}></span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -466,11 +466,16 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
|
||||
// Remove the hook after sanitization
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
// Collapse empty containers left behind by blocked images
|
||||
if (shouldBlockExternal && blockedExternalContent) {
|
||||
cleanHtml = collapseBlockedImageContainers(cleanHtml);
|
||||
}
|
||||
|
||||
// Update blocked content state
|
||||
if (blockedExternalContent && !hasBlockedContent) {
|
||||
setHasBlockedContent(true);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -296,11 +296,13 @@ function EmailCard({
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||
|
||||
let finalHtml = sanitized;
|
||||
if (blockedExternalContent) {
|
||||
setHasBlockedContent(true);
|
||||
finalHtml = collapseBlockedImageContainers(sanitized);
|
||||
}
|
||||
|
||||
return { html: sanitized, isHtml: true };
|
||||
return { html: finalHtml, isHtml: true };
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
|
||||
Reference in New Issue
Block a user