feat: add Gmail inbox-style multi-email block with accordion rows

This commit is contained in:
Gagancreates 2026-05-05 00:57:37 +05:30
parent 7eae006da7
commit 2a0b073e1e
4 changed files with 539 additions and 226 deletions

View file

@ -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<MarkdownEditorHandle, MarkdownEditorPro
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,

View file

@ -1,6 +1,6 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown, Reply, Forward } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
@ -31,31 +31,20 @@ function formatFullDate(dateStr: string): string {
}
}
/** Extract display name from "Name <email>" or plain email */
function extractName(from: string): string {
const match = from.match(/^([^<]+)</)
if (match) return match[1].trim()
return from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
}
/** Get first initial for avatar */
function getInitial(from: string): string {
const name = extractName(from)
return (name[0] || '?').toUpperCase()
}
// Gmail-style deterministic avatar colors based on sender
const GMAIL_AVATAR_COLORS = [
'#1a73e8', // blue
'#e8453c', // red
'#34a853', // green
'#8430ce', // purple
'#f29900', // orange
'#00796b', // teal
'#c62828', // dark red
'#1565c0', // dark blue
'#6a1b9a', // deep purple
'#2e7d32', // dark green
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
]
function avatarColor(from: string): string {
@ -70,7 +59,329 @@ declare global {
}
}
// --- Email Block ---
// --- Shared: expanded email body used by both block types ---
function EmailExpandedBody({
config,
resolvedTheme,
onDelete,
}: {
config: blocks.EmailBlock
resolvedTheme: string
onDelete?: () => void
}) {
const [draftBody, setDraftBody] = useState(config.draft_response || '')
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(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 (
<div className="email-gmail-expanded">
{config.subject && (
<div className="email-gmail-exp-subject">{config.subject}</div>
)}
<div className="email-gmail-exp-meta">
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-gmail-exp-meta-right">
<div className="email-gmail-exp-sender">{senderName}</div>
<div className="email-gmail-exp-to-date">
{config.to && <span>to {config.to}</span>}
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
</div>
</div>
<div className="email-gmail-exp-meta-actions">
{gmailUrl && (
<button
className="email-gmail-icon-btn"
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
onMouseDown={(e) => e.stopPropagation()}
title="Open in Gmail"
>
<ExternalLink size={15} />
</button>
)}
{onDelete && (
<button
className="email-gmail-icon-btn"
onClick={(e) => { e.stopPropagation(); onDelete() }}
onMouseDown={(e) => e.stopPropagation()}
title="Remove"
>
<X size={15} />
</button>
)}
</div>
</div>
<div className="email-gmail-exp-body">{config.latest_email}</div>
{config.past_summary && (
<div className="email-gmail-exp-history">
<div className="email-gmail-exp-history-label">Earlier conversation</div>
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
</div>
)}
{/* Reply / Forward / Draft with Rowboat row */}
<div className="email-gmail-reply-row">
<button
className="email-gmail-reply-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<Reply size={14} />
Reply
</button>
{gmailUrl && (
<button
className="email-gmail-reply-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<Forward size={14} />
Forward
</button>
)}
<button
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
</div>
{hasDraft && (
<div className="email-gmail-compose">
<div className="email-gmail-compose-to">
<span className="email-gmail-compose-to-label">Reply</span>
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-gmail-compose-body"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Write your reply..."
rows={3}
/>
<div className="email-gmail-compose-footer">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); copyDraft() }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
</div>
</div>
)}
</div>
)
}
// --- Multi-email inbox block (language-emails) ---
function EmailsBlockView({ node, deleteNode }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
}) {
const raw = node.attrs.data as string
let config: blocks.EmailsBlock | null = null
try {
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
if (!config || config.emails.length === 0) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
{config.title && (
<div className="email-inbox-title">{config.title}</div>
)}
<div className="email-inbox-list">
{config.emails.map((email, i) => {
const isExpanded = expandedIndex === i
const senderName = email.from ? extractName(email.from) : 'Unknown'
const initial = email.from ? getInitial(email.from) : '?'
const color = email.from ? avatarColor(email.from) : '#5f6368'
const snippet = email.summary
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
return (
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
{/* Collapsed row */}
<div
className="email-inbox-row-header"
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-inbox-sender">{senderName}</div>
<div className="email-inbox-middle">
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
{snippet && (
<span className="email-inbox-snippet">
{email.subject ? `${snippet}` : snippet}
</span>
)}
</div>
{email.date && (
<div className="email-inbox-date">{formatEmailDate(email.date)}</div>
)}
<ChevronDown
size={14}
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
/>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="email-inbox-expanded-wrap">
<EmailExpandedBody
config={email}
resolvedTheme={resolvedTheme}
/>
</div>
)}
</div>
)
})}
</div>
</div>
</NodeViewWrapper>
)
}
export const EmailsBlockExtension = Node.create({
name: 'emailsBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return { data: { default: '{}' } }
},
parseHTML() {
return [{
tag: 'pre',
priority: 61,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
if ((code.className || '').includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmailsBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```emails\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})
// --- Single email block (language-email, backward compat) ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> }
@ -82,249 +393,57 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
try {
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
setDraftBody(parsed.draft_response || '')
} catch { /* ignore */ }
}, [raw])
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const commitDraft = useCallback((newBody: string) => {
try {
const current = JSON.parse(raw) as Record<string, unknown>
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
} catch { /* ignore */ }
}, [raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
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`
prompt += `**From:** ${config.from || 'Unknown'}\n`
prompt += `**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])
void updateAttributes // available for future per-email draft persistence
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-error">
<span>Invalid email block</span>
</div>
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
</NodeViewWrapper>
)
}
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'
// Snippet: use summary if present, else truncate latest_email
const snippet = config.summary
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
{/* Gmail-style two-column row */}
<div
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Avatar */}
<div
className="email-gmail-avatar"
style={{ backgroundColor: color }}
aria-hidden="true"
>
{initial}
</div>
{/* Content */}
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
<div className="email-gmail-content">
<div className="email-gmail-top-row">
<span className="email-gmail-sender">{senderName}</span>
{config.date && (
<span className="email-gmail-date">{formatEmailDate(config.date)}</span>
)}
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
</div>
<div className="email-gmail-bottom-row">
{config.subject && (
<span className="email-gmail-subject">{config.subject}</span>
)}
{snippet && (
<span className="email-gmail-snippet">
{config.subject ? `${snippet}` : snippet}
</span>
)}
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
{snippet && <span className="email-gmail-snippet">{config.subject ? `${snippet}` : snippet}</span>}
</div>
</div>
{/* Chevron */}
<ChevronDown
size={15}
className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`}
/>
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
</div>
{/* Expanded email detail */}
{expanded && (
<div className="email-gmail-expanded">
{/* Subject heading */}
{config.subject && (
<div className="email-gmail-exp-subject">{config.subject}</div>
)}
{/* Metadata strip */}
<div className="email-gmail-exp-meta">
<div
className="email-gmail-exp-avatar"
style={{ backgroundColor: color }}
>
{initial}
</div>
<div className="email-gmail-exp-meta-right">
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
<div className="email-gmail-exp-to-date">
{config.to && <span>to {config.to}</span>}
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
</div>
</div>
{gmailUrl && (
<button
className="email-gmail-open-btn"
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
onMouseDown={(e) => e.stopPropagation()}
title="Open in Gmail"
>
<ExternalLink size={14} />
</button>
)}
</div>
{/* Email body */}
<div className="email-gmail-exp-body">{config.latest_email}</div>
{/* Earlier conversation */}
{hasPastSummary && (
<div className="email-gmail-exp-history">
<div className="email-gmail-exp-history-label">Earlier conversation</div>
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
</div>
)}
{/* Draft compose area */}
{hasDraft && (
<div className="email-gmail-compose">
<div className="email-gmail-compose-to">
<span className="email-gmail-compose-to-label">Reply</span>
{config.from && (
<span className="email-gmail-compose-to-addr">{config.from}</span>
)}
</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-gmail-compose-body"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Write your reply..."
rows={3}
/>
<div className="email-gmail-compose-footer">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
{hasDraft && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = draftBody
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
)}
</div>
</div>
)}
{/* Actions when no draft yet */}
{!hasDraft && (
<div className="email-gmail-actions">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with Rowboat
</button>
</div>
)}
</div>
<EmailExpandedBody
config={config}
resolvedTheme={resolvedTheme}
onDelete={deleteNode}
/>
)}
</div>
</NodeViewWrapper>
@ -339,9 +458,7 @@ export const EmailBlockExtension = Node.create({
draggable: false,
addAttributes() {
return {
data: { default: '{}' },
}
return { data: { default: '{}' } }
},
parseHTML() {
@ -352,7 +469,7 @@ export const EmailBlockExtension = Node.create({
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false

View file

@ -1757,6 +1757,194 @@
font-size: 14px;
}
/* Reply / Forward pill buttons (in expanded view) */
.tiptap-editor .ProseMirror .email-gmail-reply-row {
display: flex;
gap: 8px;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
background: transparent;
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
border-radius: 18px;
cursor: pointer;
transition: background 0.12s ease, box-shadow 0.12s ease;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-reply-row-end {
margin-left: auto;
}
/* Icon-only buttons (open in Gmail, remove) */
.tiptap-editor .ProseMirror .email-gmail-icon-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: none;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-gmail-icon-btn:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-exp-meta-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
/* ---- Emails inbox block (language-emails) ---- */
.tiptap-editor .ProseMirror .email-inbox-card {
position: relative;
background-color: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.1), 0 1px 3px 1px rgba(0,0,0,0.06);
overflow: hidden;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-inbox-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
padding: 10px 16px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-list {
display: flex;
flex-direction: column;
}
/* Each email row */
.tiptap-editor .ProseMirror .email-inbox-row {
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row:last-child {
border-bottom: none;
}
.tiptap-editor .ProseMirror .email-inbox-row-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 8px 10px;
cursor: pointer;
transition: background 0.1s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .email-inbox-row-header:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row.email-inbox-row-expanded .email-inbox-row-header {
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
/* Avatar */
.tiptap-editor .ProseMirror .email-inbox-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
/* Sender name — fixed width, like Gmail's column */
.tiptap-editor .ProseMirror .email-inbox-sender {
width: 160px;
flex-shrink: 0;
font-size: 13.5px;
font-weight: 600;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
/* Subject + snippet — fills remaining space */
.tiptap-editor .ProseMirror .email-inbox-middle {
flex: 1;
min-width: 0;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-inbox-subject {
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-inbox-snippet {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-weight: 400;
}
/* Date */
.tiptap-editor .ProseMirror .email-inbox-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
/* Expand chevron */
.tiptap-editor .ProseMirror .email-inbox-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 35%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-inbox-chevron.email-inbox-chevron-open {
transform: rotate(180deg);
}
/* Expanded content padding */
.tiptap-editor .ProseMirror .email-inbox-expanded-wrap {
padding: 0 16px 16px 16px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
}
/* Transcript block */
.tiptap-editor .ProseMirror .transcript-block-toggle {
display: flex;

View file

@ -101,6 +101,13 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const EmailsBlockSchema = z.object({
title: z.string().optional(),
emails: z.array(EmailBlockSchema),
});
export type EmailsBlock = z.infer<typeof EmailsBlockSchema>;
export const TranscriptBlockSchema = z.object({
transcript: z.string(),
});