feat: implement tag counts fetching and display in sidebar and email components

This commit is contained in:
Linus Rath
2026-03-14 17:12:38 +01:00
parent f995558bf5
commit f7bbab5e4a
5 changed files with 107 additions and 4 deletions
+8 -1
View File
@@ -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);
}
+28 -1
View File
@@ -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}
>
<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>}
{!isCollapsed && (
<>
<span className="truncate">{kw.label}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium",
isSelected
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{unreadCount}
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{totalCount}
</span>
</span>
</>
)}
</button>
</div>
);
@@ -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}
/>
);
})}
+6 -2
View File
@@ -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<HTMLDivElement>) => {
@@ -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: {
+47
View File
@@ -548,6 +548,53 @@ export class JMAPClient {
}
}
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
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<string, { total: number; unread: number }> = {};
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<Email | null> {
try {
const targetAccountId = accountId || this.accountId;
+18
View File
@@ -31,6 +31,7 @@ interface EmailStore {
// Keyword/tag filter
selectedKeyword: string | null;
tagCounts: Record<string, { total: number; unread: number }>;
// 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<void>;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
@@ -143,6 +145,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Keyword/tag filter
selectedKeyword: null,
tagCounts: {},
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
@@ -162,6 +165,20 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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