diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b3cbff6b..8512dbbc 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -108,6 +108,7 @@ export default function Home() { selectedKeyword, selectKeyword, hasMoreEmails, + fetchTagCounts, } = useEmailStore(); // Keyboard shortcuts handlers @@ -299,6 +300,9 @@ export default function Home() { await fetchEmails(client); } + // Fetch tag counts + fetchTagCounts(client); + // Setup push notifications after successful data load try { // Register state change callback @@ -330,7 +334,7 @@ export default function Home() { client.closePushNotifications(); } }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); // Handle mark-as-read with delay based on settings useEffect(() => { @@ -587,6 +591,9 @@ export default function Home() { // Refresh emails list to show color in list await fetchEmails(client, selectedMailbox); + + // Refresh tag counts + fetchTagCounts(client); } catch (error) { console.error("Failed to set color tag:", error); } diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 86576200..9c4859fc 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -34,6 +34,7 @@ import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store"; +import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -227,11 +228,15 @@ function TagItem({ isSelected, isCollapsed, onTagSelect, + totalCount, + unreadCount, }: { kw: KeywordDefinition; isSelected: boolean; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; + totalCount: number; + unreadCount: number; }) { const t = useTranslations('notifications'); const palette = KEYWORD_PALETTE[kw.color]; @@ -272,7 +277,26 @@ function TagItem({ title={isCollapsed ? kw.label : undefined} > - {!isCollapsed && {kw.label}} + {!isCollapsed && ( + <> + {kw.label} + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + {totalCount} + + + + )} ); @@ -321,6 +345,7 @@ export function Sidebar({ } catch { return true; } }); const emailKeywords = useSettingsStore(s => s.emailKeywords); + const tagCounts = useEmailStore(s => s.tagCounts); const t = useTranslations('sidebar'); useEffect(() => { @@ -532,6 +557,8 @@ export function Sidebar({ isSelected={isSelected} isCollapsed={isCollapsed} onTagSelect={onTagSelect} + totalCount={tagCounts[kw.id]?.total ?? 0} + unreadCount={tagCounts[kw.id]?.unread ?? 0} /> ); })} diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index 2e80b487..c90c68c1 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -5,6 +5,7 @@ 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; @@ -25,7 +26,7 @@ interface UseTagDropReturn { export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); - const { fetchEmails, selectedMailbox } = useEmailStore(); + const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(); const { isDragging, endDrag } = useDragDropContext(); const handleDragOver = useCallback((e: DragEvent) => { @@ -93,6 +94,9 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us // Refresh the email list await fetchEmails(client, selectedMailbox); + // Refresh tag counts + fetchTagCounts(client); + onSuccess?.(emailIds.length, tagId); } catch (error) { console.error("Failed to tag emails:", error); @@ -100,7 +104,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us } finally { endDrag(); } - }, [client, isDragging, tagId, fetchEmails, selectedMailbox, endDrag, onSuccess, onError]); + }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]); return { dropHandlers: { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ecc79d83..0f5b3726 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -548,6 +548,53 @@ export class JMAPClient { } } + async getTagCounts(tagIds: string[]): Promise> { + if (tagIds.length === 0) return {}; + try { + const methodCalls: JMAPMethodCall[] = []; + for (let i = 0; i < tagIds.length; i++) { + const keyword = `$label:${tagIds[i]}`; + // Total count for this tag + methodCalls.push(["Email/query", { + accountId: this.accountId, + filter: { hasKeyword: keyword }, + limit: 0, + calculateTotal: true, + }, `total_${i}`]); + // Unread count for this tag + methodCalls.push(["Email/query", { + accountId: this.accountId, + filter: { + operator: "AND", + conditions: [ + { hasKeyword: keyword }, + { notKeyword: "$seen" }, + ], + }, + limit: 0, + calculateTotal: true, + }, `unread_${i}`]); + } + + const response = await this.request(methodCalls); + const result: Record = {}; + + for (let i = 0; i < tagIds.length; i++) { + const totalResp = response.methodResponses?.[i * 2]?.[1]; + const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1]; + result[tagIds[i]] = { + total: totalResp?.total ?? 0, + unread: unreadResp?.total ?? 0, + }; + } + + return result; + } catch (error) { + console.error('Failed to get tag counts:', error); + return {}; + } + } + async getEmail(emailId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; diff --git a/stores/email-store.ts b/stores/email-store.ts index 5fd2eafe..a6c198da 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -31,6 +31,7 @@ interface EmailStore { // Keyword/tag filter selectedKeyword: string | null; + tagCounts: Record; // Advanced search state searchFilters: SearchFilters; @@ -47,6 +48,7 @@ interface EmailStore { setSearchQuery: (query: string) => void; setQuota: (quota: { used: number; total: number } | null) => void; selectKeyword: (keyword: string | null) => void; + fetchTagCounts: (client: JMAPClient) => Promise; toggleEmailSelection: (emailId: string) => void; selectRangeEmails: (targetEmailId: string) => void; lastSelectedEmailId: string | null; @@ -143,6 +145,7 @@ export const useEmailStore = create((set, get) => ({ // Keyword/tag filter selectedKeyword: null, + tagCounts: {}, // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, @@ -162,6 +165,20 @@ export const useEmailStore = create((set, get) => ({ expandedThreadIds: new Set(), threadEmailsCache: new Map(), }), + fetchTagCounts: async (client) => { + try { + const keywords = useSettingsStore.getState().emailKeywords; + if (keywords.length === 0) { + set({ tagCounts: {} }); + return; + } + const tagIds = keywords.map(k => k.id); + const counts = await client.getTagCounts(tagIds); + set({ tagCounts: counts }); + } catch (error) { + console.error('Failed to fetch tag counts:', error); + } + }, selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId, selectedEmail: null, @@ -1040,6 +1057,7 @@ export const useEmailStore = create((set, get) => ({ // Handle Email state changes - refresh current mailbox if (accountChanges.Email) { await get().refreshCurrentMailbox(client); + get().fetchTagCounts(client); } // Handle Mailbox state changes - refresh mailbox list