diff --git a/src/extensions/mail-ext-labels/package.json b/src/extensions/mail-ext-labels/package.json new file mode 100644 index 00000000..532943e6 --- /dev/null +++ b/src/extensions/mail-ext-labels/package.json @@ -0,0 +1,23 @@ +{ + "name": "mail-ext-labels", + "version": "1.0.0", + "description": "View Gmail labels on emails", + "main": "./src/index.ts", + "mailExtension": { + "id": "labels", + "displayName": "Labels", + "description": "View Gmail labels on emails", + "builtIn": true, + "activationEvents": ["onEmail"], + "contributes": { + "sidebarPanels": [ + { + "id": "email-labels", + "title": "Labels", + "priority": 80, + "scope": "sender" + } + ] + } + } +} diff --git a/src/extensions/mail-ext-labels/src/index.ts b/src/extensions/mail-ext-labels/src/index.ts new file mode 100644 index 00000000..954051ed --- /dev/null +++ b/src/extensions/mail-ext-labels/src/index.ts @@ -0,0 +1,21 @@ +import type { + ExtensionContext, + ExtensionAPI, + ExtensionModule, +} from "../../../shared/extension-types"; +import { createLabelsProvider } from "./labels-provider"; + +const extension: ExtensionModule = { + async activate(context: ExtensionContext, api: ExtensionAPI): Promise { + context.logger.info("Activating labels extension"); + const provider = createLabelsProvider(context); + api.registerEnrichmentProvider(provider); + context.logger.info("Labels extension activated"); + }, + + async deactivate(): Promise { + // No cleanup needed — label cache is in-memory and clears on exit + }, +}; + +export const { activate, deactivate } = extension; diff --git a/src/extensions/mail-ext-labels/src/labels-provider.ts b/src/extensions/mail-ext-labels/src/labels-provider.ts new file mode 100644 index 00000000..414e5051 --- /dev/null +++ b/src/extensions/mail-ext-labels/src/labels-provider.ts @@ -0,0 +1,30 @@ +import type { + ExtensionContext, + EnrichmentProvider, + EnrichmentData, +} from "../../../shared/extension-types"; +import type { DashboardEmail } from "../../../shared/types"; + +/** + * Labels enrichment provider — registered to keep the sidebar panel active, + * but returns null for enrichment data. The LabelsPanel component resolves + * labels directly via window.api.labels to avoid the sender-scoped enrichment + * cache (which would serve stale labels across different emails from the same sender). + */ +export function createLabelsProvider(_context: ExtensionContext): EnrichmentProvider { + return { + id: "labels-provider", + panelId: "email-labels", + priority: 90, + + canEnrich(): boolean { + return true; + }, + + async enrich(_email: DashboardEmail): Promise { + // Labels are resolved directly in LabelsPanel via window.api.labels + // to avoid sender-scoped enrichment cache returning wrong labels. + return null; + }, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 6cba35b5..28f9c544 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,7 @@ ipcMain.on("debug:log", (_, msg: string) => { import { ExtensionManifestSchema } from "../shared/extension-types"; import webSearchPackageJson from "../extensions/mail-ext-web-search/package.json"; import calendarPackageJson from "../extensions/mail-ext-calendar/package.json"; +import labelsPackageJson from "../extensions/mail-ext-labels/package.json"; import { createWindow, getIconPath } from "./window"; import { registerGmailIpc } from "./ipc/gmail.ipc"; import { registerAnalysisIpc } from "./ipc/analysis.ipc"; @@ -32,6 +33,7 @@ import { registerSearchIpc } from "./ipc/search.ipc"; import { registerOutboxIpc, registerNetworkIpc } from "./ipc/outbox.ipc"; import { registerMemoryIpc } from "./ipc/memory.ipc"; import { registerSplitsIpc } from "./ipc/splits.ipc"; +import { registerLabelsIpc } from "./ipc/labels.ipc"; import { registerSnippetsIpc } from "./ipc/snippets.ipc"; import { registerArchiveReadyIpc } from "./ipc/archive-ready.ipc"; import { registerSnoozeIpc } from "./ipc/snooze.ipc"; @@ -55,6 +57,7 @@ import { calendarSyncService } from "./services/calendar-sync"; import { emailSyncService } from "./services/email-sync"; import * as webSearchExtension from "../extensions/mail-ext-web-search/src/index"; import * as calendarExtension from "../extensions/mail-ext-calendar/src/index"; +import * as labelsExtension from "../extensions/mail-ext-labels/src/index"; // Skip Keychain for Chromium's internal cookie/localStorage encryption. // Without this, macOS prompts "wants to access data from other apps" on first launch @@ -517,6 +520,7 @@ app.whenReady().then(async () => { registerOutboxIpc(); registerMemoryIpc(); registerSplitsIpc(); + registerLabelsIpc(); registerSnippetsIpc(); registerArchiveReadyIpc(); registerSnoozeIpc(); @@ -548,10 +552,12 @@ app.whenReady().then(async () => { const webSearchManifest = ExtensionManifestSchema.parse(webSearchPackageJson.mailExtension); const calendarManifest = ExtensionManifestSchema.parse(calendarPackageJson.mailExtension); + const labelsManifest = ExtensionManifestSchema.parse(labelsPackageJson.mailExtension); Promise.all([ extensionHost.registerBundledExtensionFull(webSearchManifest, webSearchExtension), extensionHost.registerBundledExtensionFull(calendarManifest, calendarExtension), + extensionHost.registerBundledExtensionFull(labelsManifest, labelsExtension), ]) .then(() => { log.info("[Extensions] Bundled extensions activated"); diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts new file mode 100644 index 00000000..4cf7f9ed --- /dev/null +++ b/src/main/ipc/labels.ipc.ts @@ -0,0 +1,116 @@ +import { ipcMain } from "electron"; +import { getClient } from "./gmail.ipc"; +import { updateEmailLabelIds, getEmailsByThread } from "../db"; +import type { IpcResponse, LabelInfo } from "../../shared/types"; +import { createLogger } from "../services/logger"; + +const log = createLogger("labels-ipc"); + +export function registerLabelsIpc(): void { + // List all labels for an account + ipcMain.handle( + "labels:list", + async (_, { accountId }: { accountId: string }): Promise> => { + try { + const client = await getClient(accountId); + const labels = await client.listLabels(); + return { success: true, data: labels }; + } catch (error) { + log.error({ err: error }, "Failed to list labels"); + return { success: false, error: String(error) }; + } + }, + ); + + // Create a new Gmail label + ipcMain.handle( + "labels:create", + async ( + _, + { accountId, name }: { accountId: string; name: string }, + ): Promise> => { + try { + const client = await getClient(accountId); + const label = await client.createLabel(name); + return { success: true, data: label }; + } catch (error) { + log.error({ err: error }, "[Labels] Failed to create label"); + return { success: false, error: String(error) }; + } + }, + ); + + // Modify labels on a single message + ipcMain.handle( + "labels:modify-message", + async ( + _, + { + accountId, + emailId, + addLabelIds, + removeLabelIds, + }: { + accountId: string; + emailId: string; + addLabelIds: string[]; + removeLabelIds: string[]; + }, + ): Promise> => { + try { + const client = await getClient(accountId); + await client.modifyMessageLabels(emailId, addLabelIds, removeLabelIds); + + // Read back the updated message to get authoritative labelIds + const msg = await client.readEmail(emailId); + const newLabelIds = msg?.labelIds ?? []; + + // Update local DB + updateEmailLabelIds(emailId, newLabelIds); + + return { success: true, data: { labelIds: newLabelIds } }; + } catch (error) { + log.error({ err: error }, "Failed to modify message labels"); + return { success: false, error: String(error) }; + } + }, + ); + + // Modify labels on all messages in a thread + ipcMain.handle( + "labels:modify-thread", + async ( + _, + { + accountId, + threadId, + addLabelIds, + removeLabelIds, + }: { + accountId: string; + threadId: string; + addLabelIds: string[]; + removeLabelIds: string[]; + }, + ): Promise> => { + try { + const client = await getClient(accountId); + await client.modifyThreadLabels(threadId, addLabelIds, removeLabelIds); + + // Read back authoritative labelIds from Gmail for each thread message (parallel) + const threadEmails = getEmailsByThread(threadId, accountId); + await Promise.all( + threadEmails.map(async (email) => { + const msg = await client.readEmail(email.id); + updateEmailLabelIds(email.id, msg?.labelIds ?? []); + }), + ); + + return { success: true, data: undefined }; + } catch (error) { + log.error({ err: error }, "Failed to modify thread labels"); + return { success: false, error: String(error) }; + } + }, + ); +} diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 2b5bc280..8e79e83e 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -17,6 +17,7 @@ import type { ComposeMessageOptions, AttachmentMeta, SendAsAlias, + LabelInfo, } from "../../shared/types"; import { getAccounts } from "../db"; import { getDataDir } from "../data-dir"; @@ -525,6 +526,81 @@ export class GmailClient { return allMessages; } + /** + * List all labels for the authenticated account. + * Returns id, name, type, and optional color for each label. + */ + async listLabels(): Promise { + const gmail = this.gmail!; + const response = await gmail.users.labels.list({ userId: "me" }); + const rawLabels = response.data.labels || []; + return rawLabels.map((l) => ({ + id: l.id!, + name: l.name!, + type: l.type || "user", + ...(l.color + ? { color: { textColor: l.color.textColor!, backgroundColor: l.color.backgroundColor! } } + : {}), + })); + } + + /** + * Create a new Gmail label. Returns the created label's id and name. + */ + async createLabel(name: string): Promise { + const gmail = this.gmail!; + const response = await gmail.users.labels.create({ + userId: "me", + requestBody: { + name, + labelListVisibility: "labelShow", + messageListVisibility: "show", + }, + }); + const l = response.data; + return { id: l.id!, name: l.name!, type: "user" }; + } + + /** + * Modify labels on a message (add and/or remove arbitrary labels) + */ + async modifyMessageLabels( + messageId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise { + if (addLabelIds.length === 0 && removeLabelIds.length === 0) return; + const gmail = this.gmail!; + await gmail.users.messages.modify({ + userId: "me", + id: messageId, + requestBody: { + addLabelIds: addLabelIds.length > 0 ? addLabelIds : undefined, + removeLabelIds: removeLabelIds.length > 0 ? removeLabelIds : undefined, + }, + }); + } + + /** + * Modify labels on all messages in a thread + */ + async modifyThreadLabels( + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise { + if (addLabelIds.length === 0 && removeLabelIds.length === 0) return; + const gmail = this.gmail!; + await gmail.users.threads.modify({ + userId: "me", + id: threadId, + requestBody: { + addLabelIds: addLabelIds.length > 0 ? addLabelIds : undefined, + removeLabelIds: removeLabelIds.length > 0 ? removeLabelIds : undefined, + }, + }); + } + /** * Get the total number of messages with a given label. * Uses the labels.get endpoint which returns exact counts. diff --git a/src/preload/index.ts b/src/preload/index.ts index 9507fecf..bc680081 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -187,6 +187,37 @@ const api = { ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }), }, + // Label operations + labels: { + list: (accountId: string): Promise => ipcRenderer.invoke("labels:list", { accountId }), + create: (accountId: string, name: string): Promise => + ipcRenderer.invoke("labels:create", { accountId, name }), + modifyMessage: ( + accountId: string, + emailId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise => + ipcRenderer.invoke("labels:modify-message", { + accountId, + emailId, + addLabelIds, + removeLabelIds, + }), + modifyThread: ( + accountId: string, + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise => + ipcRenderer.invoke("labels:modify-thread", { + accountId, + threadId, + addLabelIds, + removeLabelIds, + }), + }, + // Style operations style: { getContext: (toAddress: string): Promise => diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx new file mode 100644 index 00000000..dbd5e382 --- /dev/null +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -0,0 +1,438 @@ +import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import type { DashboardEmail, LabelInfo } from "../../../shared/types"; +import type { ExtensionEnrichmentResult } from "../../../shared/extension-types"; +import { useAppStore } from "../../store"; + +interface LabelsPanelProps { + email: DashboardEmail; + threadEmails: DashboardEmail[]; + enrichment: ExtensionEnrichmentResult | null; + isLoading: boolean; +} + +// Type for the labels API on window.api +type LabelsAPI = { + list: (accountId: string) => Promise<{ success: boolean; data?: LabelInfo[] }>; + create: ( + accountId: string, + name: string, + ) => Promise<{ success: boolean; data?: LabelInfo; error?: string }>; + modifyMessage: ( + accountId: string, + emailId: string, + addLabelIds: string[], + removeLabelIds: string[], + ) => Promise<{ success: boolean; data?: { labelIds: string[] }; error?: string }>; + modifyThread: ( + accountId: string, + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ) => Promise<{ success: boolean; error?: string }>; +}; + +declare global { + interface Window { + api: { + labels: LabelsAPI; + [key: string]: unknown; + }; + } +} + +// System labels the UI already shows via other means — filter from picker and display +const HIDDEN_SYSTEM = new Set([ + "INBOX", + "UNREAD", + "SENT", + "DRAFT", + "SPAM", + "TRASH", + "IMPORTANT", + "STARRED", + "CATEGORY_PERSONAL", + "CATEGORY_SOCIAL", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + "CATEGORY_PROMOTIONS", + // Gmail star variants — redundant with the star icon in the UI + "YELLOW_STAR", + "RED_STAR", + "ORANGE_STAR", + "GREEN_STAR", + "BLUE_STAR", + "PURPLE_STAR", + "RED_BANG", + "ORANGE_GUILLEMET", + "YELLOW_BANG", + "GREEN_CHECK", + "BLUE_INFO", + "PURPLE_QUESTION", +]); + +export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.ReactElement { + const [currentLabels, setCurrentLabels] = useState([]); + const [allLabels, setAllLabels] = useState([]); + const [labelsLoaded, setLabelsLoaded] = useState(false); + const [showPicker, setShowPicker] = useState(false); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const [createError, setCreateError] = useState(null); + const [highlightIndex, setHighlightIndex] = useState(0); + const inputRef = useRef(null); + const pickerRef = useRef(null); + const isLabelPickerOpen = useAppStore((s) => s.isLabelPickerOpen); + const closeLabelPicker = useAppStore((s) => s.closeLabelPicker); + const updateEmail = useAppStore((s) => s.updateEmail); + + // Open picker when triggered by 'l' hotkey + useEffect(() => { + if (isLabelPickerOpen && labelsLoaded) { + setShowPicker(true); + closeLabelPicker(); + } + }, [isLabelPickerOpen, labelsLoaded, closeLabelPicker]); + + // Stable thread label IDs — only recompute when the actual IDs change, not on + // every threadEmails array reference change (which triggers on each render). + const threadLabelKey = useMemo(() => { + const ids = new Set(); + for (const e of [email, ...threadEmails]) { + for (const id of e.labelIds ?? []) { + ids.add(id); + } + } + return [...ids].sort().join(","); + }, [email, threadEmails]); + + // Fetch all labels and resolve current email's labels directly + // (bypasses sender-scoped enrichment cache which would serve wrong labels) + useEffect(() => { + const accountId = email.accountId || "default"; + setLabelsLoaded(false); + window.api.labels + .list(accountId) + .then((result: { success: boolean; data?: LabelInfo[] }) => { + if (result.success && result.data) { + setAllLabels(result.data); + + const labelIds = threadLabelKey.split(",").filter(Boolean); + const labelMap = new Map(result.data.map((l) => [l.id, l])); + const resolved: LabelInfo[] = []; + for (const id of labelIds) { + if (HIDDEN_SYSTEM.has(id)) continue; + const info = labelMap.get(id); + if (info) resolved.push(info); + } + resolved.sort((a, b) => a.name.localeCompare(b.name)); + setCurrentLabels(resolved); + } + setLabelsLoaded(true); + }) + .catch(() => { + setLabelsLoaded(true); + }); + }, [email.id, email.accountId, threadLabelKey]); + + // Focus input when picker opens + useEffect(() => { + if (showPicker && inputRef.current) { + inputRef.current.focus(); + } + }, [showPicker]); + + // Close picker on outside click + useEffect(() => { + if (!showPicker) return; + const handleClick = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowPicker(false); + setSearch(""); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [showPicker]); + + const removeLabel = useCallback( + async (labelId: string) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + try { + // Gmail labels are thread-level — remove from entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [], [labelId]); + if (result.success) { + setCurrentLabels((prev) => prev.filter((l) => l.id !== labelId)); + // Sync store for all thread emails (including the current email prop, + // which may not be in threadEmails) + const seen = new Set(); + for (const te of [email, ...threadEmails]) { + if (seen.has(te.id)) continue; + seen.add(te.id); + const updated = (te.labelIds ?? []).filter((id) => id !== labelId); + updateEmail(te.id, { labelIds: updated }); + } + } + } finally { + setBusy(false); + } + }, + [email.threadId, email.accountId, threadEmails, updateEmail], + ); + + const addLabel = useCallback( + async (label: LabelInfo) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + try { + // Gmail labels are thread-level — add to entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [label.id], []); + if (result.success) { + setCurrentLabels((prev) => { + if (prev.some((l) => l.id === label.id)) return prev; + return [...prev, label].sort((a, b) => a.name.localeCompare(b.name)); + }); + // Sync store for all thread emails (including the current email prop) + const seen = new Set(); + for (const te of [email, ...threadEmails]) { + if (seen.has(te.id)) continue; + seen.add(te.id); + const current = te.labelIds ?? []; + if (!current.includes(label.id)) { + updateEmail(te.id, { labelIds: [...current, label.id] }); + } + } + } + setShowPicker(false); + setSearch(""); + } finally { + setBusy(false); + } + }, + [email.threadId, email.accountId, threadEmails, updateEmail], + ); + + const createAndAddLabel = useCallback( + async (name: string) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + setCreateError(null); + try { + const createResult = await window.api.labels.create(accountId, name); + if (!createResult.success) { + setCreateError(createResult.error ?? "Failed to create label"); + return; + } + if (createResult.data) { + const newLabel = createResult.data; + // Add to allLabels so it appears in future searches + setAllLabels((prev) => [...prev, newLabel]); + // Apply to thread + const result = await window.api.labels.modifyThread( + accountId, + threadId, + [newLabel.id], + [], + ); + if (result.success) { + setCurrentLabels((prev) => + [...prev, newLabel].sort((a, b) => a.name.localeCompare(b.name)), + ); + const seen = new Set(); + for (const te of [email, ...threadEmails]) { + if (seen.has(te.id)) continue; + seen.add(te.id); + const current = te.labelIds ?? []; + if (!current.includes(newLabel.id)) { + updateEmail(te.id, { labelIds: [...current, newLabel.id] }); + } + } + } + } + setShowPicker(false); + setSearch(""); + } finally { + setBusy(false); + } + }, + [email.threadId, email.accountId, threadEmails, updateEmail], + ); + + // Reset highlight and error when search changes + useEffect(() => { + setHighlightIndex(0); + setCreateError(null); + }, [search]); + + const availableLabels = useMemo(() => { + const currentIds = new Set(currentLabels.map((l) => l.id)); + return allLabels + .filter((l) => !HIDDEN_SYSTEM.has(l.id) && !currentIds.has(l.id)) + .filter((l) => !search || l.name.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [allLabels, currentLabels, search]); + + return ( +
+ {!labelsLoaded && ( +
+ + + + + Loading labels... +
+ )} + + {labelsLoaded && ( + <> + {/* Current labels as removable chips */} +
+ {currentLabels.map((label) => ( + + {label.name} + + + ))} + + {currentLabels.length === 0 && ( + No labels + )} +
+ + {/* Add label button / picker */} +
+ {!showPicker ? ( + + ) : ( +
+ setSearch(e.target.value)} + placeholder="Search labels..." + className="w-full px-3 py-2 text-sm bg-transparent border-b border-gray-200 dark:border-gray-600 outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400" + onKeyDown={(e) => { + if (e.key === "Escape") { + setShowPicker(false); + setSearch(""); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightIndex((i) => + availableLabels.length === 0 + ? 0 + : Math.min(i + 1, Math.min(availableLabels.length - 1, 49)), + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && !busy) { + if (availableLabels.length > 0) { + addLabel( + availableLabels[Math.min(highlightIndex, availableLabels.length - 1)], + ); + } else if (search.trim()) { + createAndAddLabel(search.trim()); + } + } + }} + /> +
+ {availableLabels.length === 0 && !search.trim() && ( +
No matching labels
+ )} + {availableLabels.length === 0 && search.trim() && ( + <> + {createError && ( +
+ {createError} +
+ )} + + + )} + {availableLabels.slice(0, 50).map((label, index) => ( + + ))} +
+
+ )} +
+ + )} +
+ ); +} diff --git a/src/renderer/extensions/bundled/index.ts b/src/renderer/extensions/bundled/index.ts index fe91d349..dfecf0f8 100644 --- a/src/renderer/extensions/bundled/index.ts +++ b/src/renderer/extensions/bundled/index.ts @@ -7,6 +7,7 @@ import { registerPanelComponent } from "../ExtensionPanelSlot"; import { SenderProfilePanel } from "./SenderProfilePanel"; import { CalendarPanel } from "../../../extensions/mail-ext-calendar/src/renderer/CalendarPanel"; +import { LabelsPanel } from "./LabelsPanel"; import { registerPrivateExtensions } from "../private-extensions"; import { loadInstalledExtensionPanels } from "../installed-extensions"; @@ -21,6 +22,9 @@ export function registerBundledExtensions(): void { // Register calendar extension's day-view panel registerPanelComponent("calendar", "day-view", CalendarPanel); + // Register labels extension's panel + registerPanelComponent("labels", "email-labels", LabelsPanel); + // Register private extension panels (loaded from extensions-private/) registerPrivateExtensions(); diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 913f6bec..a8b9df0f 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -1034,6 +1034,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) } break; + // Labels — open label picker (Gmail's native shortcut) + case "l": + if (selectedEmailId) { + e.preventDefault(); + state.setSidebarTab("sender"); + state.openLabelPicker(); + } + break; + // Search (exclude Shift+/ which is "?" for help) case "/": if (!e.shiftKey) { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7096cec1..fd9d6553 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -218,6 +218,9 @@ interface AppState { // Command palette state isCommandPaletteOpen: boolean; + // Label picker state (toggled by 'l' hotkey) + isLabelPickerOpen: boolean; + // Search state isSearchOpen: boolean; activeSearchQuery: string | null; @@ -372,6 +375,10 @@ interface AppState { openCommandPalette: () => void; closeCommandPalette: () => void; + // Label picker actions + openLabelPicker: () => void; + closeLabelPicker: () => void; + // Find-in-page isFindBarOpen: boolean; openFindBar: () => void; @@ -572,6 +579,9 @@ export const useAppStore = create((set, get) => ({ // Command palette state isCommandPaletteOpen: false, + // Label picker state + isLabelPickerOpen: false, + // Find-in-page state isFindBarOpen: false, @@ -781,7 +791,7 @@ export const useAppStore = create((set, get) => ({ selectedEmailId: nextEmailId, }; }), - setSelectedEmailId: (id) => set({ selectedEmailId: id }), + setSelectedEmailId: (id) => set({ selectedEmailId: id, isLabelPickerOpen: false }), setSelectedThreadId: (id) => set({ selectedThreadId: id }), setFocusedThreadEmailId: (id) => set({ focusedThreadEmailId: id }), toggleThreadExpanded: (threadId) => @@ -902,6 +912,10 @@ export const useAppStore = create((set, get) => ({ openCommandPalette: () => set({ isCommandPaletteOpen: true }), closeCommandPalette: () => set({ isCommandPaletteOpen: false }), + // Label picker actions + openLabelPicker: () => set({ isLabelPickerOpen: true }), + closeLabelPicker: () => set({ isLabelPickerOpen: false }), + // Find-in-page actions openFindBar: () => set({ isFindBarOpen: true }), closeFindBar: () => set({ isFindBarOpen: false }), diff --git a/src/shared/types.ts b/src/shared/types.ts index b3a8ae36..dfd991a6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,6 +11,14 @@ export const AttachmentMetaSchema = z.object({ export type AttachmentMeta = z.infer; +// Gmail label metadata +export interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + // Email from Gmail API export const EmailSchema = z.object({ id: z.string(),