mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
* feat: compose new email with contact autocomplete and AI drafting - Add a compose-new-email box to the email view with a recipient field that autocompletes from Gmail contacts (keyboard navigation, match highlighting, avatar chips) - Build contact indices in core: gmail_sent_contacts syncs the SENT label via the Gmail API for full coverage of people you've emailed, with gmail_contacts as an instant local-snapshot fallback; both are pre-warmed at startup so the first keystroke is instant - Add generateOneShot() one-shot text generation for the composer's "write with AI", resolving to the active default model/provider - Add getAccountName() (parsed from a recent SENT message's From header, no extra OAuth scope) so AI drafts sign off with the real name - New IPC channels: gmail:searchContacts, gmail:getAccountName, llm:generate, llm:getDefaultModel * feat: attachments, undo/redo, and unified compose for new emails - Merge ComposeNewBox into ComposeBox via a new 'new' mode, memoizing the component so inbox sync ticks no longer jank the open composer. - Add file attachments: stage files in the renderer (25MB cap), pass raw base64 over IPC, and build a multipart/mixed MIME on send. - Add undo/redo buttons to the compose toolbar. - Single Write/Edit AI bar that generates a draft, then iteratively rewrites it; drop the hardcoded Gemini Flash model and use the default Copilot model. - Suppress inbox reloads while the compose-new modal is open. - Log llm:generate provider/model/output for debugging. * fix: remove redundant Subject placeholder in composer The subject row already has a 'Subject' gutter label, so the input's placeholder repeated the word — an empty field read 'Subject' twice. Drop the placeholder to match the To/Cc/Bcc fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2216 lines
81 KiB
TypeScript
2216 lines
81 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
|
||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||
import StarterKit from '@tiptap/starter-kit'
|
||
import Link from '@tiptap/extension-link'
|
||
import Placeholder from '@tiptap/extension-placeholder'
|
||
import type { blocks } from '@x/shared'
|
||
import { cn } from '@/lib/utils'
|
||
import { toast } from '@/lib/toast'
|
||
import { useTheme } from '@/contexts/theme-context'
|
||
import { SettingsDialog } from '@/components/settings-dialog'
|
||
|
||
type GmailThread = blocks.GmailThread
|
||
type GmailThreadMessage = blocks.GmailThreadMessage
|
||
type GmailConnectionStatus = {
|
||
connected: boolean
|
||
hasRequiredScope: boolean
|
||
missingScopes: string[]
|
||
email: string | null
|
||
}
|
||
|
||
function formatInboxTime(value?: string): string {
|
||
if (!value) return ''
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return value
|
||
const now = new Date()
|
||
const diffMs = now.getTime() - date.getTime()
|
||
const diffMin = Math.round(diffMs / 60000)
|
||
if (diffMin < 1) return 'now'
|
||
if (diffMin < 60) return `${diffMin}m`
|
||
const sameDay = date.toDateString() === now.toDateString()
|
||
if (sameDay) return `${Math.round(diffMin / 60)}h`
|
||
const yesterday = new Date(now)
|
||
yesterday.setDate(now.getDate() - 1)
|
||
if (date.toDateString() === yesterday.toDateString()) return 'Yest'
|
||
if (diffMs < 7 * 24 * 60 * 60 * 1000) return date.toLocaleDateString([], { weekday: 'short' })
|
||
if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: '2-digit' })
|
||
}
|
||
|
||
function formatFullDate(value?: string): string {
|
||
if (!value) return ''
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return value
|
||
return date.toLocaleString([], {
|
||
weekday: 'short',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function extractName(from?: string): string {
|
||
if (!from) return 'Unknown'
|
||
const match = from.match(/^([^<]+)</)
|
||
if (match?.[1]) return match[1].replace(/^["']|["']$/g, '').trim()
|
||
const address = from.match(/<?([^<>\s]+@[^<>\s]+)>?/)?.[1] ?? from
|
||
return address.replace(/@.*/, '').replace(/[._+]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||
}
|
||
|
||
function extractAddress(from?: string): string {
|
||
if (!from) return ''
|
||
return from.match(/<([^>]+)>/)?.[1] ?? from
|
||
}
|
||
|
||
function snippet(text?: string): string {
|
||
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||
}
|
||
|
||
function isReplyQuoteBoundary(lines: string[], index: number): boolean {
|
||
const line = lines[index]?.trim() || ''
|
||
if (/^On\b.+\bwrote:\s*$/i.test(line)) return true
|
||
if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true
|
||
if (/^From:\s+\S/i.test(line)) {
|
||
const next = lines.slice(index + 1, index + 6).map((value) => value.trim())
|
||
return next.some((value) => /^(Sent|Date):\s+\S/i.test(value))
|
||
&& next.some((value) => /^To:\s+\S/i.test(value))
|
||
&& next.some((value) => /^Subject:\s+\S/i.test(value))
|
||
}
|
||
return false
|
||
}
|
||
|
||
function stripQuotedReplyText(text: string): string {
|
||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
|
||
const boundary = lines.findIndex((line, index) => {
|
||
if (isReplyQuoteBoundary(lines, index)) return true
|
||
return index > 0
|
||
&& line.trim().startsWith('>')
|
||
&& (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>'))
|
||
})
|
||
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines
|
||
return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||
}
|
||
|
||
function getInitial(from?: string): string {
|
||
return (extractName(from)[0] || '?').toUpperCase()
|
||
}
|
||
|
||
const AVATAR_COLORS = ['#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900', '#00796b', '#c62828', '#1565c0']
|
||
|
||
function avatarColor(from?: string): string {
|
||
const value = from || 'unknown'
|
||
let hash = 0
|
||
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) >>> 0
|
||
return AVATAR_COLORS[hash % AVATAR_COLORS.length]
|
||
}
|
||
|
||
function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
|
||
return thread.messages[thread.messages.length - 1]
|
||
}
|
||
|
||
// Split a raw header recipient string (e.g. `"Jo Bloggs" <jo@x.com>, b@y.com`) into
|
||
// individual address tokens, respecting commas inside quotes/angle brackets.
|
||
function splitAddresses(raw?: string): string[] {
|
||
if (!raw) return []
|
||
const tokens: string[] = []
|
||
let buf = ''
|
||
let inQuote = false
|
||
let depth = 0
|
||
for (const ch of raw) {
|
||
if (ch === '"') inQuote = !inQuote
|
||
else if (ch === '<') depth += 1
|
||
else if (ch === '>') depth = Math.max(0, depth - 1)
|
||
if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) {
|
||
const token = buf.trim()
|
||
if (token) tokens.push(token)
|
||
buf = ''
|
||
continue
|
||
}
|
||
buf += ch
|
||
}
|
||
const last = buf.trim()
|
||
if (last) tokens.push(last)
|
||
return tokens
|
||
}
|
||
|
||
// Display label for a recipient chip: the display name if present, else the bare address.
|
||
function recipientLabel(token: string): string {
|
||
const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/)
|
||
if (named?.[1]?.trim()) return named[1].trim()
|
||
return extractAddress(token)
|
||
}
|
||
|
||
// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`.
|
||
function dedupeRecipients(tokens: string[], exclude: Set<string>): string[] {
|
||
const seen = new Set<string>(exclude)
|
||
const out: string[] = []
|
||
for (const token of tokens) {
|
||
const addr = extractAddress(token).toLowerCase()
|
||
if (!addr || seen.has(addr)) continue
|
||
seen.add(addr)
|
||
out.push(token)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me".
|
||
function buildRecipients(
|
||
mode: ComposeMode,
|
||
thread: GmailThread,
|
||
selfEmail: string,
|
||
): { to: string[]; cc: string[] } {
|
||
if (mode === 'forward') return { to: [], cc: [] }
|
||
|
||
const latest = latestMessage(thread)
|
||
const self = selfEmail.toLowerCase()
|
||
const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : ''
|
||
const iAmSender = Boolean(self) && fromAddr === self
|
||
|
||
// If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender.
|
||
const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : [])
|
||
const ccPool = iAmSender
|
||
? splitAddresses(latest?.cc)
|
||
: [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)]
|
||
|
||
const selfSet = new Set<string>(self ? [self] : [])
|
||
const to = dedupeRecipients(rawTo, selfSet)
|
||
if (iAmSender && to.length === 0 && self && rawTo.some((token) => extractAddress(token).toLowerCase() === self)) {
|
||
to.push(self)
|
||
}
|
||
|
||
if (mode === 'reply') return { to, cc: [] }
|
||
|
||
const ccExclude = new Set<string>(selfSet)
|
||
for (const token of to) ccExclude.add(extractAddress(token).toLowerCase())
|
||
const cc = dedupeRecipients(ccPool, ccExclude)
|
||
return { to, cc }
|
||
}
|
||
|
||
// 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 === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim()
|
||
return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim()
|
||
}
|
||
|
||
function buildForwardedContent(thread: GmailThread): string {
|
||
const message = latestMessage(thread)
|
||
if (!message) return ''
|
||
const rows = [
|
||
'---------- Forwarded message ---------',
|
||
message.from ? `From: ${message.from}` : null,
|
||
message.date ? `Date: ${formatFullDate(message.date)}` : null,
|
||
message.subject || thread.subject ? `Subject: ${message.subject || thread.subject}` : null,
|
||
message.to ? `To: ${message.to}` : null,
|
||
message.cc ? `Cc: ${message.cc}` : null,
|
||
].filter((line): line is string => Boolean(line))
|
||
const body = (message.body || snippet(message.bodyHtml)).trim()
|
||
return [
|
||
'<p></p>',
|
||
'<blockquote>',
|
||
...rows.map((line) => `<p>${escapeHtml(line)}</p>`),
|
||
body ? `<p>${escapeHtml(body).replace(/\n/g, '<br />')}</p>` : '',
|
||
'</blockquote>',
|
||
].join('')
|
||
}
|
||
|
||
const PREFETCH_HOVER_MS = 180
|
||
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
||
|
||
function extractImageUrls(html: string): string[] {
|
||
const urls: string[] = []
|
||
const re = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi
|
||
let match: RegExpExecArray | null
|
||
while ((match = re.exec(html)) !== null) {
|
||
const url = match[1]
|
||
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||
urls.push(url)
|
||
}
|
||
}
|
||
return urls
|
||
}
|
||
|
||
function prefetchThreadImages(thread: GmailThread): void {
|
||
const seen = new Set<string>()
|
||
for (const msg of thread.messages) {
|
||
if (!msg.bodyHtml) continue
|
||
for (const url of extractImageUrls(msg.bodyHtml)) {
|
||
if (seen.has(url)) continue
|
||
seen.add(url)
|
||
if (seen.size > PREFETCH_MAX_IMAGES_PER_THREAD) return
|
||
const img = new Image()
|
||
img.decoding = 'async'
|
||
img.referrerPolicy = 'no-referrer'
|
||
img.src = url
|
||
}
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
// Convert AI-generated plain text into the simple paragraph HTML the Tiptap
|
||
// editor expects (blank lines → paragraphs, single newlines → <br />).
|
||
function plainTextToHtml(text: string): string {
|
||
return text
|
||
.split(/\n{2,}/)
|
||
.map((para) => `<p>${escapeHtml(para.trim()).replace(/\n/g, '<br />')}</p>`)
|
||
.join('')
|
||
}
|
||
|
||
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
|
||
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
|
||
const match = re.exec(text)
|
||
if (!match) return { visible: text, quoted: null }
|
||
const start = match.index === 0 ? 0 : match.index + 1
|
||
const visible = text.slice(0, start).trimEnd()
|
||
const quoted = text.slice(start)
|
||
if (!quoted.trim()) return { visible: text, quoted: null }
|
||
return { visible, quoted }
|
||
}
|
||
|
||
// True if the HTML — after stripping quoted/hidden content — defines its
|
||
// own visual layout (real images, tables, explicit backgrounds). Unstyled
|
||
// HTML (Gmail replies, Outlook one-liners wrapped in MsoNormal boilerplate,
|
||
// outreach emails with only a tracking pixel, reply HTML whose only image
|
||
// lives inside the inline-quoted thread) gets an iframe that adapts to the
|
||
// app theme; styled HTML keeps the white "paper" look so newsletters /
|
||
// branded designs render as their senders intended.
|
||
function isStyledHtml(html: string): boolean {
|
||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||
doc.querySelectorAll('.gmail_quote, .gmail_attr, blockquote[type="cite"]').forEach((n) => n.remove())
|
||
if (doc.querySelector('table')) return true
|
||
for (const img of Array.from(doc.querySelectorAll('img'))) {
|
||
const w = parseInt(img.getAttribute('width') || '0', 10)
|
||
const h = parseInt(img.getAttribute('height') || '0', 10)
|
||
if (w === 1 && h === 1) continue
|
||
const style = img.getAttribute('style') || ''
|
||
if (/display\s*:\s*none/i.test(style)) continue
|
||
if (/visibility\s*:\s*hidden/i.test(style)) continue
|
||
return true
|
||
}
|
||
const visible = doc.body?.innerHTML || ''
|
||
if (/bgcolor\s*=/i.test(visible)) return true
|
||
if (/background-(color|image)\s*:/i.test(visible)) return true
|
||
return false
|
||
}
|
||
|
||
function buildEmailDocument(
|
||
html: string,
|
||
opts: { theme: 'light' | 'dark'; adaptToTheme: boolean },
|
||
): string {
|
||
const useDark = opts.theme === 'dark' && opts.adaptToTheme
|
||
// Only opt into the dark color scheme when the email actually adapts to the
|
||
// theme — otherwise Chromium paints the canvas dark under emails that
|
||
// assume a white background.
|
||
const colorScheme = useDark ? 'light dark' : 'light'
|
||
const bodyColor = useDark ? '#d4d4d8' : '#202124'
|
||
const linkColor = useDark ? '#a78bfa' : '#1a73e8'
|
||
const quoteBorder = useDark ? '#2e2e35' : '#dadce0'
|
||
const quoteColor = useDark ? '#71717a' : '#5f6368'
|
||
return `<!doctype html>
|
||
<html><head>
|
||
<meta charset="utf-8">
|
||
<meta name="color-scheme" content="${colorScheme}">
|
||
<base target="_blank">
|
||
<style>
|
||
:root { color-scheme: ${colorScheme}; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body {
|
||
font: 14px/1.6 Arial, sans-serif;
|
||
background: transparent;
|
||
color: ${bodyColor};
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
word-wrap: break-word;
|
||
padding-bottom: 4px;
|
||
}
|
||
body > *:last-child { margin-bottom: 0; }
|
||
img { max-width: 100%; height: auto; }
|
||
table { max-width: 100%; }
|
||
a { color: ${linkColor}; }
|
||
blockquote {
|
||
margin: 0 0 0 6px;
|
||
padding-left: 12px;
|
||
border-left: 2px solid ${quoteBorder};
|
||
color: ${quoteColor};
|
||
}
|
||
.gmail_quote,
|
||
.gmail_attr,
|
||
blockquote[type="cite"] { display: none; }
|
||
[data-show-quotes="true"] .gmail_quote,
|
||
[data-show-quotes="true"] .gmail_attr,
|
||
[data-show-quotes="true"] blockquote[type="cite"] { display: block; }
|
||
</style>
|
||
</head><body>${html}</body></html>`
|
||
}
|
||
|
||
function MessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) {
|
||
const isPlainText = !(message.bodyHtml && message.bodyHtml.trim())
|
||
return isPlainText
|
||
? <PlainTextBody message={message} />
|
||
: <HtmlMessageBody message={message} threadId={threadId} />
|
||
}
|
||
|
||
function PlainTextBody({ message }: { message: GmailThreadMessage }) {
|
||
const text = (message.body || '(No message body)').trim()
|
||
const { visible, quoted } = splitPlainTextQuote(text)
|
||
const [showQuote, setShowQuote] = useState(false)
|
||
return (
|
||
<>
|
||
<div className="gmail-message-plain">
|
||
<pre className="gmail-message-pre">{visible}</pre>
|
||
{quoted && showQuote && <pre className="gmail-message-pre gmail-message-pre-quoted">{quoted}</pre>}
|
||
</div>
|
||
{quoted && (
|
||
<button
|
||
type="button"
|
||
className="gmail-quote-toggle"
|
||
onClick={() => setShowQuote((v) => !v)}
|
||
aria-label={showQuote ? 'Hide quoted text' : 'Show quoted text'}
|
||
aria-expanded={showQuote}
|
||
>
|
||
<span>•••</span>
|
||
</button>
|
||
)}
|
||
{message.attachments && message.attachments.length > 0 && (
|
||
<MessageAttachments attachments={message.attachments} />
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) {
|
||
const { resolvedTheme } = useTheme()
|
||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||
const observerRef = useRef<ResizeObserver | null>(null)
|
||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const lastSavedHeightRef = useRef<number>(message.bodyHeight ?? 0)
|
||
const [height, setHeight] = useState(message.bodyHeight ?? 80)
|
||
const [hasQuote, setHasQuote] = useState(false)
|
||
const [showQuotes, setShowQuotes] = useState(false)
|
||
|
||
const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml])
|
||
const srcDoc = useMemo(
|
||
() => buildEmailDocument(message.bodyHtml!, { theme: resolvedTheme, adaptToTheme }),
|
||
[message.bodyHtml, resolvedTheme, adaptToTheme],
|
||
)
|
||
|
||
const handleLoad = useCallback(() => {
|
||
const iframe = iframeRef.current
|
||
const doc = iframe?.contentDocument
|
||
if (!doc?.body) return
|
||
setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]'))
|
||
const measure = () => {
|
||
// Measure off body only. documentElement.scrollHeight stretches to fill
|
||
// the iframe viewport, so once we size the iframe up (e.g. user expanded
|
||
// the quote) it never shrinks back when the body collapses. The body's
|
||
// own padding-bottom + last-child margin reset (see buildEmailDocument)
|
||
// already prevent under-reporting from collapsed bottom margins.
|
||
const next = Math.max(40, doc.body.scrollHeight, doc.body.offsetHeight)
|
||
setHeight((current) => (current === next ? current : next))
|
||
if (!message.id) return
|
||
if (Math.abs(next - lastSavedHeightRef.current) < 4) return
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||
saveTimerRef.current = setTimeout(() => {
|
||
lastSavedHeightRef.current = next
|
||
void window.ipc.invoke('gmail:saveMessageHeight', {
|
||
threadId,
|
||
messageId: message.id!,
|
||
height: next,
|
||
}).catch(() => {})
|
||
}, 500)
|
||
}
|
||
measure()
|
||
observerRef.current?.disconnect()
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
observerRef.current = new ResizeObserver(measure)
|
||
observerRef.current.observe(doc.body)
|
||
}
|
||
}, [message.id, threadId])
|
||
|
||
const toggleQuotes = useCallback(() => {
|
||
setShowQuotes((prev) => {
|
||
const next = !prev
|
||
const doc = iframeRef.current?.contentDocument
|
||
if (doc) doc.documentElement.dataset.showQuotes = next ? 'true' : ''
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => () => {
|
||
observerRef.current?.disconnect()
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||
}, [])
|
||
|
||
return (
|
||
<>
|
||
<iframe
|
||
ref={iframeRef}
|
||
srcDoc={srcDoc}
|
||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||
title="Email content"
|
||
className={cn('gmail-message-iframe', adaptToTheme && 'gmail-message-iframe-adaptive')}
|
||
style={{ height }}
|
||
onLoad={handleLoad}
|
||
/>
|
||
{hasQuote && (
|
||
<button
|
||
type="button"
|
||
className="gmail-quote-toggle"
|
||
onClick={toggleQuotes}
|
||
aria-label={showQuotes ? 'Hide quoted text' : 'Show quoted text'}
|
||
aria-expanded={showQuotes}
|
||
>
|
||
<span>•••</span>
|
||
</button>
|
||
)}
|
||
{message.attachments && message.attachments.length > 0 && (
|
||
<MessageAttachments attachments={message.attachments} />
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function formatAttachmentSize(bytes?: number): string {
|
||
if (!bytes || bytes <= 0) return ''
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
|
||
function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThreadMessage['attachments']> }) {
|
||
const openAttachment = (path: string, filename: string) => {
|
||
void window.ipc
|
||
.invoke('shell:openPath', { path })
|
||
.then((result) => {
|
||
if (result?.error) toast(`Could not open ${filename}: ${result.error}`, 'error')
|
||
})
|
||
.catch((err) => {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
toast(`Could not open ${filename}: ${message}`, 'error')
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="gmail-message-attachments">
|
||
{attachments.map((att) => {
|
||
const size = formatAttachmentSize(att.sizeBytes)
|
||
return (
|
||
<button
|
||
key={att.savedPath}
|
||
type="button"
|
||
className="gmail-attachment"
|
||
onClick={() => openAttachment(att.savedPath, att.filename)}
|
||
title={`Open ${att.filename}`}
|
||
>
|
||
<Paperclip size={13} />
|
||
<span className="gmail-attachment-name">{att.filename}</span>
|
||
{size && <span className="gmail-attachment-size">{size}</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
|
||
|
||
function ComposeToolbarButton({
|
||
editor,
|
||
command,
|
||
isActive,
|
||
label,
|
||
children,
|
||
}: {
|
||
editor: Editor
|
||
command: () => void
|
||
isActive: boolean
|
||
label: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={cn('gmail-compose-tool', isActive && 'is-active')}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => {
|
||
command()
|
||
editor.chain().focus().run()
|
||
}}
|
||
aria-label={label}
|
||
aria-pressed={isActive}
|
||
title={label}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
|
||
return (
|
||
<div className="gmail-compose-toolbar">
|
||
<button
|
||
type="button"
|
||
className="gmail-compose-tool"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => editor.chain().focus().undo().run()}
|
||
disabled={!editor.can().undo()}
|
||
aria-label="Undo"
|
||
title="Undo"
|
||
>
|
||
<Undo2 size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="gmail-compose-tool"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => editor.chain().focus().redo().run()}
|
||
disabled={!editor.can().redo()}
|
||
aria-label="Redo"
|
||
title="Redo"
|
||
>
|
||
<Redo2 size={14} />
|
||
</button>
|
||
<span className="gmail-compose-tool-sep" />
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleBold().run()}
|
||
isActive={editor.isActive('bold')}
|
||
label="Bold"
|
||
>
|
||
<Bold size={14} />
|
||
</ComposeToolbarButton>
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleItalic().run()}
|
||
isActive={editor.isActive('italic')}
|
||
label="Italic"
|
||
>
|
||
<Italic size={14} />
|
||
</ComposeToolbarButton>
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleStrike().run()}
|
||
isActive={editor.isActive('strike')}
|
||
label="Strikethrough"
|
||
>
|
||
<Strikethrough size={14} />
|
||
</ComposeToolbarButton>
|
||
<span className="gmail-compose-tool-sep" />
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleBulletList().run()}
|
||
isActive={editor.isActive('bulletList')}
|
||
label="Bulleted list"
|
||
>
|
||
<List size={14} />
|
||
</ComposeToolbarButton>
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleOrderedList().run()}
|
||
isActive={editor.isActive('orderedList')}
|
||
label="Numbered list"
|
||
>
|
||
<ListOrdered size={14} />
|
||
</ComposeToolbarButton>
|
||
<ComposeToolbarButton
|
||
editor={editor}
|
||
command={() => editor.chain().focus().toggleBlockquote().run()}
|
||
isActive={editor.isActive('blockquote')}
|
||
label="Quote"
|
||
>
|
||
<Quote size={14} />
|
||
</ComposeToolbarButton>
|
||
<span className="gmail-compose-tool-sep" />
|
||
<button
|
||
type="button"
|
||
className={cn('gmail-compose-tool', editor.isActive('link') && 'is-active')}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={onOpenLink}
|
||
aria-label="Link"
|
||
aria-pressed={editor.isActive('link')}
|
||
title="Link"
|
||
>
|
||
<LinkIcon size={14} />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type ContactSuggestion = {
|
||
name: string
|
||
email: string
|
||
}
|
||
|
||
function formatContactToken(c: ContactSuggestion): string {
|
||
return c.name ? `${c.name} <${c.email}>` : c.email
|
||
}
|
||
|
||
// Stable hue per email so the avatar circle keeps a consistent color.
|
||
function contactHue(email: string): number {
|
||
let h = 0
|
||
for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0
|
||
return h % 360
|
||
}
|
||
|
||
function contactInitial(c: ContactSuggestion): string {
|
||
const src = (c.name || c.email).trim()
|
||
return (src[0] || '?').toUpperCase()
|
||
}
|
||
|
||
// Renders a string with the matched substring wrapped in <mark>.
|
||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||
if (!query) return <>{text}</>
|
||
const lower = text.toLowerCase()
|
||
const q = query.toLowerCase()
|
||
const idx = lower.indexOf(q)
|
||
if (idx < 0) return <>{text}</>
|
||
return (
|
||
<>
|
||
{text.slice(0, idx)}
|
||
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
|
||
{text.slice(idx + q.length)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function RecipientField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
autoFocus,
|
||
trailing,
|
||
}: {
|
||
label: string
|
||
value: string[]
|
||
onChange: (next: string[]) => void
|
||
autoFocus?: boolean
|
||
trailing?: React.ReactNode
|
||
}) {
|
||
const [draft, setDraft] = useState('')
|
||
const [suggestions, setSuggestions] = useState<ContactSuggestion[]>([])
|
||
const [activeIndex, setActiveIndex] = useState(0)
|
||
const [isFocused, setIsFocused] = useState(false)
|
||
const [queryShown, setQueryShown] = useState('')
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
const fieldRef = useRef<HTMLDivElement>(null)
|
||
const listRef = useRef<HTMLUListElement>(null)
|
||
const queryTokenRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
if (autoFocus) inputRef.current?.focus()
|
||
}, [autoFocus])
|
||
|
||
const excludeEmails = useMemo(
|
||
() => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean),
|
||
[value],
|
||
)
|
||
|
||
// Debounced contact search — only runs when the user has actually typed
|
||
// something. An empty draft (including the post-pick reset) closes the menu.
|
||
useEffect(() => {
|
||
const trimmed = draft.trim()
|
||
if (!isFocused || !trimmed) {
|
||
queryTokenRef.current++
|
||
setSuggestions([])
|
||
return
|
||
}
|
||
const token = ++queryTokenRef.current
|
||
const timer = window.setTimeout(async () => {
|
||
try {
|
||
const result = (await window.ipc.invoke('gmail:searchContacts', {
|
||
query: draft,
|
||
limit: 8,
|
||
excludeEmails,
|
||
})) as { contacts?: ContactSuggestion[] } | undefined
|
||
if (token !== queryTokenRef.current) return
|
||
setSuggestions(result?.contacts ?? [])
|
||
setQueryShown(trimmed)
|
||
setActiveIndex(0)
|
||
} catch {
|
||
if (token !== queryTokenRef.current) return
|
||
setSuggestions([])
|
||
}
|
||
}, 60)
|
||
return () => window.clearTimeout(timer)
|
||
}, [draft, isFocused, excludeEmails])
|
||
|
||
// Keep the active row scrolled into view during keyboard navigation.
|
||
useEffect(() => {
|
||
const list = listRef.current
|
||
if (!list) return
|
||
const node = list.children[activeIndex] as HTMLElement | undefined
|
||
node?.scrollIntoView({ block: 'nearest' })
|
||
}, [activeIndex, suggestions])
|
||
|
||
const commit = (raw: string) => {
|
||
const additions = splitAddresses(raw)
|
||
if (additions.length === 0) return
|
||
onChange(dedupeRecipients([...value, ...additions], new Set()))
|
||
setDraft('')
|
||
setSuggestions([])
|
||
}
|
||
|
||
const pickSuggestion = (c: ContactSuggestion) => {
|
||
commit(formatContactToken(c))
|
||
// Keep focus in the input so the user can keep typing more recipients.
|
||
inputRef.current?.focus()
|
||
}
|
||
|
||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||
const hasSuggestions = suggestions.length > 0
|
||
if (event.key === 'ArrowDown' && hasSuggestions) {
|
||
event.preventDefault()
|
||
setActiveIndex((i) => (i + 1) % suggestions.length)
|
||
return
|
||
}
|
||
if (event.key === 'ArrowUp' && hasSuggestions) {
|
||
event.preventDefault()
|
||
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
|
||
return
|
||
}
|
||
if (event.key === 'Escape' && hasSuggestions) {
|
||
event.preventDefault()
|
||
setSuggestions([])
|
||
return
|
||
}
|
||
if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) {
|
||
// Prefer the highlighted suggestion when one is present.
|
||
if (hasSuggestions) {
|
||
event.preventDefault()
|
||
pickSuggestion(suggestions[activeIndex])
|
||
return
|
||
}
|
||
if (event.key === 'Enter' && draft.trim()) {
|
||
event.preventDefault()
|
||
commit(draft)
|
||
return
|
||
}
|
||
}
|
||
if (event.key === ',' || event.key === ';') {
|
||
if (draft.trim()) {
|
||
event.preventDefault()
|
||
commit(draft)
|
||
}
|
||
return
|
||
}
|
||
if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||
onChange(value.slice(0, -1))
|
||
}
|
||
}
|
||
|
||
const showSuggestions = isFocused && suggestions.length > 0
|
||
|
||
return (
|
||
<div className="gmail-recipient-row">
|
||
<span className="gmail-recipient-label">{label}</span>
|
||
<div className="gmail-recipient-field" ref={fieldRef}>
|
||
{value.map((token, index) => (
|
||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||
<button
|
||
type="button"
|
||
className="gmail-recipient-chip-remove"
|
||
aria-label={`Remove ${extractAddress(token)}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onChange(value.filter((_, idx) => idx !== index))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
ref={inputRef}
|
||
className="gmail-recipient-input"
|
||
value={draft}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
onFocus={() => setIsFocused(true)}
|
||
onBlur={() => {
|
||
// Defer so a mousedown on a suggestion can pick it before the menu closes.
|
||
window.setTimeout(() => {
|
||
setIsFocused(false)
|
||
if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) {
|
||
commit(draft)
|
||
}
|
||
}, 80)
|
||
}}
|
||
onPaste={(event) => {
|
||
const text = event.clipboardData.getData('text')
|
||
if (text && /[,;\n]/.test(text)) {
|
||
event.preventDefault()
|
||
commit(text)
|
||
}
|
||
}}
|
||
/>
|
||
{showSuggestions && (
|
||
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
|
||
{suggestions.map((c, idx) => {
|
||
const hue = contactHue(c.email)
|
||
return (
|
||
<li
|
||
key={c.email}
|
||
role="option"
|
||
aria-selected={idx === activeIndex}
|
||
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
|
||
onMouseDown={(event) => {
|
||
// Prevent input blur before click fires.
|
||
event.preventDefault()
|
||
pickSuggestion(c)
|
||
}}
|
||
onMouseEnter={() => setActiveIndex(idx)}
|
||
>
|
||
<span
|
||
className="gmail-recipient-suggestion-avatar"
|
||
style={{ background: `hsl(${hue}, 60%, 42%)` }}
|
||
aria-hidden="true"
|
||
>
|
||
{contactInitial(c)}
|
||
</span>
|
||
<span className="gmail-recipient-suggestion-text">
|
||
<span className="gmail-recipient-suggestion-name">
|
||
<HighlightedText text={c.name || c.email} query={queryShown} />
|
||
</span>
|
||
{c.name && (
|
||
<span className="gmail-recipient-suggestion-email">
|
||
<HighlightedText text={c.email} query={queryShown} />
|
||
</span>
|
||
)}
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const AI_GENERATE_SYSTEM =
|
||
'You write complete emails. Given an instruction, produce a subject line and a body. ' +
|
||
'Respond in EXACTLY this format and nothing else:\n' +
|
||
'Subject: <a concise, specific subject line>\n' +
|
||
'\n' +
|
||
'<the email body as plain text>\n' +
|
||
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
|
||
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
|
||
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
|
||
'(never write a placeholder like "[Your Name]").'
|
||
|
||
const AI_REWRITE_SYSTEM =
|
||
'You rewrite emails. Given the current subject and body plus an edit instruction, ' +
|
||
'produce the revised subject line and body. Keep the subject if it still fits, or ' +
|
||
'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' +
|
||
'Subject: <the subject line>\n' +
|
||
'\n' +
|
||
'<the rewritten email body as plain text>\n' +
|
||
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
|
||
'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".'
|
||
|
||
// Split AI output of the form "Subject: …\n\n<body>" into its parts. If no
|
||
// subject line is present, the whole text is treated as the body.
|
||
function parseGeneratedEmail(text: string): { subject: string | null; body: string } {
|
||
const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i)
|
||
if (match) {
|
||
const subject = match[1].trim()
|
||
const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '')
|
||
return { subject, body }
|
||
}
|
||
return { subject: null, body: text }
|
||
}
|
||
|
||
// Guarantee the sender's name signs off the email. If the model already ended
|
||
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
|
||
function ensureSignature(body: string, name: string): string {
|
||
const signer = name.trim()
|
||
if (!signer) return body
|
||
const trimmed = body.replace(/\s+$/, '')
|
||
// Check the last couple of lines so we don't double up an existing sign-off.
|
||
const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase()
|
||
if (tail.includes(signer.toLowerCase())) return trimmed
|
||
return `${trimmed}\n\n${signer}`
|
||
}
|
||
|
||
const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [
|
||
{ key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' },
|
||
{ key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' },
|
||
{ key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' },
|
||
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
|
||
]
|
||
|
||
// 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.
|
||
const ComposeBox = memo(function ComposeBox({
|
||
mode,
|
||
thread,
|
||
selfEmail = '',
|
||
onClose,
|
||
}: {
|
||
mode: ComposeMode
|
||
thread?: GmailThread
|
||
selfEmail?: string
|
||
onClose: () => void
|
||
}) {
|
||
const isNew = mode === 'new'
|
||
const latest = thread ? latestMessage(thread) : undefined
|
||
const initialRecipients = useMemo(
|
||
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
|
||
[mode, thread, selfEmail],
|
||
)
|
||
|
||
const [toList, setToList] = useState<string[]>(initialRecipients.to)
|
||
const [ccList, setCcList] = useState<string[]>(initialRecipients.cc)
|
||
const [bccList, setBccList] = useState<string[]>([])
|
||
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 initialContent = useMemo(() => {
|
||
if (!thread) return ''
|
||
if (mode === 'forward') return buildForwardedContent(thread)
|
||
// 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 ''
|
||
return source
|
||
.split(/\n{2,}/)
|
||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||
.join('')
|
||
}, [mode, thread])
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({ link: false }),
|
||
Link.configure({ openOnClick: false, autolink: true }),
|
||
Placeholder.configure({
|
||
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||
}),
|
||
],
|
||
editorProps: {
|
||
attributes: { class: 'gmail-compose-content' },
|
||
},
|
||
content: initialContent,
|
||
})
|
||
|
||
const [linkOpen, setLinkOpen] = useState(false)
|
||
const [linkUrl, setLinkUrl] = useState('')
|
||
const savedSelectionRef = useRef<{ from: number; to: number } | null>(null)
|
||
const linkInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const openLink = () => {
|
||
if (!editor) return
|
||
const { from, to: selTo } = editor.state.selection
|
||
savedSelectionRef.current = { from, to: selTo }
|
||
const existing = editor.getAttributes('link').href as string | undefined
|
||
setLinkUrl(existing || 'https://')
|
||
setLinkOpen(true)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!linkOpen) return
|
||
const id = window.setTimeout(() => linkInputRef.current?.select(), 0)
|
||
return () => window.clearTimeout(id)
|
||
}, [linkOpen])
|
||
|
||
const applyLink = () => {
|
||
if (!editor) {
|
||
setLinkOpen(false)
|
||
return
|
||
}
|
||
const sel = savedSelectionRef.current
|
||
setLinkOpen(false)
|
||
if (!sel) return
|
||
const trimmed = linkUrl.trim()
|
||
if (!trimmed || trimmed === 'https://') {
|
||
editor.chain().focus().setTextSelection(sel).extendMarkRange('link').unsetLink().run()
|
||
return
|
||
}
|
||
const href = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||
editor.chain().focus().setTextSelection(sel).extendMarkRange('link').setLink({ href }).run()
|
||
}
|
||
|
||
const cancelLink = () => {
|
||
setLinkOpen(false)
|
||
const sel = savedSelectionRef.current
|
||
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
|
||
}
|
||
|
||
// The signed-in account's display name, used to sign off AI-generated emails.
|
||
const [selfName, setSelfName] = useState<string>('')
|
||
useEffect(() => {
|
||
if (!isNew) return
|
||
let cancelled = false
|
||
window.ipc.invoke('gmail:getAccountName', {})
|
||
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
|
||
.catch(() => {})
|
||
return () => { cancelled = true }
|
||
}, [isNew])
|
||
|
||
const [aiPrompt, setAiPrompt] = useState('')
|
||
const [generating, setGenerating] = useState(false)
|
||
// Once a draft has been generated, show a follow-up bar for iterative edits
|
||
// ("add a line about…", "remove the last paragraph", etc.). It hides again if
|
||
// the draft is emptied (e.g. undone), tracked via hasContent below.
|
||
const [hasGenerated, setHasGenerated] = useState(false)
|
||
const [hasContent, setHasContent] = useState(false)
|
||
|
||
// Keep hasContent in sync with the editor across typing, undo/redo, and clears.
|
||
useEffect(() => {
|
||
if (!editor) return
|
||
const sync = () => setHasContent(!editor.isEmpty)
|
||
sync()
|
||
editor.on('update', sync)
|
||
return () => { editor.off('update', sync) }
|
||
}, [editor])
|
||
|
||
// Clearing the body reverts the AI control to its "Write" state and drops the
|
||
// generated subject, so an emptied composer behaves like a fresh one. The
|
||
// hasGenerated guard avoids wiping a subject typed before any generation.
|
||
useEffect(() => {
|
||
if (hasGenerated && !hasContent) {
|
||
setHasGenerated(false)
|
||
setSubject('')
|
||
}
|
||
}, [hasGenerated, hasContent])
|
||
|
||
const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => {
|
||
if (!editor || generating) return
|
||
const current = editor.getText().trim()
|
||
let prompt: string
|
||
let system: string
|
||
if (aiMode === 'generate') {
|
||
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
|
||
system = AI_GENERATE_SYSTEM
|
||
const ctx: string[] = []
|
||
// Use the recipients' names (from the contacts picker) so the AI can
|
||
// address them naturally; fall back to the address when there's no name.
|
||
const recipientNames = toList
|
||
.map((token) => {
|
||
const name = extractName(token)
|
||
return name && name !== 'Unknown' ? name : extractAddress(token)
|
||
})
|
||
.filter(Boolean)
|
||
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
|
||
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
|
||
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
|
||
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
|
||
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
|
||
} else {
|
||
if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return }
|
||
if (!current) { toast('Write something first.', 'error'); return }
|
||
system = AI_REWRITE_SYSTEM
|
||
const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : ''
|
||
prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}`
|
||
}
|
||
|
||
setGenerating(true)
|
||
try {
|
||
// Draft through Copilot: no model override, so the backend resolves the
|
||
// same default model/provider the Copilot chat uses (models.json).
|
||
const res = await window.ipc.invoke('llm:generate', { prompt, system })
|
||
if (res.error || !res.text) {
|
||
toast(res.error || 'No text was generated.', 'error')
|
||
return
|
||
}
|
||
// Replace via a tracked transaction (selectAll + insertContent) so the AI
|
||
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
|
||
if (aiMode === 'generate') {
|
||
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
|
||
if (generatedSubject) setSubject(generatedSubject)
|
||
// Always sign off with the account name, even if the model omitted it.
|
||
const signed = ensureSignature(body, selfName)
|
||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
|
||
setHasGenerated(true)
|
||
} else {
|
||
// Rewrites also regenerate the subject so it stays in sync with the body.
|
||
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
|
||
if (rewrittenSubject) setSubject(rewrittenSubject)
|
||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
|
||
}
|
||
} catch (err) {
|
||
toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||
} finally {
|
||
setGenerating(false)
|
||
}
|
||
}
|
||
|
||
// The single Write/Edit bar: generate a fresh draft until one exists, then
|
||
// switch to rewriting it. Clears the prompt after a run kicks off.
|
||
const runAiBar = async () => {
|
||
await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate')
|
||
setAiPrompt('')
|
||
}
|
||
|
||
// Attachments staged for this message. contentBase64 is the raw file bytes,
|
||
// read in the renderer; the main process wraps them into the MIME on send.
|
||
const [attachments, setAttachments] = useState<
|
||
Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }>
|
||
>([])
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
// Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%.
|
||
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
|
||
|
||
// Read a file's bytes as raw base64 (the part after the data: URL prefix).
|
||
const readAsBase64 = (file: File) =>
|
||
new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
|
||
reader.onload = () => {
|
||
const result = String(reader.result)
|
||
const comma = result.indexOf(',')
|
||
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
})
|
||
|
||
const addFiles = async (files: FileList | null) => {
|
||
if (!files || files.length === 0) return
|
||
const staged: typeof attachments = []
|
||
for (const file of Array.from(files)) {
|
||
try {
|
||
staged.push({
|
||
id: `${file.name}-${file.size}-${file.lastModified}`,
|
||
filename: file.name,
|
||
mimeType: file.type || 'application/octet-stream',
|
||
size: file.size,
|
||
contentBase64: await readAsBase64(file),
|
||
})
|
||
} catch {
|
||
toast(`Could not read ${file.name}.`, 'error')
|
||
}
|
||
}
|
||
setAttachments((prev) => {
|
||
const merged = [...prev]
|
||
for (const item of staged) {
|
||
if (!merged.some((a) => a.id === item.id)) merged.push(item)
|
||
}
|
||
const total = merged.reduce((sum, a) => sum + a.size, 0)
|
||
if (total > MAX_TOTAL_BYTES) {
|
||
toast('Attachments exceed the 25MB limit.', 'error')
|
||
return prev
|
||
}
|
||
return merged
|
||
})
|
||
}
|
||
|
||
const removeAttachment = (id: string) => {
|
||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||
}
|
||
|
||
const [sending, setSending] = useState(false)
|
||
const sendInGmail = async () => {
|
||
if (!editor || sending) return
|
||
const html = editor.getHTML()
|
||
const text = editor.getText().trim()
|
||
if (!text) {
|
||
toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error')
|
||
return
|
||
}
|
||
|
||
if (toList.length === 0) {
|
||
toast('Add at least one recipient.', 'error')
|
||
return
|
||
}
|
||
|
||
// Build References chain from all known message ids (newest last).
|
||
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
|
||
|
||
setSending(true)
|
||
try {
|
||
const result = await window.ipc.invoke('gmail:sendReply', {
|
||
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) : '(No 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,
|
||
})
|
||
if (result.error) {
|
||
toast(`Send failed: ${result.error}`, 'error')
|
||
return
|
||
}
|
||
toast('Sent.', 'success')
|
||
onClose()
|
||
} catch (err) {
|
||
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const refineWithCopilot = () => {
|
||
if (!editor || !thread) return
|
||
const currentDraft = editor.getText().trim()
|
||
const threadSubject = thread.subject || '(No subject)'
|
||
|
||
const lines: string[] = []
|
||
lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`)
|
||
lines.push('')
|
||
lines.push(`**Mode:** ${modeLabel}`)
|
||
lines.push(`**Subject:** ${threadSubject}`)
|
||
lines.push('')
|
||
lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`)
|
||
lines.push('')
|
||
thread.messages.forEach((message, index) => {
|
||
lines.push(`### Message ${index + 1}`)
|
||
if (message.from) lines.push(`**From:** ${message.from}`)
|
||
if (message.to) lines.push(`**To:** ${message.to}`)
|
||
if (message.date) lines.push(`**Date:** ${message.date}`)
|
||
lines.push('')
|
||
lines.push((message.body || '(empty)').trim())
|
||
lines.push('')
|
||
})
|
||
|
||
lines.push(`## Current draft`)
|
||
lines.push('')
|
||
lines.push(currentDraft || '(empty — no draft yet)')
|
||
|
||
window.__pendingEmailDraft = { prompt: lines.join('\n') }
|
||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||
}
|
||
|
||
const card = (
|
||
<div
|
||
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
|
||
onClick={isNew ? (event) => event.stopPropagation() : undefined}
|
||
>
|
||
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
|
||
<span>{modeLabel}</span>
|
||
<button
|
||
type="button"
|
||
className={isNew ? 'gmail-icon-button' : undefined}
|
||
onClick={onClose}
|
||
aria-label="Close compose"
|
||
>×</button>
|
||
</div>
|
||
<RecipientField
|
||
label="To"
|
||
value={toList}
|
||
onChange={setToList}
|
||
autoFocus={isNew || mode === 'forward'}
|
||
trailing={
|
||
<div className="gmail-recipient-toggles">
|
||
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
|
||
</div>
|
||
}
|
||
/>
|
||
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
|
||
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
|
||
{isNew && (
|
||
<>
|
||
<div className="gmail-compose-ai-bar">
|
||
<input
|
||
className="gmail-compose-ai-input"
|
||
value={aiPrompt}
|
||
onChange={(event) => setAiPrompt(event.target.value)}
|
||
placeholder={hasGenerated
|
||
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
|
||
: 'Describe the email and let AI write it…'}
|
||
disabled={generating}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault()
|
||
void runAiBar()
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="gmail-refine-button"
|
||
onClick={() => { void runAiBar() }}
|
||
disabled={generating}
|
||
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
|
||
>
|
||
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
|
||
{generating
|
||
? (hasGenerated ? 'Editing…' : 'Writing…')
|
||
: (hasGenerated ? 'Edit' : 'Write')}
|
||
</button>
|
||
</div>
|
||
<div className="gmail-compose-ai-presets">
|
||
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
|
||
{TONE_PRESETS.map((preset) => (
|
||
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
{(isNew || mode === 'forward') && (
|
||
<div className="gmail-compose-line">
|
||
<span className="gmail-compose-label">Subject</span>
|
||
<input
|
||
className="gmail-compose-subject-input"
|
||
value={subject}
|
||
onChange={(event) => setSubject(event.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={(event) => {
|
||
void addFiles(event.target.value ? event.currentTarget.files : null)
|
||
event.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
{attachments.length > 0 && (
|
||
<div className="gmail-compose-attachments">
|
||
{attachments.map((att) => (
|
||
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
|
||
<Paperclip size={13} />
|
||
<span className="gmail-compose-attachment-name">{att.filename}</span>
|
||
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
|
||
<button
|
||
type="button"
|
||
className="gmail-compose-attachment-remove"
|
||
onClick={() => removeAttachment(att.id)}
|
||
aria-label={`Remove ${att.filename}`}
|
||
>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{linkOpen && (
|
||
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
|
||
<input
|
||
ref={linkInputRef}
|
||
value={linkUrl}
|
||
onChange={(event) => setLinkUrl(event.target.value)}
|
||
placeholder="https://example.com"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault()
|
||
applyLink()
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
cancelLink()
|
||
}
|
||
}}
|
||
/>
|
||
<button type="button" className="gmail-compose-link-popover-apply" onClick={applyLink}>Apply</button>
|
||
<button type="button" className="gmail-compose-link-popover-cancel" onClick={cancelLink}>Cancel</button>
|
||
</div>
|
||
)}
|
||
<div className="gmail-compose-actions">
|
||
<div className="gmail-compose-actions-primary">
|
||
<button
|
||
type="button"
|
||
className="gmail-send-button"
|
||
onClick={() => { void sendInGmail() }}
|
||
disabled={sending}
|
||
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
|
||
>
|
||
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
|
||
{sending ? 'Sending…' : 'Send'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="gmail-refine-button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={sending}
|
||
title="Attach files"
|
||
>
|
||
<Paperclip size={15} />
|
||
Attach
|
||
</button>
|
||
{thread && (
|
||
<button
|
||
type="button"
|
||
className="gmail-refine-button"
|
||
onClick={refineWithCopilot}
|
||
title="Refine this draft with Copilot"
|
||
>
|
||
<Sparkles size={15} />
|
||
Refine
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
if (isNew) {
|
||
return (
|
||
<div className="gmail-compose-overlay" onClick={onClose}>
|
||
{card}
|
||
</div>
|
||
)
|
||
}
|
||
return card
|
||
})
|
||
|
||
function ThreadDetail({
|
||
thread,
|
||
onClose,
|
||
hidden,
|
||
}: {
|
||
thread: GmailThread
|
||
onClose: () => void
|
||
hidden?: boolean
|
||
}) {
|
||
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
|
||
const [selfEmail, setSelfEmail] = useState<string>('')
|
||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
|
||
() => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : [])
|
||
)
|
||
|
||
// The connected Gmail address, so reply-all can exclude "me".
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
window.ipc.invoke('gmail:getAccountEmail', {})
|
||
.then((res) => { if (!cancelled && res?.email) setSelfEmail(res.email) })
|
||
.catch(() => {})
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
const canReplyAll = useMemo(() => {
|
||
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
|
||
return cc.length > 0 || to.length > 1
|
||
}, [thread, selfEmail])
|
||
|
||
const toggleExpand = useCallback((index: number) => {
|
||
setExpandedIndices((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(index)) next.delete(index)
|
||
else next.add(index)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
return (
|
||
<div className={cn('gmail-detail gmail-detail-inline', hidden && 'gmail-detail-hidden')}>
|
||
<div className="gmail-detail-toolbar">
|
||
<div className="gmail-thread-subject-inline">{thread.subject || '(No subject)'}</div>
|
||
<button type="button" className="gmail-icon-button" onClick={onClose} aria-label="Close thread">
|
||
<span>×</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="gmail-thread-body">
|
||
{thread.summary && (
|
||
<div className="gmail-thread-summary">
|
||
<span className="gmail-thread-summary-label">Summary</span>
|
||
<span className="gmail-thread-summary-text">{thread.summary}</span>
|
||
</div>
|
||
)}
|
||
<div className="gmail-message-stack">
|
||
{thread.messages.map((message, index) => {
|
||
const isExpanded = expandedIndices.has(index)
|
||
return (
|
||
<div key={message.id || index} className={cn('gmail-message', isExpanded && 'gmail-message-expanded')}>
|
||
<div className="gmail-message-avatar" style={{ backgroundColor: avatarColor(message.from) }}>
|
||
{getInitial(message.from)}
|
||
</div>
|
||
<div className="gmail-message-main">
|
||
<button
|
||
type="button"
|
||
className="gmail-message-header"
|
||
onClick={() => toggleExpand(index)}
|
||
aria-expanded={isExpanded}
|
||
>
|
||
<div className="gmail-message-meta">
|
||
<div className="gmail-message-from">
|
||
<strong>{extractName(message.from)}</strong>
|
||
{isExpanded && <span>{extractAddress(message.from)}</span>}
|
||
</div>
|
||
<div className="gmail-message-date">
|
||
{isExpanded ? formatFullDate(message.date) : formatInboxTime(message.date)}
|
||
</div>
|
||
</div>
|
||
{isExpanded ? (
|
||
<>
|
||
<div className="gmail-message-to">to {message.to || 'me'}</div>
|
||
{message.cc && <div className="gmail-message-cc">cc {message.cc}</div>}
|
||
</>
|
||
) : (
|
||
<div className="gmail-message-snippet">{snippet(message.body)}</div>
|
||
)}
|
||
</button>
|
||
{isExpanded && (
|
||
<MessageBody message={message} threadId={thread.threadId} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="gmail-thread-actions">
|
||
<button type="button" onClick={() => setComposeMode('reply')}>
|
||
<Reply size={16} />
|
||
Reply
|
||
</button>
|
||
{canReplyAll && (
|
||
<button type="button" onClick={() => setComposeMode('replyAll')}>
|
||
<ReplyAll size={16} />
|
||
Reply all
|
||
</button>
|
||
)}
|
||
<button type="button" onClick={() => setComposeMode('forward')}>
|
||
<Forward size={16} />
|
||
Forward
|
||
</button>
|
||
</div>
|
||
|
||
{composeMode && (
|
||
<ComposeBox
|
||
key={composeMode}
|
||
mode={composeMode}
|
||
thread={thread}
|
||
selfEmail={selfEmail}
|
||
onClose={() => setComposeMode(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const MAX_KEPT_OPEN = 5
|
||
const PAGE_SIZE = 25
|
||
type InboxSection = 'important' | 'other'
|
||
|
||
interface SectionState {
|
||
threads: GmailThread[]
|
||
nextCursor: string | null
|
||
hasReachedEnd: boolean
|
||
loadingPage: boolean
|
||
}
|
||
|
||
const initialSectionState: SectionState = {
|
||
threads: [],
|
||
nextCursor: null,
|
||
hasReachedEnd: false,
|
||
loadingPage: false,
|
||
}
|
||
|
||
// Module-level survives unmount/remount within the renderer process — so switching
|
||
// panels and coming back doesn't reload from scratch.
|
||
let persistedImportant: SectionState | null = null
|
||
let persistedOther: SectionState | null = null
|
||
|
||
function clearLoadingFlag(state: SectionState | null): SectionState {
|
||
if (!state) return initialSectionState
|
||
return { ...state, loadingPage: false }
|
||
}
|
||
|
||
export type EmailViewProps = {
|
||
/** If provided, the view opens with this thread already expanded. */
|
||
initialThreadId?: string | null
|
||
/** Bump to re-focus on the same threadId after navigating away inside the view. */
|
||
threadIdVersion?: number
|
||
}
|
||
|
||
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
|
||
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
|
||
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
||
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
|
||
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(initialThreadId ? [initialThreadId] : [])
|
||
useEffect(() => {
|
||
setSelectedThreadId(initialThreadId ?? null)
|
||
if (initialThreadId) {
|
||
setOpenedThreadIds((prev) => {
|
||
const without = prev.filter((id) => id !== initialThreadId)
|
||
return [...without, initialThreadId].slice(-MAX_KEPT_OPEN)
|
||
})
|
||
}
|
||
}, [initialThreadId, threadIdVersion])
|
||
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [query, setQuery] = useState('')
|
||
const [composeOpen, setComposeOpen] = useState(false)
|
||
// Stable so the open composer isn't re-rendered on every inbox sync tick.
|
||
const closeCompose = useCallback(() => setComposeOpen(false), [])
|
||
// Gmail sync uses the native Google OAuth connection.
|
||
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
const check = async () => {
|
||
try {
|
||
const status = await window.ipc.invoke('gmail:getConnectionStatus', {})
|
||
if (!cancelled) setEmailConnection(status)
|
||
} catch {
|
||
if (!cancelled) {
|
||
setEmailConnection({
|
||
connected: false,
|
||
hasRequiredScope: false,
|
||
missingScopes: [],
|
||
email: null,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
void check()
|
||
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
|
||
return () => {
|
||
cancelled = true
|
||
cleanupOAuthConnect()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { persistedImportant = important }, [important])
|
||
useEffect(() => { persistedOther = other }, [other])
|
||
|
||
const setSection = useCallback((section: InboxSection, updater: (prev: SectionState) => SectionState) => {
|
||
if (section === 'important') setImportant(updater)
|
||
else setOther(updater)
|
||
}, [])
|
||
|
||
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) => {
|
||
try {
|
||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
||
if (result.ok) {
|
||
removeThreadFromState(threadId)
|
||
} else if (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) => {
|
||
try {
|
||
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
|
||
if (result.ok) {
|
||
removeThreadFromState(threadId)
|
||
} else if (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 === 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<Set<string>>(new Set())
|
||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
const cancelHoverPrefetch = useCallback(() => {
|
||
if (hoverTimerRef.current) {
|
||
clearTimeout(hoverTimerRef.current)
|
||
hoverTimerRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const scheduleHoverPrefetch = useCallback((thread: GmailThread) => {
|
||
cancelHoverPrefetch()
|
||
if (prefetchedRef.current.has(thread.threadId)) return
|
||
hoverTimerRef.current = setTimeout(() => {
|
||
hoverTimerRef.current = null
|
||
prefetchedRef.current.add(thread.threadId)
|
||
prefetchThreadImages(thread)
|
||
}, PREFETCH_HOVER_MS)
|
||
}, [cancelHoverPrefetch])
|
||
|
||
useEffect(() => () => {
|
||
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
|
||
}, [])
|
||
|
||
// Per-section load epochs so concurrent reloads of different sections don't
|
||
// trample each other. (A single shared epoch caused Important's silent
|
||
// reload to be discarded whenever Other was reloaded in the same tick.)
|
||
const epochsRef = useRef<Record<InboxSection, number>>({ important: 0, other: 0 })
|
||
|
||
const sectionChannel = (section: InboxSection) =>
|
||
section === 'important' ? 'gmail:getImportant' as const : 'gmail:getEverythingElse' as const
|
||
|
||
const loadNextPage = useCallback(async (section: InboxSection) => {
|
||
const current = section === 'important' ? important : other
|
||
if (current.loadingPage || current.hasReachedEnd) return
|
||
|
||
const epoch = epochsRef.current[section]
|
||
setSection(section, (prev) => ({ ...prev, loadingPage: true }))
|
||
try {
|
||
const result = await window.ipc.invoke(sectionChannel(section), {
|
||
cursor: current.nextCursor ?? undefined,
|
||
limit: PAGE_SIZE,
|
||
})
|
||
if (epoch !== epochsRef.current[section]) return
|
||
setSection(section, (prev) => ({
|
||
threads: [...prev.threads, ...result.threads],
|
||
nextCursor: result.nextCursor,
|
||
hasReachedEnd: result.nextCursor === null,
|
||
loadingPage: false,
|
||
}))
|
||
} catch (err) {
|
||
if (epoch !== epochsRef.current[section]) return
|
||
console.warn(`[Gmail] page load failed for ${section}:`, err)
|
||
setSection(section, (prev) => ({ ...prev, loadingPage: false }))
|
||
}
|
||
}, [important, other, setSection])
|
||
|
||
const reloadFirstPage = useCallback(async (section: InboxSection, options: { silent?: boolean } = {}) => {
|
||
const epoch = ++epochsRef.current[section]
|
||
if (options.silent) {
|
||
setSection(section, (prev) => ({ ...prev, loadingPage: true }))
|
||
} else {
|
||
setSection(section, () => ({ ...initialSectionState, loadingPage: true }))
|
||
}
|
||
try {
|
||
const result = await window.ipc.invoke(sectionChannel(section), {
|
||
limit: PAGE_SIZE,
|
||
})
|
||
if (epoch !== epochsRef.current[section]) return
|
||
setSection(section, () => ({
|
||
threads: result.threads,
|
||
nextCursor: result.nextCursor,
|
||
hasReachedEnd: result.nextCursor === null,
|
||
loadingPage: false,
|
||
}))
|
||
} catch (err) {
|
||
if (epoch !== epochsRef.current[section]) return
|
||
console.warn(`[Gmail] initial page load failed for ${section}:`, err)
|
||
setSection(section, (prev) => ({ ...prev, loadingPage: false }))
|
||
}
|
||
}, [setSection])
|
||
|
||
// Initial load — fetch page 1 of Important. On first-ever mount we do a
|
||
// non-silent load (shows loading state). On re-mount with persisted state we
|
||
// do a silent reconcile against the cache — necessary because the watcher
|
||
// subscription only runs while mounted, so any cache changes that happened
|
||
// while the panel was unmounted would otherwise stay invisible.
|
||
useEffect(() => {
|
||
if (hadPersistedDataOnMount.current) {
|
||
void reloadFirstPage('important', { silent: true })
|
||
// Reconcile Other too if it had been loaded before the unmount.
|
||
if (other.threads.length > 0) {
|
||
void reloadFirstPage('other', { silent: true })
|
||
}
|
||
} else {
|
||
void reloadFirstPage('important')
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
// Once Important is exhausted, kick off page 1 of Everything else.
|
||
useEffect(() => {
|
||
if (!important.hasReachedEnd) return
|
||
if (other.threads.length > 0) return
|
||
if (other.loadingPage) return
|
||
void reloadFirstPage('other')
|
||
}, [important.hasReachedEnd, other.threads.length, other.loadingPage, reloadFirstPage])
|
||
|
||
// Live updates: watcher on inbox_lists/ → silently refresh visible sections
|
||
// when files change. Throttled to at most one reload per ~3s so a burst of
|
||
// backend writes (sync processing many threads sequentially) coalesces into
|
||
// a small number of in-place updates rather than a flicker storm.
|
||
// Suppressed while a thread is open (reading/replying) or the compose-new
|
||
// modal is open; deferred until whichever is open closes. A reload replaces
|
||
// the threads array and re-renders the whole inbox list (and any mounted
|
||
// ThreadDetail iframes) on the main thread — that re-render janks an open
|
||
// composer even though ComposeBox itself is memoized, so we pause it.
|
||
const pendingReloadRef = useRef(false)
|
||
const reloadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const lastReloadAtRef = useRef(0)
|
||
const isSelectedRef = useRef<string | null>(null)
|
||
isSelectedRef.current = selectedThreadId
|
||
const composeOpenRef = useRef(false)
|
||
composeOpenRef.current = composeOpen
|
||
const isRefreshingRef = useRef(false)
|
||
isRefreshingRef.current = refreshing
|
||
const otherHasThreadsRef = useRef(false)
|
||
otherHasThreadsRef.current = other.threads.length > 0
|
||
|
||
const RELOAD_THROTTLE_MS = 3000
|
||
|
||
const doReload = useCallback(() => {
|
||
if (isRefreshingRef.current) return
|
||
if (isSelectedRef.current !== null || composeOpenRef.current) {
|
||
pendingReloadRef.current = true
|
||
return
|
||
}
|
||
lastReloadAtRef.current = Date.now()
|
||
void reloadFirstPage('important', { silent: true })
|
||
// Only refresh Other if it had been loaded — otherwise the chained
|
||
// effect handles it once Important hits hasReachedEnd.
|
||
if (otherHasThreadsRef.current) {
|
||
void reloadFirstPage('other', { silent: true })
|
||
}
|
||
}, [reloadFirstPage])
|
||
|
||
// Leading-edge throttle:
|
||
// - First event after a quiet period (≥ THROTTLE) → fire immediately.
|
||
// - During an active burst → queue a trailing fire at the next throttle
|
||
// boundary. Subsequent events while a trailing fire is pending do nothing
|
||
// (so a continuous stream of writes can't starve the reload).
|
||
const triggerLiveReload = useCallback(() => {
|
||
const sinceLast = Date.now() - lastReloadAtRef.current
|
||
if (sinceLast >= RELOAD_THROTTLE_MS && !reloadDebounceRef.current) {
|
||
doReload()
|
||
return
|
||
}
|
||
if (reloadDebounceRef.current) return
|
||
const wait = Math.max(200, RELOAD_THROTTLE_MS - sinceLast)
|
||
reloadDebounceRef.current = setTimeout(() => {
|
||
reloadDebounceRef.current = null
|
||
doReload()
|
||
}, wait)
|
||
}, [doReload])
|
||
|
||
useEffect(() => {
|
||
const cleanup = window.ipc.on('workspace:didChange', (event) => {
|
||
const matches = (p: string) => p.startsWith('inbox_lists/')
|
||
switch (event.type) {
|
||
case 'created':
|
||
case 'changed':
|
||
case 'deleted':
|
||
if (event.path && matches(event.path)) triggerLiveReload()
|
||
break
|
||
case 'moved':
|
||
if ((event.from && matches(event.from)) || (event.to && matches(event.to))) triggerLiveReload()
|
||
break
|
||
case 'bulkChanged':
|
||
if (event.paths?.some(matches)) triggerLiveReload()
|
||
break
|
||
}
|
||
})
|
||
return () => {
|
||
cleanup()
|
||
if (reloadDebounceRef.current) clearTimeout(reloadDebounceRef.current)
|
||
}
|
||
}, [triggerLiveReload])
|
||
|
||
// When the user closes the open thread or the compose-new modal, if updates
|
||
// arrived while it was open, flush them now.
|
||
useEffect(() => {
|
||
if (selectedThreadId !== null || composeOpen) return
|
||
if (!pendingReloadRef.current) return
|
||
pendingReloadRef.current = false
|
||
lastReloadAtRef.current = Date.now()
|
||
void reloadFirstPage('important', { silent: true })
|
||
if (otherHasThreadsRef.current) {
|
||
void reloadFirstPage('other', { silent: true })
|
||
}
|
||
}, [selectedThreadId, composeOpen, reloadFirstPage])
|
||
|
||
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
|
||
// the watcher fires, and triggerLiveReload picks up the changes. The
|
||
// spinner is a UX cue — we stop it shortly after the sync poke.
|
||
const refreshInFlightRef = useRef(false)
|
||
const refresh = useCallback(async () => {
|
||
if (refreshInFlightRef.current) return
|
||
refreshInFlightRef.current = true
|
||
setRefreshing(true)
|
||
setError(null)
|
||
try {
|
||
await window.ipc.invoke('gmail:triggerSync', {})
|
||
} catch (err) {
|
||
console.warn('[Gmail] triggerSync failed:', err)
|
||
setError(err instanceof Error ? err.message : String(err))
|
||
} finally {
|
||
// Leave the spinner on briefly so the user sees feedback; the watcher
|
||
// will refresh the visible state once the sync cycle writes new files.
|
||
setTimeout(() => {
|
||
refreshInFlightRef.current = false
|
||
setRefreshing(false)
|
||
}, 800)
|
||
}
|
||
}, [])
|
||
|
||
// Kick off a live refresh on mount only when there's no persisted data —
|
||
// otherwise we'd clobber the snapshot the user already had.
|
||
useEffect(() => {
|
||
if (hadPersistedDataOnMount.current) return
|
||
void refresh()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const filterThreads = useCallback((threads: GmailThread[]) => {
|
||
const normalized = query.trim().toLowerCase()
|
||
if (!normalized) return threads
|
||
return threads.filter((thread) => {
|
||
const latest = latestMessage(thread)
|
||
return [
|
||
thread.subject,
|
||
latest?.from,
|
||
latest?.to,
|
||
latest?.body,
|
||
].some(value => (value || '').toLowerCase().includes(normalized))
|
||
})
|
||
}, [query])
|
||
|
||
const visibleImportant = useMemo(() => filterThreads(important.threads), [important.threads, filterThreads])
|
||
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
|
||
|
||
const hasAny = important.threads.length > 0 || other.threads.length > 0
|
||
const initialLoading = !hasAny && refreshing
|
||
const needsEmailConnect = emailConnection?.connected === false
|
||
const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope
|
||
|
||
const renderRow = (thread: GmailThread) => {
|
||
const latest = latestMessage(thread)
|
||
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 (
|
||
<div key={thread.threadId} className="gmail-row-group">
|
||
<div
|
||
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
|
||
onMouseEnter={() => scheduleHoverPrefetch(thread)}
|
||
onMouseLeave={cancelHoverPrefetch}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
||
onClick={() => toggleThread(thread)}
|
||
>
|
||
<span className="gmail-row-dot" aria-hidden />
|
||
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
|
||
<span className="gmail-row-content">
|
||
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
|
||
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
|
||
</span>
|
||
<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="Archive"
|
||
aria-label="Archive"
|
||
onClick={(e) => { stop(e); void archiveThreadAction(thread.threadId) }}
|
||
>
|
||
<Archive size={15} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="gmail-row-action gmail-row-action-danger"
|
||
title="Delete"
|
||
aria-label="Delete"
|
||
onClick={(e) => { stop(e); void trashThreadAction(thread.threadId) }}
|
||
>
|
||
<Trash2 size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{isMounted && (
|
||
<ThreadDetail
|
||
thread={thread}
|
||
onClose={() => setSelectedThreadId(null)}
|
||
hidden={!isSelected}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="gmail-shell">
|
||
<div className="gmail-main">
|
||
<div className="gmail-topbar">
|
||
<div className="gmail-search">
|
||
<Search size={18} />
|
||
<input
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder="Search loaded 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} />}
|
||
</button>
|
||
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
|
||
<SquarePen size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && !hasAny ? (
|
||
<div className="gmail-empty-state">Could not load mail: {error}</div>
|
||
) : hasAny ? (
|
||
<div className="gmail-list" aria-label="Recent emails">
|
||
{important.threads.length > 0 && (
|
||
<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>
|
||
{visibleImportant.map(renderRow)}
|
||
{!important.hasReachedEnd && (
|
||
<SectionSentinel
|
||
disabled={important.loadingPage || important.hasReachedEnd}
|
||
onIntersect={() => loadNextPage('important')}
|
||
loading={important.loadingPage}
|
||
/>
|
||
)}
|
||
</section>
|
||
)}
|
||
{important.hasReachedEnd && other.threads.length > 0 && (
|
||
<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>
|
||
{visibleOther.map(renderRow)}
|
||
{!other.hasReachedEnd && (
|
||
<SectionSentinel
|
||
disabled={other.loadingPage || other.hasReachedEnd}
|
||
onIntersect={() => loadNextPage('other')}
|
||
loading={other.loadingPage}
|
||
/>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
) : needsEmailConnect || needsEmailReconnect ? (
|
||
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
||
<Mail size={28} className="opacity-50" />
|
||
<p>
|
||
{needsEmailReconnect
|
||
? 'Reconnect your email to enable Gmail sync and actions.'
|
||
: 'Connect your email to see your inbox here.'}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSettingsOpen(true)}
|
||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
<Mail size={15} />
|
||
{needsEmailReconnect ? 'Reconnect your email' : 'Connect your email'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="gmail-empty-state">
|
||
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
|
||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionSentinel({
|
||
disabled,
|
||
onIntersect,
|
||
loading,
|
||
}: {
|
||
disabled: boolean
|
||
onIntersect: () => void
|
||
loading: boolean
|
||
}) {
|
||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
if (disabled) return
|
||
const el = sentinelRef.current
|
||
if (!el) return
|
||
const observer = new IntersectionObserver((entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
onIntersect()
|
||
}
|
||
}, { rootMargin: '200px' })
|
||
observer.observe(el)
|
||
return () => observer.disconnect()
|
||
}, [disabled, onIntersect])
|
||
|
||
return (
|
||
<div ref={sentinelRef} className="gmail-section-sentinel" aria-hidden>
|
||
{loading ? <LoaderIcon size={14} className="animate-spin" /> : null}
|
||
</div>
|
||
)
|
||
}
|