mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
add Gmail contacts autocomplete to compose box (#607)
Adds a gmail:searchContacts IPC channel backed by two indices: a SENT-label API-backed index (gmail_sent_contacts) for full historical coverage of people you've actually emailed, and a local-snapshot fallback (gmail_contacts) used until the SENT sync finishes on first launch. Both indices warm at startup so the first keystroke in the recipient box is instant. Renderer wires the suggestions into the to/cc/bcc fields in email-view with styled chips. Co-authored-by: arkml <6592213+arkml@users.noreply.github.com>
This commit is contained in:
parent
0aec665220
commit
c48ef5ac0c
6 changed files with 1056 additions and 4 deletions
|
|
@ -53,6 +53,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -444,6 +446,13 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
// for full historical coverage of people you've actually emailed.
|
||||
warmContactIndex();
|
||||
warmSentContacts();
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -521,6 +530,22 @@ export function setupIpcHandlers() {
|
|||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'gmail:searchContacts': async (_event, args) => {
|
||||
const query = args?.query ?? '';
|
||||
const limit = args?.limit;
|
||||
const excludeEmails = args?.excludeEmails;
|
||||
|
||||
// Primary source: people you've actually sent mail to (Gmail SENT label,
|
||||
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
|
||||
// — used only when the SENT index hasn't been populated yet (very first
|
||||
// launch, before the background sync finishes).
|
||||
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
|
||||
if (sent.length > 0) {
|
||||
return { contacts: sent };
|
||||
}
|
||||
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
|
||||
return { contacts: fallback };
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -800,6 +800,108 @@
|
|||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: min(440px, 100%);
|
||||
background: var(--gm-bg-elevated, #1e1e1e);
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
0 12px 32px rgba(0, 0, 0, 0.36);
|
||||
max-height: 296px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
transform-origin: top left;
|
||||
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes gmail-recipient-suggestions-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gm-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms linear;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion.is-active {
|
||||
background: rgba(99, 142, 255, 0.18);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-avatar {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--gm-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-match {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
|
|
|
|||
|
|
@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -626,34 +663,123 @@ function RecipientField({
|
|||
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>) => {
|
||||
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
|
||||
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)
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
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">
|
||||
<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>
|
||||
|
|
@ -674,7 +800,16 @@ function RecipientField({
|
|||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => { if (draft.trim()) commit(draft) }}
|
||||
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)) {
|
||||
|
|
@ -683,6 +818,45 @@ function RecipientField({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue