From 0e3d058c2959034cd84de9cd7382e9bf2f5b678a Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 6 May 2026 21:41:26 +0530 Subject: [PATCH] feat: Gmail-style email block with inbox container layout (#531) * feat: restyle email block with Gmail-style layout and avatar * style: apply Google Sans/Roboto font to email block * feat: add Gmail inbox-style multi-email block with accordion rows * style: fix sender name casing, weight, and email display in expanded view * feat: emails inbox block with container layout, two-line rows, Gmail title style --- .../src/components/markdown-editor.tsx | 3 +- .../renderer/src/extensions/email-block.tsx | 550 ++++++++++++------ apps/x/apps/renderer/src/styles/editor.css | 545 +++++++++++++---- .../core/src/knowledge/ensure_daily_note.ts | 6 +- .../core/src/knowledge/inline_task_agent.ts | 10 +- apps/x/packages/shared/src/blocks.ts | 7 + apps/x/pnpm-lock.yaml | 141 +++-- 7 files changed, 899 insertions(+), 363 deletions(-) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index e97f7c6e..10e429d8 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -20,7 +20,7 @@ import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' -import { EmailBlockExtension } from '@/extensions/email-block' +import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' @@ -707,6 +707,7 @@ export const MarkdownEditor = forwardRef" format */ -function senderFirstName(from: string): string { - const name = from.replace(/<.*>/, '').trim() - return name.split(/\s+/)[0] || name +function formatFullDate(dateStr: string): string { + try { + const d = new Date(dateStr) + if (isNaN(d.getTime())) return dateStr + return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) + + ', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + } catch { + return dateStr + } +} + +function extractName(from: string): string { + const match = from.match(/^([^<]+) c.toUpperCase()) +} + +function getInitial(from: string): string { + const name = extractName(from) + return (name[0] || '?').toUpperCase() +} + +const GMAIL_AVATAR_COLORS = [ + '#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900', + '#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32', +] + +function avatarColor(from: string): string { + let hash = 0 + for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0 + return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length] } declare global { @@ -30,7 +60,308 @@ declare global { } } -// --- Email Block --- +// --- Shared: expanded email body used by both block types --- + +function EmailExpandedBody({ + config, + resolvedTheme, +}: { + config: blocks.EmailBlock + resolvedTheme: string +}) { + const [draftBody, setDraftBody] = useState(config.draft_response || '') + const [copied, setCopied] = useState(false) + const bodyRef = useRef(null) + + useEffect(() => { + setDraftBody(config.draft_response || '') + }, [config.draft_response]) + + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.style.height = 'auto' + bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px' + } + }, [draftBody]) + + const draftWithAssistant = useCallback(() => { + let prompt = draftBody + ? `Help me refine this draft response to an email` + : `Help me draft a response to this email` + if (config.threadId) { + prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` + } + prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n` + if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n` + window.__pendingEmailDraft = { prompt } + window.dispatchEvent(new Event('email-block:draft-with-assistant')) + }, [config, draftBody]) + + const copyDraft = useCallback(() => { + navigator.clipboard.writeText(draftBody).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }).catch(() => { + const el = document.createElement('textarea') + el.value = draftBody + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, [draftBody]) + + const gmailUrl = config.threadId + ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` + : null + + const senderName = config.from ? extractName(config.from) : 'Unknown' + const initial = config.from ? getInitial(config.from) : '?' + const color = config.from ? avatarColor(config.from) : '#5f6368' + const hasDraft = !!config.draft_response + + return ( +
+ {config.subject && ( +
{config.subject}
+ )} + +
+
{initial}
+
+
{config.from || 'Unknown'}
+
+ {config.to && to {config.to}} + {config.date && {formatFullDate(config.date)}} +
+
+
+ +
{config.latest_email}
+ + {config.past_summary && ( +
+
Earlier conversation
+
{config.past_summary}
+
+ )} + + {!hasDraft && ( +
+ {gmailUrl && ( + + )} + +
+ )} + + {hasDraft && ( +
+
+ Reply + {config.from && {config.from}} +
+