mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
added archive and delete
This commit is contained in:
parent
c0266d4583
commit
3adcea4b2c
5 changed files with 252 additions and 17 deletions
|
|
@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead } from '@x/core/dist/knowledge/sync_gmail.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';
|
||||
|
|
@ -496,6 +496,15 @@ export function setupIpcHandlers() {
|
|||
'gmail:sendReply': async (_event, args) => {
|
||||
return sendThreadReply(args);
|
||||
},
|
||||
'gmail:archiveThread': async (_event, args) => {
|
||||
return archiveThread(args.threadId);
|
||||
},
|
||||
'gmail:trashThread': async (_event, args) => {
|
||||
return trashThread(args.threadId);
|
||||
},
|
||||
'gmail:markThreadRead': async (_event, args) => {
|
||||
return markThreadRead(args.threadId);
|
||||
},
|
||||
'gmail:saveMessageHeight': async (_event, args) => {
|
||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -232,6 +232,10 @@
|
|||
color: var(--gm-text-faint);
|
||||
}
|
||||
|
||||
.gmail-row-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
|
||||
|
|
@ -249,6 +253,51 @@
|
|||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gmail-row-shell:hover .gmail-row-date {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.gmail-row-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-row-action:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text-strong);
|
||||
}
|
||||
|
||||
.gmail-row-action-danger:hover {
|
||||
color: #e8453c;
|
||||
}
|
||||
|
||||
.gmail-row:hover {
|
||||
background: var(--gm-bg-row-hover);
|
||||
box-shadow: none;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -890,18 +890,75 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
else setOther(updater)
|
||||
}, [])
|
||||
|
||||
const toggleThread = useCallback((threadId: string) => {
|
||||
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) => {
|
||||
removeThreadFromState(threadId)
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
||||
if (!result.ok && 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) => {
|
||||
removeThreadFromState(threadId)
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
|
||||
if (!result.ok && 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 === threadId ? null : threadId
|
||||
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)
|
||||
|
|
@ -1154,23 +1211,61 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
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">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
||||
onClick={() => toggleThread(thread.threadId)}
|
||||
<div
|
||||
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
|
||||
onMouseEnter={() => scheduleHoverPrefetch(thread)}
|
||||
onMouseLeave={cancelHoverPrefetch}
|
||||
>
|
||||
<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>
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh
|
|||
}
|
||||
}
|
||||
|
||||
function deleteCachedSnapshot(threadId: string): void {
|
||||
try {
|
||||
fs.rmSync(cachePath(threadId), { force: true });
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail cache] delete failed for ${threadId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getGmailClientOrThrow() {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) throw new Error('Gmail is not connected.');
|
||||
return google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
export interface ThreadActionResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function archiveThread(threadId: string): Promise<ThreadActionResult> {
|
||||
try {
|
||||
const gmailClient = await getGmailClientOrThrow();
|
||||
await gmailClient.users.threads.modify({
|
||||
userId: 'me',
|
||||
id: threadId,
|
||||
requestBody: { removeLabelIds: ['INBOX'] },
|
||||
});
|
||||
deleteCachedSnapshot(threadId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function trashThread(threadId: string): Promise<ThreadActionResult> {
|
||||
try {
|
||||
const gmailClient = await getGmailClientOrThrow();
|
||||
await gmailClient.users.threads.trash({ userId: 'me', id: threadId });
|
||||
deleteCachedSnapshot(threadId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
|
||||
try {
|
||||
const gmailClient = await getGmailClientOrThrow();
|
||||
await gmailClient.users.threads.modify({
|
||||
userId: 'me',
|
||||
id: threadId,
|
||||
requestBody: { removeLabelIds: ['UNREAD'] },
|
||||
});
|
||||
// Update local cache: clear unread on all messages in the thread.
|
||||
const cached = readCachedSnapshot(threadId);
|
||||
if (cached) {
|
||||
for (const m of cached.snapshot.messages) m.unread = false;
|
||||
cached.snapshot.unread = false;
|
||||
try {
|
||||
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncedThread {
|
||||
threadId: string;
|
||||
markdown: string;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,18 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'gmail:archiveThread': {
|
||||
req: z.object({ threadId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:trashThread': {
|
||||
req: z.object({ threadId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:markThreadRead': {
|
||||
req: z.object({ threadId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:saveMessageHeight': {
|
||||
req: z.object({
|
||||
threadId: z.string().min(1),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue