feat: implement tagging functionality for emails with drag-and-drop support
This commit is contained in:
@@ -105,6 +105,8 @@ export default function Home() {
|
|||||||
clearSearchFilters,
|
clearSearchFilters,
|
||||||
toggleAdvancedSearch,
|
toggleAdvancedSearch,
|
||||||
advancedSearch,
|
advancedSearch,
|
||||||
|
selectedKeyword,
|
||||||
|
selectKeyword,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
// Keyboard shortcuts handlers
|
// Keyboard shortcuts handlers
|
||||||
@@ -594,6 +596,25 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTagSelect = async (keywordId: string | null) => {
|
||||||
|
selectKeyword(keywordId);
|
||||||
|
|
||||||
|
// On mobile, close sidebar and go to list view
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
|
||||||
|
// On tablet, show the list again
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
await fetchEmails(client);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
@@ -852,7 +873,9 @@ export default function Home() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
|
selectedKeyword={selectedKeyword}
|
||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
|
onTagSelect={handleTagSelect}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 5100, receivedAt: daysAgo(1),
|
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
||||||
@@ -151,7 +151,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 6200, receivedAt: daysAgo(0),
|
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0),
|
||||||
from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }],
|
from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: '[jmap-webmail] New issue: Add dark mode toggle (#42)',
|
subject: '[jmap-webmail] New issue: Add dark mode toggle (#42)',
|
||||||
@@ -180,7 +180,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
// Newsletter with full HTML
|
// Newsletter with full HTML
|
||||||
{
|
{
|
||||||
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 18200, receivedAt: daysAgo(0),
|
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0),
|
||||||
from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }],
|
from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Launchpad Weekly #47 — The future of the open web',
|
subject: 'Launchpad Weekly #47 — The future of the open web',
|
||||||
@@ -227,7 +227,7 @@ const emails: MockEmail[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4100, receivedAt: hoursAgo(3),
|
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3),
|
||||||
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Code review request: JMAP-342 contact import',
|
subject: 'Code review request: JMAP-342 contact import',
|
||||||
@@ -254,7 +254,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 4700, receivedAt: daysAgo(1),
|
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }],
|
from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Your Hetzner invoice is available — February 2026',
|
subject: 'Your Hetzner invoice is available — February 2026',
|
||||||
@@ -354,7 +354,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 4100, receivedAt: daysAgo(6),
|
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6),
|
||||||
from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }],
|
from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Action required: API v2023-10 deprecation on April 15, 2026',
|
subject: 'Action required: API v2023-10 deprecation on April 15, 2026',
|
||||||
@@ -1302,22 +1302,75 @@ function handleMailboxSet(args: MethodArgs, callId: string): MethodResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEmailQuery(args: MethodArgs, callId: string): MethodResult {
|
function handleEmailQuery(args: MethodArgs, callId: string): MethodResult {
|
||||||
const filter = args.filter as Record<string, string> | undefined;
|
const filter = args.filter as Record<string, unknown> | undefined;
|
||||||
const limit = (args.limit as number) || 50;
|
const limit = (args.limit as number) || 50;
|
||||||
const position = (args.position as number) || 0;
|
const position = (args.position as number) || 0;
|
||||||
|
|
||||||
let filtered = [...emails];
|
let filtered = [...emails];
|
||||||
if (filter?.inMailbox) {
|
|
||||||
filtered = filtered.filter((e) => e.mailboxIds[filter.inMailbox]);
|
// Support both flat filters and operator/conditions compound filters
|
||||||
}
|
const applyFilter = (f: Record<string, unknown>, list: MockEmail[]): MockEmail[] => {
|
||||||
if (filter?.text) {
|
let result = list;
|
||||||
const q = (filter.text as string).toLowerCase();
|
if (f.operator && Array.isArray(f.conditions)) {
|
||||||
filtered = filtered.filter(
|
const sub = (f.conditions as Record<string, unknown>[]).map(c => applyFilter(c, result));
|
||||||
(e) =>
|
if (f.operator === 'AND') {
|
||||||
(e.subject?.toLowerCase().includes(q)) ||
|
result = sub.reduce((acc, s) => acc.filter(e => s.includes(e)));
|
||||||
(e.preview?.toLowerCase().includes(q)) ||
|
} else if (f.operator === 'OR') {
|
||||||
e.from?.some((f) => f.name?.toLowerCase().includes(q) || f.email.toLowerCase().includes(q)),
|
const ids = new Set(sub.flat().map(e => e.id));
|
||||||
);
|
result = result.filter(e => ids.has(e.id));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (f.inMailbox) {
|
||||||
|
result = result.filter((e) => e.mailboxIds[f.inMailbox as string]);
|
||||||
|
}
|
||||||
|
if (f.text) {
|
||||||
|
const q = (f.text as string).toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(e) =>
|
||||||
|
(e.subject?.toLowerCase().includes(q)) ||
|
||||||
|
(e.preview?.toLowerCase().includes(q)) ||
|
||||||
|
e.from?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.hasKeyword) {
|
||||||
|
const kw = f.hasKeyword as string;
|
||||||
|
result = result.filter((e) => e.keywords[kw] === true);
|
||||||
|
}
|
||||||
|
if (f.notKeyword) {
|
||||||
|
const kw = f.notKeyword as string;
|
||||||
|
result = result.filter((e) => !e.keywords[kw]);
|
||||||
|
}
|
||||||
|
if (f.from) {
|
||||||
|
const q = (f.from as string).toLowerCase();
|
||||||
|
result = result.filter((e) => e.from?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
if (f.to) {
|
||||||
|
const q = (f.to as string).toLowerCase();
|
||||||
|
result = result.filter((e) => e.to?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
if (f.subject) {
|
||||||
|
const q = (f.subject as string).toLowerCase();
|
||||||
|
result = result.filter((e) => e.subject?.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
if (f.hasAttachment === true) {
|
||||||
|
result = result.filter((e) => e.hasAttachment);
|
||||||
|
} else if (f.hasAttachment === false) {
|
||||||
|
result = result.filter((e) => !e.hasAttachment);
|
||||||
|
}
|
||||||
|
if (f.after) {
|
||||||
|
const after = new Date(f.after as string).getTime();
|
||||||
|
result = result.filter((e) => new Date(e.receivedAt).getTime() >= after);
|
||||||
|
}
|
||||||
|
if (f.before) {
|
||||||
|
const before = new Date(f.before as string).getTime();
|
||||||
|
result = result.filter((e) => new Date(e.receivedAt).getTime() <= before);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
filtered = applyFilter(filter, filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// Sort newest first
|
||||||
@@ -1377,9 +1430,14 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
|||||||
email.mailboxIds = changes.mailboxIds as Record<string, boolean>;
|
email.mailboxIds = changes.mailboxIds as Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full keywords replacement
|
// Full keywords replacement (strip false values per JMAP spec: keywords is a set)
|
||||||
if (changes.keywords !== undefined) {
|
if (changes.keywords !== undefined) {
|
||||||
email.keywords = changes.keywords as Record<string, boolean>;
|
const raw = changes.keywords as Record<string, boolean>;
|
||||||
|
const cleaned: Record<string, boolean> = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (v) cleaned[k] = true;
|
||||||
|
}
|
||||||
|
email.keywords = cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc.
|
// Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc.
|
||||||
|
|||||||
@@ -23,21 +23,26 @@ import {
|
|||||||
Palmtree,
|
Palmtree,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
|
Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||||
|
import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
|
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
selectedMailbox?: string;
|
selectedMailbox?: string;
|
||||||
|
selectedKeyword?: string | null;
|
||||||
onMailboxSelect?: (mailboxId: string) => void;
|
onMailboxSelect?: (mailboxId: string) => void;
|
||||||
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onSidebarClose?: () => void;
|
onSidebarClose?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -217,6 +222,62 @@ function MailboxTreeItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TagItem({
|
||||||
|
kw,
|
||||||
|
isSelected,
|
||||||
|
isCollapsed,
|
||||||
|
onTagSelect,
|
||||||
|
}: {
|
||||||
|
kw: KeywordDefinition;
|
||||||
|
isSelected: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('notifications');
|
||||||
|
const palette = KEYWORD_PALETTE[kw.color];
|
||||||
|
const { isDragging: globalDragging } = useDragDropContext();
|
||||||
|
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||||
|
tagId: kw.id,
|
||||||
|
onSuccess: (count, _tagLabel) => {
|
||||||
|
if (count === 1) {
|
||||||
|
toast.success(t('email_tagged'), kw.label);
|
||||||
|
} else {
|
||||||
|
toast.success(t('emails_tagged', { count }), kw.label);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('tag_failed'), kw.label);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...(globalDragging ? dropHandlers : {})}
|
||||||
|
className={cn(
|
||||||
|
"group w-full flex items-center py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
|
||||||
|
isCollapsed ? "justify-center px-1" : "px-2",
|
||||||
|
isSelected
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center py-1 lg:py-1 max-lg:py-2 px-1 rounded transition-colors duration-150",
|
||||||
|
isCollapsed ? "justify-center" : "flex-1 text-left"
|
||||||
|
)}
|
||||||
|
style={isCollapsed ? undefined : { paddingLeft: '40px' }}
|
||||||
|
title={isCollapsed ? kw.label : undefined}
|
||||||
|
>
|
||||||
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
||||||
|
{!isCollapsed && <span className="truncate">{kw.label}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VacationBanner() {
|
function VacationBanner() {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -243,7 +304,9 @@ function VacationBanner() {
|
|||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
mailboxes = [],
|
mailboxes = [],
|
||||||
selectedMailbox = "",
|
selectedMailbox = "",
|
||||||
|
selectedKeyword = null,
|
||||||
onMailboxSelect,
|
onMailboxSelect,
|
||||||
|
onTagSelect,
|
||||||
onCompose,
|
onCompose,
|
||||||
onSidebarClose,
|
onSidebarClose,
|
||||||
className,
|
className,
|
||||||
@@ -251,6 +314,13 @@ export function Sidebar({
|
|||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity } = useAuthStore();
|
const { primaryIdentity } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||||
|
return stored !== null ? JSON.parse(stored) : true;
|
||||||
|
} catch { return true; }
|
||||||
|
});
|
||||||
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -379,7 +449,7 @@ export function Sidebar({
|
|||||||
<MailboxTreeItem
|
<MailboxTreeItem
|
||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={expandedFolders}
|
||||||
onMailboxSelect={onMailboxSelect}
|
onMailboxSelect={onMailboxSelect}
|
||||||
onToggleExpand={handleToggleExpand}
|
onToggleExpand={handleToggleExpand}
|
||||||
@@ -389,6 +459,86 @@ export function Sidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Section */}
|
||||||
|
{emailKeywords.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group w-full flex items-center py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
|
||||||
|
isCollapsed ? "justify-center px-1" : "px-2",
|
||||||
|
"text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTagsExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||||
|
"hover:bg-muted active:bg-accent"
|
||||||
|
)}
|
||||||
|
title={tagsExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||||
|
>
|
||||||
|
{tagsExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollapsed) return;
|
||||||
|
setTagsExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center py-1 lg:py-1 max-lg:py-2 px-1 rounded",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
isCollapsed ? "justify-center" : "flex-1 text-left"
|
||||||
|
)}
|
||||||
|
style={isCollapsed ? undefined : { paddingLeft: '4px' }}
|
||||||
|
title={isCollapsed ? t("tags") : undefined}
|
||||||
|
>
|
||||||
|
<Tag className={cn(
|
||||||
|
"w-4 h-4 flex-shrink-0 transition-colors",
|
||||||
|
!isCollapsed && "mr-2",
|
||||||
|
tagsExpanded && "text-primary"
|
||||||
|
)} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="flex-1 truncate">{t("tags")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
|
<div className="relative">
|
||||||
|
{emailKeywords.map((kw) => {
|
||||||
|
const isSelected = selectedKeyword === kw.id;
|
||||||
|
return (
|
||||||
|
<TagItem
|
||||||
|
key={kw.id}
|
||||||
|
kw={kw}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onTagSelect={onTagSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose Button */}
|
{/* Compose Button */}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState, DragEvent } from "react";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
|
|
||||||
|
interface UseTagDropOptions {
|
||||||
|
tagId: string;
|
||||||
|
onSuccess?: (count: number, tagLabel: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTagDropReturn {
|
||||||
|
dropHandlers: {
|
||||||
|
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
};
|
||||||
|
isDropTarget: boolean;
|
||||||
|
isValidDropTarget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
|
||||||
|
const [isOver, setIsOver] = useState(false);
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
const { fetchEmails, selectedMailbox } = useEmailStore();
|
||||||
|
const { isDragging, endDrag } = useDragDropContext();
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isDragging) {
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.dropEffect = "none";
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const relatedTarget = e.relatedTarget as Node | null;
|
||||||
|
if (!e.currentTarget.contains(relatedTarget)) {
|
||||||
|
setIsOver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOver(false);
|
||||||
|
|
||||||
|
if (!client || !isDragging) {
|
||||||
|
endDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
|
||||||
|
if (!emailIdsJson) {
|
||||||
|
endDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||||
|
|
||||||
|
for (const emailId of emailIds) {
|
||||||
|
// Read fresh state to avoid stale closures
|
||||||
|
const currentEmails = useEmailStore.getState().emails;
|
||||||
|
const email = currentEmails.find(em => em.id === emailId);
|
||||||
|
const keywords = { ...(email?.keywords || {}) };
|
||||||
|
|
||||||
|
// Remove old label/color keywords
|
||||||
|
Object.keys(keywords).forEach(key => {
|
||||||
|
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||||
|
keywords[key] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the new tag
|
||||||
|
keywords[`$label:${tagId}`] = true;
|
||||||
|
|
||||||
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the email list
|
||||||
|
await fetchEmails(client, selectedMailbox);
|
||||||
|
|
||||||
|
onSuccess?.(emailIds.length, tagId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to tag emails:", error);
|
||||||
|
onError?.(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
}, [client, isDragging, tagId, fetchEmails, selectedMailbox, endDrag, onSuccess, onError]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dropHandlers: {
|
||||||
|
onDragOver: handleDragOver,
|
||||||
|
onDragEnter: handleDragEnter,
|
||||||
|
onDragLeave: handleDragLeave,
|
||||||
|
onDrop: handleDrop,
|
||||||
|
},
|
||||||
|
isDropTarget: isOver && isDragging,
|
||||||
|
isValidDropTarget: isOver && isDragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-2
@@ -499,13 +499,16 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
const filter: { inMailbox?: string } = {};
|
const filter: { inMailbox?: string; hasKeyword?: string } = {};
|
||||||
if (mailboxId) {
|
if (mailboxId) {
|
||||||
filter.inMailbox = mailboxId;
|
filter.inMailbox = mailboxId;
|
||||||
}
|
}
|
||||||
|
if (hasKeyword) {
|
||||||
|
filter.hasKeyword = hasKeyword;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/query", {
|
["Email/query", {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Suche löschen",
|
"clear_search": "Suche löschen",
|
||||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||||
|
"tags": "Tags",
|
||||||
"compose_hint": "Verfassen (c)",
|
"compose_hint": "Verfassen (c)",
|
||||||
"search_placeholder_hint": "E-Mails suchen... (drücke /)",
|
"search_placeholder_hint": "E-Mails suchen... (drücke /)",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Nach {mailbox} verschoben",
|
"moved_to_mailbox": "Nach {mailbox} verschoben",
|
||||||
"move_failed": "Verschieben fehlgeschlagen",
|
"move_failed": "Verschieben fehlgeschlagen",
|
||||||
"move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden",
|
"move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden",
|
||||||
|
"email_tagged": "E-Mail markiert",
|
||||||
|
"emails_tagged": "{count} E-Mails markiert",
|
||||||
|
"tag_failed": "Markierung fehlgeschlagen",
|
||||||
"identity_created": "Identität erfolgreich erstellt",
|
"identity_created": "Identität erfolgreich erstellt",
|
||||||
"identity_updated": "Identität erfolgreich aktualisiert",
|
"identity_updated": "Identität erfolgreich aktualisiert",
|
||||||
"identity_deleted": "Identität gelöscht",
|
"identity_deleted": "Identität gelöscht",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Clear search",
|
"clear_search": "Clear search",
|
||||||
"vacation_active": "Vacation responder is active",
|
"vacation_active": "Vacation responder is active",
|
||||||
|
"tags": "Tags",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation"
|
"nav_label": "Navigation"
|
||||||
},
|
},
|
||||||
@@ -388,6 +389,9 @@
|
|||||||
"moved_to_mailbox": "Moved to {mailbox}",
|
"moved_to_mailbox": "Moved to {mailbox}",
|
||||||
"move_failed": "Move failed",
|
"move_failed": "Move failed",
|
||||||
"move_error": "Could not move emails to the selected folder",
|
"move_error": "Could not move emails to the selected folder",
|
||||||
|
"email_tagged": "Email tagged",
|
||||||
|
"emails_tagged": "{count} emails tagged",
|
||||||
|
"tag_failed": "Tagging failed",
|
||||||
"identity_created": "Identity created successfully",
|
"identity_created": "Identity created successfully",
|
||||||
"identity_updated": "Identity updated successfully",
|
"identity_updated": "Identity updated successfully",
|
||||||
"identity_deleted": "Identity deleted",
|
"identity_deleted": "Identity deleted",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpiar búsqueda",
|
"clear_search": "Limpiar búsqueda",
|
||||||
"vacation_active": "Respuesta automática activa",
|
"vacation_active": "Respuesta automática activa",
|
||||||
|
"tags": "Etiquetas",
|
||||||
"compose_hint": "Redactar (c)",
|
"compose_hint": "Redactar (c)",
|
||||||
"search_placeholder_hint": "Buscar correo... (pulsa /)",
|
"search_placeholder_hint": "Buscar correo... (pulsa /)",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Movido a {mailbox}",
|
"moved_to_mailbox": "Movido a {mailbox}",
|
||||||
"move_failed": "Error al mover",
|
"move_failed": "Error al mover",
|
||||||
"move_error": "No se pudieron mover los correos a la carpeta seleccionada",
|
"move_error": "No se pudieron mover los correos a la carpeta seleccionada",
|
||||||
|
"email_tagged": "Correo etiquetado",
|
||||||
|
"emails_tagged": "{count} correos etiquetados",
|
||||||
|
"tag_failed": "Error al etiquetar",
|
||||||
"identity_created": "Identidad creada exitosamente",
|
"identity_created": "Identidad creada exitosamente",
|
||||||
"identity_updated": "Identidad actualizada exitosamente",
|
"identity_updated": "Identidad actualizada exitosamente",
|
||||||
"identity_deleted": "Identidad eliminada",
|
"identity_deleted": "Identidad eliminada",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Effacer la recherche",
|
"clear_search": "Effacer la recherche",
|
||||||
"vacation_active": "Répondeur d'absence activé",
|
"vacation_active": "Répondeur d'absence activé",
|
||||||
|
"tags": "Étiquettes",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation"
|
"nav_label": "Navigation"
|
||||||
},
|
},
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Déplacé vers {mailbox}",
|
"moved_to_mailbox": "Déplacé vers {mailbox}",
|
||||||
"move_failed": "Échec du déplacement",
|
"move_failed": "Échec du déplacement",
|
||||||
"move_error": "Impossible de déplacer les emails vers le dossier sélectionné",
|
"move_error": "Impossible de déplacer les emails vers le dossier sélectionné",
|
||||||
|
"email_tagged": "E-mail étiqueté",
|
||||||
|
"emails_tagged": "{count} e-mails étiquetés",
|
||||||
|
"tag_failed": "Échec de l'étiquetage",
|
||||||
"identity_created": "Identité créée avec succès",
|
"identity_created": "Identité créée avec succès",
|
||||||
"identity_updated": "Identité mise à jour avec succès",
|
"identity_updated": "Identité mise à jour avec succès",
|
||||||
"identity_deleted": "Identité supprimée",
|
"identity_deleted": "Identité supprimée",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Cancella ricerca",
|
"clear_search": "Cancella ricerca",
|
||||||
"vacation_active": "Risponditore automatico attivo",
|
"vacation_active": "Risponditore automatico attivo",
|
||||||
|
"tags": "Etichette",
|
||||||
"compose_hint": "Scrivi (c)",
|
"compose_hint": "Scrivi (c)",
|
||||||
"search_placeholder_hint": "Cerca posta... (premi /)",
|
"search_placeholder_hint": "Cerca posta... (premi /)",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Spostato in {mailbox}",
|
"moved_to_mailbox": "Spostato in {mailbox}",
|
||||||
"move_failed": "Spostamento non riuscito",
|
"move_failed": "Spostamento non riuscito",
|
||||||
"move_error": "Impossibile spostare i messaggi nella cartella selezionata",
|
"move_error": "Impossibile spostare i messaggi nella cartella selezionata",
|
||||||
|
"email_tagged": "Email etichettata",
|
||||||
|
"emails_tagged": "{count} email etichettate",
|
||||||
|
"tag_failed": "Etichettatura fallita",
|
||||||
"identity_created": "Identità creata con successo",
|
"identity_created": "Identità creata con successo",
|
||||||
"identity_updated": "Identità aggiornata con successo",
|
"identity_updated": "Identità aggiornata con successo",
|
||||||
"identity_deleted": "Identità eliminata",
|
"identity_deleted": "Identità eliminata",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "検索をクリア",
|
"clear_search": "検索をクリア",
|
||||||
"vacation_active": "不在応答が有効です",
|
"vacation_active": "不在応答が有効です",
|
||||||
|
"tags": "タグ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション"
|
"nav_label": "ナビゲーション"
|
||||||
},
|
},
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "{mailbox}に移動しました",
|
"moved_to_mailbox": "{mailbox}に移動しました",
|
||||||
"move_failed": "移動に失敗しました",
|
"move_failed": "移動に失敗しました",
|
||||||
"move_error": "選択したフォルダにメールを移動できませんでした",
|
"move_error": "選択したフォルダにメールを移動できませんでした",
|
||||||
|
"email_tagged": "メールにタグを付けました",
|
||||||
|
"emails_tagged": "{count}件のメールにタグを付けました",
|
||||||
|
"tag_failed": "タグ付けに失敗しました",
|
||||||
"identity_created": "送信者情報を作成しました",
|
"identity_created": "送信者情報を作成しました",
|
||||||
"identity_updated": "送信者情報を更新しました",
|
"identity_updated": "送信者情報を更新しました",
|
||||||
"identity_deleted": "送信者情報を削除しました",
|
"identity_deleted": "送信者情報を削除しました",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Zoekopdracht wissen",
|
"clear_search": "Zoekopdracht wissen",
|
||||||
"vacation_active": "Afwezigheidsmelder is actief",
|
"vacation_active": "Afwezigheidsmelder is actief",
|
||||||
|
"tags": "Labels",
|
||||||
"compose_hint": "Opstellen (c)",
|
"compose_hint": "Opstellen (c)",
|
||||||
"search_placeholder_hint": "E-mail zoeken... (druk /)",
|
"search_placeholder_hint": "E-mail zoeken... (druk /)",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Verplaatst naar {mailbox}",
|
"moved_to_mailbox": "Verplaatst naar {mailbox}",
|
||||||
"move_failed": "Verplaatsen mislukt",
|
"move_failed": "Verplaatsen mislukt",
|
||||||
"move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map",
|
"move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map",
|
||||||
|
"email_tagged": "E-mail gelabeld",
|
||||||
|
"emails_tagged": "{count} e-mails gelabeld",
|
||||||
|
"tag_failed": "Labelen mislukt",
|
||||||
"identity_created": "Identiteit succesvol aangemaakt",
|
"identity_created": "Identiteit succesvol aangemaakt",
|
||||||
"identity_updated": "Identiteit succesvol bijgewerkt",
|
"identity_updated": "Identiteit succesvol bijgewerkt",
|
||||||
"identity_deleted": "Identiteit verwijderd",
|
"identity_deleted": "Identiteit verwijderd",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpar busca",
|
"clear_search": "Limpar busca",
|
||||||
"vacation_active": "Resposta automática ativa",
|
"vacation_active": "Resposta automática ativa",
|
||||||
|
"tags": "Etiquetas",
|
||||||
"compose_hint": "Compor (c)",
|
"compose_hint": "Compor (c)",
|
||||||
"search_placeholder_hint": "Pesquisar e-mail... (pressione /)",
|
"search_placeholder_hint": "Pesquisar e-mail... (pressione /)",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
"moved_to_mailbox": "Movido para {mailbox}",
|
"moved_to_mailbox": "Movido para {mailbox}",
|
||||||
"move_failed": "Falha ao mover",
|
"move_failed": "Falha ao mover",
|
||||||
"move_error": "Não foi possível mover os e-mails para a pasta selecionada",
|
"move_error": "Não foi possível mover os e-mails para a pasta selecionada",
|
||||||
|
"email_tagged": "E-mail etiquetado",
|
||||||
|
"emails_tagged": "{count} e-mails etiquetados",
|
||||||
|
"tag_failed": "Falha ao etiquetar",
|
||||||
"identity_created": "Identidade criada com sucesso",
|
"identity_created": "Identidade criada com sucesso",
|
||||||
"identity_updated": "Identidade atualizada com sucesso",
|
"identity_updated": "Identidade atualizada com sucesso",
|
||||||
"identity_deleted": "Identidade excluída",
|
"identity_deleted": "Identidade excluída",
|
||||||
|
|||||||
+22
-3
@@ -29,6 +29,9 @@ interface EmailStore {
|
|||||||
threadEmailsCache: Map<string, Email[]>;
|
threadEmailsCache: Map<string, Email[]>;
|
||||||
isLoadingThread: string | null;
|
isLoadingThread: string | null;
|
||||||
|
|
||||||
|
// Keyword/tag filter
|
||||||
|
selectedKeyword: string | null;
|
||||||
|
|
||||||
// Advanced search state
|
// Advanced search state
|
||||||
searchFilters: SearchFilters;
|
searchFilters: SearchFilters;
|
||||||
isAdvancedSearchOpen: boolean;
|
isAdvancedSearchOpen: boolean;
|
||||||
@@ -43,6 +46,7 @@ interface EmailStore {
|
|||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setQuota: (quota: { used: number; total: number } | null) => void;
|
setQuota: (quota: { used: number; total: number } | null) => void;
|
||||||
|
selectKeyword: (keyword: string | null) => void;
|
||||||
toggleEmailSelection: (emailId: string) => void;
|
toggleEmailSelection: (emailId: string) => void;
|
||||||
selectRangeEmails: (targetEmailId: string) => void;
|
selectRangeEmails: (targetEmailId: string) => void;
|
||||||
lastSelectedEmailId: string | null;
|
lastSelectedEmailId: string | null;
|
||||||
@@ -126,6 +130,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
|
|
||||||
|
// Keyword/tag filter
|
||||||
|
selectedKeyword: null,
|
||||||
|
|
||||||
// Advanced search state
|
// Advanced search state
|
||||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
isAdvancedSearchOpen: false,
|
isAdvancedSearchOpen: false,
|
||||||
@@ -137,10 +144,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
setEmails: (emails) => set({ emails }),
|
setEmails: (emails) => set({ emails }),
|
||||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||||||
selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }),
|
selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }),
|
||||||
|
selectKeyword: (keyword) => set({
|
||||||
|
selectedKeyword: keyword,
|
||||||
|
selectedEmail: null,
|
||||||
|
selectedEmailIds: new Set(),
|
||||||
|
expandedThreadIds: new Set(),
|
||||||
|
threadEmailsCache: new Map(),
|
||||||
|
}),
|
||||||
selectMailbox: (mailboxId) => set({
|
selectMailbox: (mailboxId) => set({
|
||||||
selectedMailbox: mailboxId,
|
selectedMailbox: mailboxId,
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
selectedEmailIds: new Set(),
|
selectedEmailIds: new Set(),
|
||||||
|
selectedKeyword: null,
|
||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
@@ -231,7 +246,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
|
||||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
// Build keyword filter if a tag is selected
|
||||||
|
const { selectedKeyword } = get();
|
||||||
|
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
|
||||||
|
|
||||||
|
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
@@ -251,7 +270,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadMoreEmails: async (client) => {
|
loadMoreEmails: async (client) => {
|
||||||
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery } = get();
|
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword } = get();
|
||||||
|
|
||||||
// Don't load if already loading or no more emails
|
// Don't load if already loading or no more emails
|
||||||
if (isLoadingMore || !hasMoreEmails) return;
|
if (isLoadingMore || !hasMoreEmails) return;
|
||||||
@@ -288,7 +307,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
|
||||||
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|||||||
Reference in New Issue
Block a user