mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
email in main sidebar
This commit is contained in:
parent
1a3e5a4e2e
commit
9e36ff2fa1
3 changed files with 156 additions and 28 deletions
|
|
@ -775,6 +775,8 @@ function App() {
|
||||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
||||||
|
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||||
|
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||||
const [expandedFrom, setExpandedFrom] = useState<{
|
const [expandedFrom, setExpandedFrom] = useState<{
|
||||||
path: string | null
|
path: string | null
|
||||||
graph: boolean
|
graph: boolean
|
||||||
|
|
@ -5126,8 +5128,11 @@ function App() {
|
||||||
recordingMeetingSource={recordingMeetingSource}
|
recordingMeetingSource={recordingMeetingSource}
|
||||||
onToggleMeetingRecording={() => { void handleToggleMeeting() }}
|
onToggleMeetingRecording={() => { void handleToggleMeeting() }}
|
||||||
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
isEmailOpen={isEmailOpen}
|
onOpenEmail={(threadId) => {
|
||||||
onOpenEmail={openEmailView}
|
setEmailInitialThreadId(threadId ?? null)
|
||||||
|
setEmailThreadIdVersion((v) => v + 1)
|
||||||
|
openEmailView()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -5304,7 +5309,7 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
) : isEmailOpen ? (
|
) : isEmailOpen ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<EmailView />
|
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} />
|
||||||
</div>
|
</div>
|
||||||
) : isWorkspaceOpen ? (
|
) : isWorkspaceOpen ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -817,12 +817,28 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
|
||||||
return { ...state, loadingPage: false }
|
return { ...state, loadingPage: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailView() {
|
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 [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
|
||||||
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
||||||
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
||||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
|
||||||
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
|
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 [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,7 @@ type SidebarContentPanelProps = {
|
||||||
recordingMeetingSource?: string | null
|
recordingMeetingSource?: string | null
|
||||||
onToggleMeetingRecording?: () => void
|
onToggleMeetingRecording?: () => void
|
||||||
onOpenBgTasks?: () => void
|
onOpenBgTasks?: () => void
|
||||||
isEmailOpen?: boolean
|
onOpenEmail?: (threadId?: string) => void
|
||||||
onOpenEmail?: () => void
|
|
||||||
} & React.ComponentProps<typeof Sidebar>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
function formatEventTime(ts: string): string {
|
function formatEventTime(ts: string): string {
|
||||||
|
|
@ -450,7 +449,6 @@ export function SidebarContentPanel({
|
||||||
recordingMeetingSource,
|
recordingMeetingSource,
|
||||||
onToggleMeetingRecording,
|
onToggleMeetingRecording,
|
||||||
onOpenBgTasks,
|
onOpenBgTasks,
|
||||||
isEmailOpen = false,
|
|
||||||
onOpenEmail,
|
onOpenEmail,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
|
|
@ -465,7 +463,6 @@ export function SidebarContentPanel({
|
||||||
const { billing } = useBilling(isRowboatConnected)
|
const { billing } = useBilling(isRowboatConnected)
|
||||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
||||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||||
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
|
||||||
|
|
||||||
const handleRowboatLogin = useCallback(async () => {
|
const handleRowboatLogin = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -528,26 +525,9 @@ export function SidebarContentPanel({
|
||||||
<SidebarHeader className="titlebar-drag-region">
|
<SidebarHeader className="titlebar-drag-region">
|
||||||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||||
<div className="h-8" />
|
<div className="h-8" />
|
||||||
{/* Quick action buttons */}
|
|
||||||
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
|
||||||
{onOpenEmail && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenEmail}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
||||||
isEmailQuickActionSelected
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Mail className="size-4" />
|
|
||||||
<span>Email</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
<EmailSidebarSection onOpenEmailView={onOpenEmail} />
|
||||||
<MeetingsSidebarSection
|
<MeetingsSidebarSection
|
||||||
onOpenMeetingsView={onOpenMeetings}
|
onOpenMeetingsView={onOpenMeetings}
|
||||||
recordingState={meetingRecordingState ?? 'idle'}
|
recordingState={meetingRecordingState ?? 'idle'}
|
||||||
|
|
@ -1192,6 +1172,133 @@ function formatMeetingTime(event: UpcomingMeeting): string {
|
||||||
return event.start.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
|
return event.start.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SidebarEmailThread = {
|
||||||
|
threadId: string
|
||||||
|
subject: string
|
||||||
|
from: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEmailFrom(from: string): string {
|
||||||
|
const match = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
|
||||||
|
if (match) return match[1].trim()
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEmailTime(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 EmailSidebarSection({
|
||||||
|
onOpenEmailView,
|
||||||
|
}: {
|
||||||
|
onOpenEmailView?: (threadId?: string) => void
|
||||||
|
}) {
|
||||||
|
const [threads, setThreads] = useState<SidebarEmailThread[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 })
|
||||||
|
const unread = result.threads
|
||||||
|
.filter((t) => t.unread === true)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map<SidebarEmailThread>((t) => ({
|
||||||
|
threadId: t.threadId,
|
||||||
|
subject: t.subject ?? '(No subject)',
|
||||||
|
from: t.from ?? '',
|
||||||
|
date: t.date ?? '',
|
||||||
|
}))
|
||||||
|
setThreads(unread)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load important emails:', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const scheduleReload = () => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => { timeout = null; void load() }, 500)
|
||||||
|
}
|
||||||
|
const matches = (p: string | undefined) =>
|
||||||
|
typeof p === 'string' && (p === 'gmail_sync' || p.startsWith('gmail_sync/'))
|
||||||
|
const cleanup = window.ipc.on('workspace:didChange', (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'created':
|
||||||
|
case 'changed':
|
||||||
|
case 'deleted':
|
||||||
|
if (matches(event.path)) scheduleReload()
|
||||||
|
break
|
||||||
|
case 'moved':
|
||||||
|
if (matches(event.from) || matches(event.to)) scheduleReload()
|
||||||
|
break
|
||||||
|
case 'bulkChanged':
|
||||||
|
if (!event.paths || event.paths.some(matches)) scheduleReload()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="flex flex-col">
|
||||||
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</div>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{threads.map((t) => (
|
||||||
|
<SidebarMenuItem key={t.threadId}>
|
||||||
|
<SidebarMenuButton onClick={() => onOpenEmailView?.(t.threadId)} className="gap-2">
|
||||||
|
<Mail className="size-4 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">
|
||||||
|
{formatEmailFrom(t.from)}
|
||||||
|
<span className="text-muted-foreground"> · {t.subject}</span>
|
||||||
|
</span>
|
||||||
|
{t.date && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{formatEmailTime(t.date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
{onOpenEmailView && (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton onClick={() => onOpenEmailView()}>
|
||||||
|
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">View all</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MeetingsSidebarSection({
|
function MeetingsSidebarSection({
|
||||||
onOpenMeetingsView,
|
onOpenMeetingsView,
|
||||||
recordingState,
|
recordingState,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue