diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5bb75384..8455e382 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { fetchThreadSnapshot, listRecentThreadIds, listInboxPage, saveMessageBodyHeight, sendThreadReply } from '@x/core/dist/knowledge/sync_gmail.js'; +import { fetchThreadSnapshot, listRecentThreadIds, listInboxPage, saveMessageBodyHeight, sendThreadReply, archiveThread, trashThread, markThreadRead } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -513,6 +513,15 @@ export function setupIpcHandlers() { 'gmail:sendReply': async (_event, args) => { return sendThreadReply(args); }, + 'gmail:archiveThread': async (_event, args) => { + return archiveThread(args.threadId); + }, + 'gmail:trashThread': async (_event, args) => { + return trashThread(args.threadId); + }, + 'gmail:markThreadRead': async (_event, args) => { + return markThreadRead(args.threadId); + }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index be2926d2..50e3fbf2 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -232,6 +232,10 @@ color: var(--gm-text-faint); } +.gmail-row-shell { + position: relative; +} + .gmail-row { display: grid; grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; @@ -249,6 +253,51 @@ transition: background 120ms ease; } +.gmail-row-actions { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.gmail-row-shell:hover .gmail-row-actions { + opacity: 1; + pointer-events: auto; +} + +.gmail-row-shell:hover .gmail-row-date { + visibility: hidden; +} + +.gmail-row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-row-action:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-row-action-danger:hover { + color: #e8453c; +} + .gmail-row:hover { background: var(--gm-bg-row-hover); box-shadow: none; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 2e3a4592..094e883f 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -764,18 +764,75 @@ export function EmailView() { else setOther(updater) }, []) - const toggleThread = useCallback((threadId: string) => { + const updateThreadInState = useCallback((threadId: string, updater: (t: GmailThread) => GmailThread) => { + const mapSection = (prev: SectionState): SectionState => ({ + ...prev, + threads: prev.threads.map((t) => (t.threadId === threadId ? updater(t) : t)), + }) + setImportant(mapSection) + setOther(mapSection) + }, []) + + const removeThreadFromState = useCallback((threadId: string) => { + const filterSection = (prev: SectionState): SectionState => ({ + ...prev, + threads: prev.threads.filter((t) => t.threadId !== threadId), + }) + setImportant(filterSection) + setOther(filterSection) + setSelectedThreadId((current) => (current === threadId ? null : current)) + setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId)) + }, []) + + const markThreadReadAction = useCallback(async (threadId: string) => { + updateThreadInState(threadId, (t) => ({ + ...t, + unread: false, + messages: t.messages.map((m) => ({ ...m, unread: false })), + })) + try { + const result = await window.ipc.invoke('gmail:markThreadRead', { threadId }) + if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error) + } catch (err) { + console.warn('[Gmail] mark-read failed:', err) + } + }, [updateThreadInState]) + + const archiveThreadAction = useCallback(async (threadId: string) => { + removeThreadFromState(threadId) + try { + const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) + if (!result.ok && result.error) toast(`Archive failed: ${result.error}`, 'error') + } catch (err) { + toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } + }, [removeThreadFromState]) + + const trashThreadAction = useCallback(async (threadId: string) => { + removeThreadFromState(threadId) + try { + const result = await window.ipc.invoke('gmail:trashThread', { threadId }) + if (!result.ok && result.error) toast(`Delete failed: ${result.error}`, 'error') + } catch (err) { + toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } + }, [removeThreadFromState]) + + const toggleThread = useCallback((thread: GmailThread) => { setSelectedThreadId((current) => { - const next = current === threadId ? null : threadId + const next = current === thread.threadId ? null : thread.threadId if (next) { setOpenedThreadIds((prev) => { const without = prev.filter((id) => id !== next) return [...without, next].slice(-MAX_KEPT_OPEN) }) + if (thread.unread) { + void markThreadReadAction(thread.threadId) + } } return next }) - }, []) + }, [markThreadReadAction]) const prefetchedRef = useRef>(new Set()) const hoverTimerRef = useRef | null>(null) @@ -991,23 +1048,61 @@ export function EmailView() { const isSelected = thread.threadId === selectedThreadId const isUnread = thread.unread === true const isMounted = openedThreadIds.includes(thread.threadId) + const stop = (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation() + } return (
- + +
+ {isUnread && ( + + )} + + +
+
{isMounted && ( { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['INBOX'] }, + }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function trashThread(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.trash({ userId: 'me', id: threadId }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function markThreadRead(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['UNREAD'] }, + }); + // Update local cache: clear unread on all messages in the thread. + const cached = readCachedSnapshot(threadId); + if (cached) { + for (const m of cached.snapshot.messages) m.unread = false; + cached.snapshot.unread = false; + try { + fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8'); + } catch (err) { + console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err); + } + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + interface SyncedThread { threadId: string; markdown: string; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d45749be..ab712445 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -172,6 +172,18 @@ const ipcSchemas = { error: z.string().optional(), }), }, + 'gmail:archiveThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:trashThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:markThreadRead': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1),