mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
feat(email): drafts, search, bulk read/unread, configurable backfill
Gmail client enhancements in apps/x, bundling four independent features:
- Drafts: save/autosave, update, delete, and list Gmail drafts. The
composer autosaves to a real Gmail draft (debounced ~1.5s) while
typing, reuses the thread's existing draft so edits update in place,
flushes a final save on close, and deletes the draft on discard. Adds
isDraft/draftId to the shared thread types. New core helpers:
saveThreadDraft, deleteThreadDraft, listDraftThreads,
buildDraftSnapshot, buildRawMimeMessage.
- Search: searchThreads(query, {limit}) backed by an on-disk snapshot
cache (read/writeSearchSnapshot). Extracts parseThreadSnapshot as the
shared parse core reused by both the cache-building sync and search.
- Read state: markThreadRead now takes a `read` flag so it toggles
read/unread; new markSectionRead marks a whole section
(important/other) read/unread and returns the affected count.
- Backfill: the onboarding/recovery sync is now bounded by a
configurable thread COUNT instead of a fixed 7-day window. New
gmail_sync_config.ts (getMaxEmails/setMaxEmails) backed by
~/.rowboat/config/gmail_sync.json, default 500, clamped to 1-5000;
seeded on first run.
New IPC channels: gmail:saveDraft, gmail:deleteDraft, gmail:getDrafts,
gmail:search, gmail:markSectionRead (plus a `read` field on
gmail:markThreadRead).
This commit is contained in:
parent
4a26bc9d80
commit
ab9bce6203
7 changed files with 1149 additions and 165 deletions
|
|
@ -69,7 +69,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 { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, markSectionRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
||||
|
|
@ -740,6 +740,18 @@ export function setupIpcHandlers() {
|
|||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
},
|
||||
'gmail:saveDraft': async (_event, args) => {
|
||||
return saveThreadDraft(args);
|
||||
},
|
||||
'gmail:deleteDraft': async (_event, args) => {
|
||||
return deleteThreadDraft(args.draftId);
|
||||
},
|
||||
'gmail:getDrafts': async () => {
|
||||
return listDraftThreads();
|
||||
},
|
||||
'gmail:search': async (_event, args) => {
|
||||
return searchThreads(args.query, { limit: args.limit });
|
||||
},
|
||||
'gmail:getConnectionStatus': async () => {
|
||||
return getGmailConnectionStatus();
|
||||
},
|
||||
|
|
@ -756,7 +768,10 @@ export function setupIpcHandlers() {
|
|||
return trashThread(args.threadId);
|
||||
},
|
||||
'gmail:markThreadRead': async (_event, args) => {
|
||||
return markThreadRead(args.threadId);
|
||||
return markThreadRead(args.threadId, args.read);
|
||||
},
|
||||
'gmail:markSectionRead': async (_event, args) => {
|
||||
return markSectionRead(args.section, args.read);
|
||||
},
|
||||
'gmail:saveMessageHeight': async (_event, args) => {
|
||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useTheme } from '@/contexts/theme-context'
|
|||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
type GmailThread = blocks.GmailThread
|
||||
|
|
@ -166,6 +167,11 @@ function buildRecipients(
|
|||
selfEmail: string,
|
||||
): { to: string[]; cc: string[] } {
|
||||
if (mode === 'forward') return { to: [], cc: [] }
|
||||
// Editing an existing draft: recipients are whatever the draft already has.
|
||||
if (mode === 'draft') {
|
||||
const draftMsg = latestMessage(thread)
|
||||
return { to: splitAddresses(draftMsg?.to), cc: splitAddresses(draftMsg?.cc) }
|
||||
}
|
||||
|
||||
const latest = latestMessage(thread)
|
||||
const self = selfEmail.toLowerCase()
|
||||
|
|
@ -195,6 +201,7 @@ function buildRecipients(
|
|||
// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes.
|
||||
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
||||
const raw = (rawSubject || '').trim()
|
||||
if (mode === 'draft') return raw // keep the draft's own subject verbatim
|
||||
if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim()
|
||||
return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim()
|
||||
}
|
||||
|
|
@ -526,7 +533,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
|
|||
)
|
||||
}
|
||||
|
||||
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
|
||||
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new' | 'draft'
|
||||
|
||||
function ComposeToolbarButton({
|
||||
editor,
|
||||
|
|
@ -977,6 +984,25 @@ const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> =
|
|||
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
|
||||
]
|
||||
|
||||
// Debounce before autosaving the composer to a Gmail draft after the last edit.
|
||||
const DRAFT_AUTOSAVE_MS = 1500
|
||||
|
||||
// Shape of the gmail:saveDraft request, kept here so we can stash a snapshot in
|
||||
// a ref for the close/unmount flush without re-reading a torn-down editor.
|
||||
type DraftPayload = {
|
||||
draftId?: string
|
||||
threadId?: string
|
||||
to?: string
|
||||
cc?: string
|
||||
bcc?: string
|
||||
subject: string
|
||||
bodyHtml: string
|
||||
bodyText: string
|
||||
inReplyTo?: string
|
||||
references?: string
|
||||
attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }>
|
||||
}
|
||||
|
||||
// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a
|
||||
// thread it renders as an inline card under the thread; in 'new' mode it has no
|
||||
// thread and renders as a centered modal with the AI writing bar.
|
||||
|
|
@ -992,7 +1018,9 @@ const ComposeBox = memo(function ComposeBox({
|
|||
onClose: () => void
|
||||
}) {
|
||||
const isNew = mode === 'new'
|
||||
const latest = thread ? latestMessage(thread) : undefined
|
||||
// Drafts and new messages share the full-modal layout (subject line, AI bar).
|
||||
const isModal = isNew || mode === 'draft'
|
||||
const latest = useMemo(() => (thread ? latestMessage(thread) : undefined), [thread])
|
||||
const initialRecipients = useMemo(
|
||||
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
|
||||
[mode, thread, selfEmail],
|
||||
|
|
@ -1004,11 +1032,16 @@ const ComposeBox = memo(function ComposeBox({
|
|||
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
|
||||
const [showBcc, setShowBcc] = useState<boolean>(false)
|
||||
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
|
||||
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply'
|
||||
const modeLabel = mode === 'draft' ? 'Draft' : isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply'
|
||||
|
||||
const initialContent = useMemo(() => {
|
||||
if (!thread) return ''
|
||||
if (mode === 'forward') return buildForwardedContent(thread)
|
||||
// For a saved draft, reopen the exact HTML we stored so formatting survives.
|
||||
if (mode === 'draft') {
|
||||
const draftMsg = latestMessage(thread)
|
||||
if (draftMsg?.bodyHtml) return draftMsg.bodyHtml
|
||||
}
|
||||
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
|
||||
if (!source) return ''
|
||||
|
|
@ -1023,7 +1056,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
StarterKit.configure({ link: false }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||
placeholder: isModal || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
|
|
@ -1255,6 +1288,155 @@ const ComposeBox = memo(function ComposeBox({
|
|||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
// ── Draft autosave ─────────────────────────────────────────────────────────
|
||||
// Keep a real Gmail draft in sync with the composer while the user types
|
||||
// (debounced) and flush a final save on close, so closing keeps the work in
|
||||
// Gmail's Drafts folder (synced to every device) instead of discarding it.
|
||||
// Empty/whitespace drafts are skipped. The core saveThreadDraft reuses an
|
||||
// existing thread draft and tracks the id so edits update in place.
|
||||
// Seeded when editing an existing draft so the first save updates it in place.
|
||||
const draftIdRef = useRef<string | undefined>(thread?.draftId)
|
||||
const lastPayloadRef = useRef<DraftPayload | null>(null)
|
||||
const savingRef = useRef(false) // a saveDraft IPC is in flight
|
||||
const pendingRef = useRef(false) // edits arrived mid-flight; save once more
|
||||
const sentRef = useRef(false) // suppress autosave after a successful send
|
||||
const discardedRef = useRef(false) // suppress autosave after discard
|
||||
const closedRef = useRef(false) // close already handled; skip unmount flush
|
||||
const dirtyRef = useRef(false) // user has edited since open
|
||||
const fieldsMounted = useRef(false) // skip the field effect's initial run
|
||||
const autosaveTimer = useRef<number | null>(null)
|
||||
const saveDraftNowRef = useRef<() => Promise<void>>(undefined)
|
||||
|
||||
const buildDraftPayload = useCallback((): DraftPayload | null => {
|
||||
if (!editor || editor.isDestroyed) return null
|
||||
const text = editor.getText().trim()
|
||||
if (!text) return null // skip empty/whitespace drafts
|
||||
const html = editor.getHTML()
|
||||
const messageIds = (thread?.messages ?? [])
|
||||
.map((m) => m.messageIdHeader)
|
||||
.filter((v): v is string => Boolean(v))
|
||||
const references = messageIds.join(' ')
|
||||
const inReplyTo = latest?.messageIdHeader
|
||||
// Only replies stay on the thread; forwards and new emails start fresh.
|
||||
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
|
||||
return {
|
||||
draftId: draftIdRef.current,
|
||||
threadId: isThreaded ? thread?.threadId : undefined,
|
||||
to: toList.join(', '),
|
||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : ''),
|
||||
bodyHtml: html,
|
||||
bodyText: text,
|
||||
inReplyTo: isThreaded ? inReplyTo : undefined,
|
||||
references: isThreaded ? references || undefined : undefined,
|
||||
attachments: attachments.length
|
||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||
: undefined,
|
||||
}
|
||||
}, [editor, thread, mode, isNew, toList, ccList, bccList, subject, attachments, latest])
|
||||
|
||||
const saveDraftNow = useCallback(async () => {
|
||||
if (sentRef.current || discardedRef.current) return
|
||||
if (savingRef.current) { pendingRef.current = true; return }
|
||||
const payload = lastPayloadRef.current
|
||||
if (!payload) return
|
||||
savingRef.current = true
|
||||
pendingRef.current = false
|
||||
try {
|
||||
payload.draftId = draftIdRef.current
|
||||
const res = await window.ipc.invoke('gmail:saveDraft', payload)
|
||||
if (res?.draftId && !discardedRef.current) draftIdRef.current = res.draftId
|
||||
} catch {
|
||||
// Autosave is best-effort; a failure just leaves the prior draft in place.
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
// Coalesce edits that landed mid-flight into one more save.
|
||||
if (pendingRef.current && !sentRef.current && !discardedRef.current) {
|
||||
pendingRef.current = false
|
||||
void saveDraftNowRef.current?.()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { saveDraftNowRef.current = saveDraftNow }, [saveDraftNow])
|
||||
|
||||
const scheduleAutosave = useCallback(() => {
|
||||
if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current)
|
||||
autosaveTimer.current = window.setTimeout(() => { void saveDraftNow() }, DRAFT_AUTOSAVE_MS)
|
||||
}, [saveDraftNow])
|
||||
|
||||
// Wait (briefly, capped) for any in-flight autosave to settle so send/discard
|
||||
// don't race it into a duplicate or orphaned draft.
|
||||
const waitForSaveIdle = useCallback(async () => {
|
||||
for (let guard = 0; savingRef.current && guard < 40; guard++) {
|
||||
await new Promise((r) => window.setTimeout(r, 50))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Autosave on body edits (typing, AI insert, undo/redo).
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const onUpdate = () => {
|
||||
dirtyRef.current = true
|
||||
lastPayloadRef.current = buildDraftPayload()
|
||||
scheduleAutosave()
|
||||
}
|
||||
editor.on('update', onUpdate)
|
||||
return () => { editor.off('update', onUpdate) }
|
||||
}, [editor, buildDraftPayload, scheduleAutosave])
|
||||
|
||||
// Autosave on header edits (recipients, subject, attachments), skipping mount.
|
||||
useEffect(() => {
|
||||
if (!fieldsMounted.current) { fieldsMounted.current = true; return }
|
||||
dirtyRef.current = true
|
||||
lastPayloadRef.current = buildDraftPayload()
|
||||
scheduleAutosave()
|
||||
}, [toList, ccList, bccList, subject, attachments, buildDraftPayload, scheduleAutosave])
|
||||
|
||||
// Safety net: if the composer is torn down without an explicit close (e.g.
|
||||
// navigating away), still flush unsaved edits. closedRef guards the common
|
||||
// path where handleClose already saved.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current)
|
||||
if (closedRef.current || sentRef.current || discardedRef.current || savingRef.current) return
|
||||
if (!dirtyRef.current) return
|
||||
const payload = lastPayloadRef.current
|
||||
if (!payload) return
|
||||
payload.draftId = draftIdRef.current
|
||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Close (X / click-away): keep the draft. Flush a final save when there are
|
||||
// unsaved edits. If a save is already in flight it will persist the latest
|
||||
// content, so we skip here to avoid creating a duplicate.
|
||||
const handleClose = useCallback(() => {
|
||||
closedRef.current = true
|
||||
if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current)
|
||||
if (!sentRef.current && !discardedRef.current && !savingRef.current && dirtyRef.current) {
|
||||
const payload = buildDraftPayload()
|
||||
if (payload) {
|
||||
payload.draftId = draftIdRef.current
|
||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
}, [buildDraftPayload, onClose])
|
||||
|
||||
// Discard: delete the autosaved draft (if any), then close.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discardedRef.current = true
|
||||
closedRef.current = true
|
||||
if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current)
|
||||
void (async () => {
|
||||
await waitForSaveIdle()
|
||||
const id = draftIdRef.current
|
||||
if (id) await window.ipc.invoke('gmail:deleteDraft', { draftId: id }).catch(() => {})
|
||||
})()
|
||||
onClose()
|
||||
}, [onClose, waitForSaveIdle])
|
||||
|
||||
const [sending, setSending] = useState(false)
|
||||
const sendInGmail = async () => {
|
||||
if (!editor || sending) return
|
||||
|
|
@ -1279,7 +1461,12 @@ const ComposeBox = memo(function ComposeBox({
|
|||
// Only replies stay on the thread; forwards and new emails start fresh.
|
||||
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
|
||||
|
||||
// Stop autosave from racing the send (it would leave an orphaned draft), and
|
||||
// let any in-flight save settle so we know the draft id to clean up.
|
||||
sentRef.current = true
|
||||
if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current)
|
||||
setSending(true)
|
||||
await waitForSaveIdle()
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||
threadId: isThreaded ? thread?.threadId : undefined,
|
||||
|
|
@ -1296,12 +1483,22 @@ const ComposeBox = memo(function ComposeBox({
|
|||
: undefined,
|
||||
})
|
||||
if (result.error) {
|
||||
sentRef.current = false // allow autosave to resume on a failed send
|
||||
toast(`Send failed: ${result.error}`, 'error')
|
||||
return
|
||||
}
|
||||
// Gmail only auto-cleans drafts on a threaded send; remove any draft we
|
||||
// autosaved for a brand-new message so it doesn't linger after sending.
|
||||
const leftover = draftIdRef.current
|
||||
if (leftover) {
|
||||
draftIdRef.current = undefined
|
||||
void window.ipc.invoke('gmail:deleteDraft', { draftId: leftover }).catch(() => {})
|
||||
}
|
||||
toast('Sent.', 'success')
|
||||
closedRef.current = true
|
||||
onClose()
|
||||
} catch (err) {
|
||||
sentRef.current = false
|
||||
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
} finally {
|
||||
setSending(false)
|
||||
|
|
@ -1419,7 +1616,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
>{preset.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
{(isNew || mode === 'forward') && (
|
||||
{(isModal || mode === 'forward') && (
|
||||
<div className="flex min-h-8 items-center gap-2 border-b border-border px-3 text-sm">
|
||||
<span className="text-muted-foreground">Subject</span>
|
||||
<input
|
||||
|
|
@ -1431,7 +1628,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
)}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cn('w-full overflow-y-auto', isNew ? 'min-h-0 flex-1' : 'max-h-[360px]')}
|
||||
className={cn('w-full overflow-y-auto', isModal ? 'min-h-0 flex-1' : 'max-h-[360px]')}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -1526,16 +1723,16 @@ const ComposeBox = memo(function ComposeBox({
|
|||
)}
|
||||
</div>
|
||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={onClose}>
|
||||
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isNew) {
|
||||
if (isModal) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
|
||||
<Dialog open onOpenChange={(open) => { if (!open) handleClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
|
|
@ -1548,7 +1745,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground"
|
||||
onClick={onClose}
|
||||
onClick={handleClose}
|
||||
aria-label="Close compose"
|
||||
>
|
||||
<X className="size-4" />
|
||||
|
|
@ -1569,7 +1766,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClose}
|
||||
onClick={handleClose}
|
||||
aria-label="Close compose"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -1731,6 +1928,8 @@ const initialSectionState: SectionState = {
|
|||
// panels and coming back doesn't reload from scratch.
|
||||
let persistedImportant: SectionState | null = null
|
||||
let persistedOther: SectionState | null = null
|
||||
// Last-loaded drafts, kept across EmailView remounts so reopening is instant.
|
||||
let persistedDrafts: GmailThread[] | null = null
|
||||
|
||||
function clearLoadingFlag(state: SectionState | null): SectionState {
|
||||
if (!state) return initialSectionState
|
||||
|
|
@ -1765,10 +1964,86 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
const [composeOpen, setComposeOpen] = useState(false)
|
||||
// Stable so the open composer isn't re-rendered on every inbox sync tick.
|
||||
const closeCompose = useCallback(() => setComposeOpen(false), [])
|
||||
// Inbox vs Drafts. Drafts are fetched live (they're not in the inbox cache).
|
||||
const [view, setView] = useState<'inbox' | 'drafts'>('inbox')
|
||||
const [drafts, setDrafts] = useState<GmailThread[]>(() => persistedDrafts ?? [])
|
||||
const [draftsLoading, setDraftsLoading] = useState(false)
|
||||
const [draftsError, setDraftsError] = useState<string | null>(null)
|
||||
const [editingDraft, setEditingDraft] = useState<GmailThread | null>(null)
|
||||
// Server-side search across the whole Gmail mailbox (results indexed locally).
|
||||
const [searchResults, setSearchResults] = useState<GmailThread[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const searchEpoch = useRef(0)
|
||||
// Gmail sync uses the native Google OAuth connection.
|
||||
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
const loadDrafts = useCallback(async () => {
|
||||
setDraftsLoading(true)
|
||||
setDraftsError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('gmail:getDrafts', {})
|
||||
if (res.error) setDraftsError(res.error)
|
||||
setDrafts(res.threads ?? [])
|
||||
} catch (err) {
|
||||
setDraftsError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setDraftsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load drafts when the Drafts view is opened.
|
||||
useEffect(() => {
|
||||
if (view === 'drafts') void loadDrafts()
|
||||
}, [view, loadDrafts])
|
||||
|
||||
// Debounced full-mailbox search. Each keystroke bumps an epoch so stale
|
||||
// responses are ignored; an empty query clears results.
|
||||
useEffect(() => {
|
||||
const q = query.trim()
|
||||
searchEpoch.current += 1
|
||||
const epoch = searchEpoch.current
|
||||
if (!q) {
|
||||
setSearchResults([])
|
||||
setSearchError(null)
|
||||
setSearching(false)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('gmail:search', { query: q, limit: 100 })
|
||||
if (searchEpoch.current !== epoch) return
|
||||
setSearchError(res.error ?? null)
|
||||
setSearchResults(res.threads ?? [])
|
||||
} catch (err) {
|
||||
if (searchEpoch.current === epoch) setSearchError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
if (searchEpoch.current === epoch) setSearching(false)
|
||||
}
|
||||
}, 400)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [query])
|
||||
|
||||
const deleteDraftAction = useCallback(async (thread: GmailThread) => {
|
||||
const id = thread.draftId
|
||||
if (!id) return
|
||||
setDrafts((prev) => prev.filter((d) => d.draftId !== id))
|
||||
try {
|
||||
await window.ipc.invoke('gmail:deleteDraft', { draftId: id })
|
||||
} catch (err) {
|
||||
toast(`Could not delete draft: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
void loadDrafts()
|
||||
}
|
||||
}, [loadDrafts])
|
||||
|
||||
// Closing the draft composer may have edited/sent/deleted it — refresh.
|
||||
const closeDraftEditor = useCallback(() => {
|
||||
setEditingDraft(null)
|
||||
void loadDrafts()
|
||||
}, [loadDrafts])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const check = async () => {
|
||||
|
|
@ -1800,7 +2075,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return
|
||||
if (composeOpen || settingsOpen) return
|
||||
if (composeOpen || settingsOpen || editingDraft) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
|
|
@ -1816,10 +2091,11 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [composeOpen, settingsOpen])
|
||||
}, [composeOpen, settingsOpen, editingDraft])
|
||||
|
||||
useEffect(() => { persistedImportant = important }, [important])
|
||||
useEffect(() => { persistedOther = other }, [other])
|
||||
useEffect(() => { persistedDrafts = drafts }, [drafts])
|
||||
|
||||
const setSection = useCallback((section: InboxSection, updater: (prev: SectionState) => SectionState) => {
|
||||
if (section === 'important') setImportant(updater)
|
||||
|
|
@ -1846,20 +2122,39 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId))
|
||||
}, [])
|
||||
|
||||
const markThreadReadAction = useCallback(async (threadId: string) => {
|
||||
const markThreadReadAction = useCallback(async (threadId: string, read: boolean = true) => {
|
||||
updateThreadInState(threadId, (t) => ({
|
||||
...t,
|
||||
unread: false,
|
||||
messages: t.messages.map((m) => ({ ...m, unread: false })),
|
||||
unread: !read,
|
||||
messages: t.messages.map((m) => ({ ...m, unread: !read })),
|
||||
}))
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId })
|
||||
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId, read })
|
||||
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] mark-read failed:', err)
|
||||
}
|
||||
}, [updateThreadInState])
|
||||
|
||||
// Toggle the read state of a whole category — server-side across the entire
|
||||
// category, not just the rows currently loaded. Optimistically flips the UI.
|
||||
const markSectionReadAction = useCallback(async (section: InboxSection, read: boolean) => {
|
||||
setSection(section, (prev) => ({
|
||||
...prev,
|
||||
threads: prev.threads.map((t) => ({
|
||||
...t,
|
||||
unread: !read,
|
||||
messages: t.messages.map((m) => ({ ...m, unread: !read })),
|
||||
})),
|
||||
}))
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:markSectionRead', { section, read })
|
||||
if (!result.ok && result.error) toast(`Could not update read state: ${result.error}`, 'error')
|
||||
} catch (err) {
|
||||
toast(`Could not update read state: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
}
|
||||
}, [setSection])
|
||||
|
||||
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
||||
|
|
@ -2151,6 +2446,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
|
||||
const visibleImportant = useMemo(() => filterThreads(important.threads), [important.threads, filterThreads])
|
||||
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
|
||||
const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, filterThreads])
|
||||
const importantHasUnread = important.threads.some((t) => t.unread)
|
||||
const otherHasUnread = other.threads.some((t) => t.unread)
|
||||
|
||||
const hasAny = important.threads.length > 0 || other.threads.length > 0
|
||||
const initialLoading = !hasAny && refreshing
|
||||
|
|
@ -2186,17 +2484,15 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
|
||||
</button>
|
||||
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
|
||||
{isUnread && (
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action"
|
||||
title="Mark as read"
|
||||
aria-label="Mark as read"
|
||||
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId) }}
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action"
|
||||
title={isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||
aria-label={isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId, isUnread) }}
|
||||
>
|
||||
{isUnread ? <CheckCheck size={15} /> : <Mail size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action"
|
||||
|
|
@ -2228,6 +2524,41 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
)
|
||||
}
|
||||
|
||||
const renderDraftRow = (thread: GmailThread) => {
|
||||
const stop = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation() }
|
||||
const recipient = thread.to ? extractName(thread.to) : 'No recipient'
|
||||
return (
|
||||
<div key={thread.draftId || thread.threadId} className="gmail-row-group">
|
||||
<div className="gmail-row-shell">
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row"
|
||||
onClick={() => setEditingDraft(thread)}
|
||||
>
|
||||
<span className="gmail-row-dot" aria-hidden />
|
||||
<span className="gmail-row-sender">To: {recipient}</span>
|
||||
<span className="gmail-row-content">
|
||||
<strong>{thread.subject || '(No subject)'}</strong>
|
||||
<span>{snippet(thread.gmail_draft || thread.latest_email)}</span>
|
||||
</span>
|
||||
<span className="gmail-row-date">{formatInboxTime(thread.date)}</span>
|
||||
</button>
|
||||
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-row-action gmail-row-action-danger"
|
||||
title="Delete draft"
|
||||
aria-label="Delete draft"
|
||||
onClick={(e) => { stop(e); void deleteDraftAction(thread) }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gmail-shell">
|
||||
<div className="gmail-main">
|
||||
|
|
@ -2237,12 +2568,29 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search loaded mail"
|
||||
placeholder="Search all mail"
|
||||
/>
|
||||
</div>
|
||||
<div className="gmail-topbar-actions">
|
||||
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
|
||||
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||
<div className="flex items-center rounded-md border border-border p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('rounded px-2.5 py-1 transition-colors', view === 'inbox' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setView('inbox')}
|
||||
>Inbox</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('rounded px-2.5 py-1 transition-colors', view === 'drafts' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => setView('drafts')}
|
||||
>Drafts{drafts.length > 0 ? ` (${drafts.length})` : ''}</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-icon-button"
|
||||
onClick={() => { if (view === 'drafts') void loadDrafts(); else void refresh() }}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
{(view === 'drafts' ? draftsLoading : refreshing) ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||
</button>
|
||||
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
|
||||
<SquarePen size={18} />
|
||||
|
|
@ -2250,7 +2598,43 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{error && !hasAny ? (
|
||||
{query.trim() ? (
|
||||
searchError && searchResults.length === 0 ? (
|
||||
<div className="gmail-empty-state">Could not search: {searchError}</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="gmail-list" aria-label="Search results">
|
||||
<section className="gmail-section">
|
||||
<div className="gmail-list-header">
|
||||
<span>Search results</span>
|
||||
<span>{searchResults.length} thread{searchResults.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
{searchResults.map(renderRow)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gmail-empty-state">
|
||||
{searching ? 'Searching all mail…' : `No results for “${query.trim()}”.`}
|
||||
</div>
|
||||
)
|
||||
) : view === 'drafts' ? (
|
||||
draftsError && drafts.length === 0 ? (
|
||||
<div className="gmail-empty-state">Could not load drafts: {draftsError}</div>
|
||||
) : drafts.length > 0 ? (
|
||||
<div className="gmail-list" aria-label="Drafts">
|
||||
<section className="gmail-section">
|
||||
<div className="gmail-list-header">
|
||||
<span>Drafts</span>
|
||||
<span>{drafts.length} draft{drafts.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
{visibleDrafts.map(renderDraftRow)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gmail-empty-state">
|
||||
{draftsLoading ? 'Loading drafts…' : 'No drafts yet.'}
|
||||
</div>
|
||||
)
|
||||
) : error && !hasAny ? (
|
||||
<div className="gmail-empty-state">Could not load mail: {error}</div>
|
||||
) : hasAny ? (
|
||||
<div className="gmail-list" aria-label="Recent emails">
|
||||
|
|
@ -2258,9 +2642,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
<section className="gmail-section">
|
||||
<div className="gmail-list-header">
|
||||
<span>Important</span>
|
||||
<span>
|
||||
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-1.5" title={importantHasUnread ? 'Mark all in Important as read' : 'Mark all in Important as unread'}>
|
||||
<span className="text-xs text-muted-foreground">Read</span>
|
||||
<Switch
|
||||
checked={!importantHasUnread}
|
||||
onCheckedChange={(checked) => void markSectionReadAction('important', checked)}
|
||||
aria-label="Mark all of Important read or unread"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{visibleImportant.map(renderRow)}
|
||||
{!important.hasReachedEnd && (
|
||||
|
|
@ -2276,9 +2670,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
<section className="gmail-section">
|
||||
<div className="gmail-list-header">
|
||||
<span>Everything else</span>
|
||||
<span>
|
||||
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-1.5" title={otherHasUnread ? 'Mark all in Everything else as read' : 'Mark all in Everything else as unread'}>
|
||||
<span className="text-xs text-muted-foreground">Read</span>
|
||||
<Switch
|
||||
checked={!otherHasUnread}
|
||||
onCheckedChange={(checked) => void markSectionReadAction('other', checked)}
|
||||
aria-label="Mark all of Everything else read or unread"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{visibleOther.map(renderRow)}
|
||||
{!other.hasReachedEnd && (
|
||||
|
|
@ -2315,6 +2719,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
)}
|
||||
</div>
|
||||
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
|
||||
{editingDraft && (
|
||||
<ComposeBox
|
||||
mode="draft"
|
||||
thread={editingDraft}
|
||||
selfEmail={emailConnection?.email ?? ''}
|
||||
onClose={closeDraftEditor}
|
||||
/>
|
||||
)}
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue