feat: implement tag counts fetching and display in sidebar and email components
This commit is contained in:
@@ -108,6 +108,7 @@ export default function Home() {
|
|||||||
selectedKeyword,
|
selectedKeyword,
|
||||||
selectKeyword,
|
selectKeyword,
|
||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
|
fetchTagCounts,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
// Keyboard shortcuts handlers
|
// Keyboard shortcuts handlers
|
||||||
@@ -299,6 +300,9 @@ export default function Home() {
|
|||||||
await fetchEmails(client);
|
await fetchEmails(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch tag counts
|
||||||
|
fetchTagCounts(client);
|
||||||
|
|
||||||
// Setup push notifications after successful data load
|
// Setup push notifications after successful data load
|
||||||
try {
|
try {
|
||||||
// Register state change callback
|
// Register state change callback
|
||||||
@@ -330,7 +334,7 @@ export default function Home() {
|
|||||||
client.closePushNotifications();
|
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
|
// Handle mark-as-read with delay based on settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -587,6 +591,9 @@ export default function Home() {
|
|||||||
|
|
||||||
// Refresh emails list to show color in list
|
// Refresh emails list to show color in list
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
|
|
||||||
|
// Refresh tag counts
|
||||||
|
fetchTagCounts(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set color tag:", error);
|
console.error("Failed to set color tag:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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 { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
@@ -227,11 +228,15 @@ function TagItem({
|
|||||||
isSelected,
|
isSelected,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onTagSelect,
|
onTagSelect,
|
||||||
|
totalCount,
|
||||||
|
unreadCount,
|
||||||
}: {
|
}: {
|
||||||
kw: KeywordDefinition;
|
kw: KeywordDefinition;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onTagSelect?: (keywordId: string | null) => void;
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
|
totalCount: number;
|
||||||
|
unreadCount: number;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('notifications');
|
const t = useTranslations('notifications');
|
||||||
const palette = KEYWORD_PALETTE[kw.color];
|
const palette = KEYWORD_PALETTE[kw.color];
|
||||||
@@ -272,7 +277,26 @@ function TagItem({
|
|||||||
title={isCollapsed ? kw.label : undefined}
|
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")} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -321,6 +345,7 @@ export function Sidebar({
|
|||||||
} catch { return true; }
|
} catch { return true; }
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -532,6 +557,8 @@ export function Sidebar({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onTagSelect={onTagSelect}
|
onTagSelect={onTagSelect}
|
||||||
|
totalCount={tagCounts[kw.id]?.total ?? 0}
|
||||||
|
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEmailStore } from "@/stores/email-store";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
|
|
||||||
|
|
||||||
interface UseTagDropOptions {
|
interface UseTagDropOptions {
|
||||||
tagId: string;
|
tagId: string;
|
||||||
onSuccess?: (count: number, tagLabel: string) => void;
|
onSuccess?: (count: number, tagLabel: string) => void;
|
||||||
@@ -25,7 +26,7 @@ interface UseTagDropReturn {
|
|||||||
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
|
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
|
||||||
const [isOver, setIsOver] = useState(false);
|
const [isOver, setIsOver] = useState(false);
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { fetchEmails, selectedMailbox } = useEmailStore();
|
const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore();
|
||||||
const { isDragging, endDrag } = useDragDropContext();
|
const { isDragging, endDrag } = useDragDropContext();
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
@@ -93,6 +94,9 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
|||||||
// Refresh the email list
|
// Refresh the email list
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
|
|
||||||
|
// Refresh tag counts
|
||||||
|
fetchTagCounts(client);
|
||||||
|
|
||||||
onSuccess?.(emailIds.length, tagId);
|
onSuccess?.(emailIds.length, tagId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to tag emails:", error);
|
console.error("Failed to tag emails:", error);
|
||||||
@@ -100,7 +104,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
|||||||
} finally {
|
} finally {
|
||||||
endDrag();
|
endDrag();
|
||||||
}
|
}
|
||||||
}, [client, isDragging, tagId, fetchEmails, selectedMailbox, endDrag, onSuccess, onError]);
|
}, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dropHandlers: {
|
dropHandlers: {
|
||||||
|
|||||||
@@ -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> {
|
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface EmailStore {
|
|||||||
|
|
||||||
// Keyword/tag filter
|
// Keyword/tag filter
|
||||||
selectedKeyword: string | null;
|
selectedKeyword: string | null;
|
||||||
|
tagCounts: Record<string, { total: number; unread: number }>;
|
||||||
|
|
||||||
// Advanced search state
|
// Advanced search state
|
||||||
searchFilters: SearchFilters;
|
searchFilters: SearchFilters;
|
||||||
@@ -47,6 +48,7 @@ interface EmailStore {
|
|||||||
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;
|
selectKeyword: (keyword: string | null) => void;
|
||||||
|
fetchTagCounts: (client: JMAPClient) => Promise<void>;
|
||||||
toggleEmailSelection: (emailId: string) => void;
|
toggleEmailSelection: (emailId: string) => void;
|
||||||
selectRangeEmails: (targetEmailId: string) => void;
|
selectRangeEmails: (targetEmailId: string) => void;
|
||||||
lastSelectedEmailId: string | null;
|
lastSelectedEmailId: string | null;
|
||||||
@@ -143,6 +145,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
// Keyword/tag filter
|
// Keyword/tag filter
|
||||||
selectedKeyword: null,
|
selectedKeyword: null,
|
||||||
|
tagCounts: {},
|
||||||
|
|
||||||
// Advanced search state
|
// Advanced search state
|
||||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
@@ -162,6 +165,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
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({
|
selectMailbox: (mailboxId) => set({
|
||||||
selectedMailbox: mailboxId,
|
selectedMailbox: mailboxId,
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
@@ -1040,6 +1057,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Handle Email state changes - refresh current mailbox
|
// Handle Email state changes - refresh current mailbox
|
||||||
if (accountChanges.Email) {
|
if (accountChanges.Email) {
|
||||||
await get().refreshCurrentMailbox(client);
|
await get().refreshCurrentMailbox(client);
|
||||||
|
get().fetchTagCounts(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Mailbox state changes - refresh mailbox list
|
// Handle Mailbox state changes - refresh mailbox list
|
||||||
|
|||||||
Reference in New Issue
Block a user