diff --git a/src/components/FlowConsole.module.css b/src/components/FlowConsole.module.css index 1cd7b00..ababde3 100644 --- a/src/components/FlowConsole.module.css +++ b/src/components/FlowConsole.module.css @@ -1827,3 +1827,191 @@ max-width: 70vw; } } + +@media (prefers-reduced-motion: no-preference) { + .mailShell, + .workspace, + .searchBox, + .messageRow, + .reader, + .composeDialog, + .accountMenu, + .readerMenu, + .contactCard { + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); + } +} + +.insightBar { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + overflow-x: auto; + border-bottom: 1px solid rgba(15, 118, 110, 0.1); + background: linear-gradient(90deg, rgba(240, 253, 250, 0.9), rgba(255, 255, 255, 0.86)); + padding: 10px 18px; + scrollbar-width: none; +} + +.insightBar::-webkit-scrollbar { + display: none; +} + +.insightBar span { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + border: 1px solid rgba(15, 118, 110, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.76); + color: #475569; + font-size: 12px; + font-weight: 600; + padding: 6px 10px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.insightBar strong { + color: #0f766e; + font-size: 13px; +} + +.mailShell { + background: + radial-gradient(circle at 15% 0%, rgba(20, 184, 166, 0.13), transparent 28%), + linear-gradient(135deg, #f8fafc 0%, #eefcf9 45%, #f8fafc 100%); +} + +.header { + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(248, 250, 252, 0.82); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(18px); +} + +.searchBox { + border: 1px solid rgba(15, 118, 110, 0.12); + background: rgba(255, 255, 255, 0.76); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 10px 24px rgba(15, 23, 42, 0.06); +} + +.searchBox:focus-within { + border-color: rgba(15, 118, 110, 0.34); + box-shadow: 0 16px 34px rgba(15, 118, 110, 0.16); +} + +.workspace { + background: transparent; +} + +.sidebar { + background: rgba(248, 250, 252, 0.72); + border-right: 1px solid rgba(15, 23, 42, 0.07); + backdrop-filter: blur(14px); +} + +.composeButton { + background: linear-gradient(135deg, #0f766e, #14b8a6); + color: #ffffff; + box-shadow: 0 14px 28px rgba(15, 118, 110, 0.26); + font-weight: 700; +} + +.composeButton:hover { + transform: translateY(-1px); + box-shadow: 0 18px 32px rgba(15, 118, 110, 0.3); +} + +.folderButton { + border-radius: 14px; +} + +.folderActive, +.folderActive:hover { + background: rgba(15, 118, 110, 0.12); + color: #0f766e; + box-shadow: inset 3px 0 0 #0f766e; +} + +.contentPane { + background: rgba(255, 255, 255, 0.72); +} + +.listPane, +.reader { + margin: 12px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 24px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +.listToolbar, +.readerToolbar, +.readerHeaderLine { + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(12px); +} + +.messageList { + background: linear-gradient(180deg, rgba(248, 250, 252, 0.6), #ffffff 160px); +} + +.messageRow { + border-bottom-color: rgba(15, 23, 42, 0.06); + background: rgba(255, 255, 255, 0.86); + transition: + background 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; +} + +.messageRow:hover { + background: #ffffff; + transform: translateY(-1px); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08); +} + +.messageUnread { + background: linear-gradient(90deg, rgba(240, 253, 250, 0.95), rgba(255, 255, 255, 0.92)); + box-shadow: inset 3px 0 0 #14b8a6; +} + +.status, +.statusSuccess { + border-radius: 14px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); +} + +.accountMenu, +.readerMenu, +.composeMenu, +.emojiMenu, +.contactCard, +.confirmDialog, +.composeDialog { + border-color: rgba(15, 23, 42, 0.08); + border-radius: 18px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.18); +} + +.composeDialog { + border-radius: 22px 22px 0 0; +} + +.composeHeader { + background: linear-gradient(135deg, #0f766e, #14b8a6); +} + +.composeHeader strong, +.composeHeader .composeIconButton { + color: #ffffff; +} + +.readerMessage { + border-bottom-color: rgba(15, 23, 42, 0.06); +} diff --git a/src/components/FlowConsole.tsx b/src/components/FlowConsole.tsx index 802131f..8e5b092 100644 --- a/src/components/FlowConsole.tsx +++ b/src/components/FlowConsole.tsx @@ -69,6 +69,8 @@ import type { import { useEffect, useMemo, useRef, useState } from 'react'; import { FlowMark } from '@/components/brand/FlowMark'; import { ContactHoverCard } from '@/components/flow-console/ContactHoverCard'; +import { MessageRow } from '@/components/MessageRow'; +import { useDebounce } from '@/hooks/useDebounce'; import { apiUrl, flowHeaders } from '@/lib/api'; import { composeEmojis, @@ -89,7 +91,6 @@ import { } from '@/lib/flow-console/compose'; import { formatFileSize, - formatListDate, formatMessageDate, formatSessionExpiry, getFolderLabel, @@ -174,13 +175,17 @@ export default function FlowConsole({ const [sidebarOpen, setSidebarOpen] = useState(true); const [status, setStatus] = useState(null); + const debouncedQuery = useDebounce(query, 140); + const allThreads = useMemo( () => groupMessagesIntoThreads(messages), [messages], ); + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const visibleThreads = useMemo(() => { - const cleanQuery = query.trim().toLowerCase(); + const cleanQuery = debouncedQuery.trim().toLowerCase(); return allThreads.filter(thread => { if (!cleanQuery) return true; @@ -198,7 +203,7 @@ export default function FlowConsole({ .toLowerCase() .includes(cleanQuery); }); - }, [allThreads, query]); + }, [allThreads, debouncedQuery]); const selectedThread = useMemo( () => allThreads.find(thread => thread.id === selectedMessageId) || null, @@ -207,7 +212,7 @@ export default function FlowConsole({ const allVisibleSelected = visibleThreads.length > 0 && - visibleThreads.every(thread => selectedIds.includes(thread.id)); + visibleThreads.every(thread => selectedIdSet.has(thread.id)); const selectedThreadIndex = visibleThreads.findIndex( thread => thread.id === selectedMessageId, ); @@ -243,6 +248,14 @@ export default function FlowConsole({ [composeAttachments], ); const sessionExpiry = formatSessionExpiry(accessSession.expiresAt); + const unreadCount = useMemo( + () => allThreads.filter(thread => thread.unread).length, + [allThreads], + ); + const starredCount = useMemo( + () => allThreads.filter(thread => thread.starred).length, + [allThreads], + ); useEffect(() => { if (!accountOpen) return; @@ -262,17 +275,19 @@ export default function FlowConsole({ useEffect(() => { let active = true; + const controller = new AbortController(); fetch(apiUrl('/flow/config'), { credentials: 'include', headers: flowHeaders(), + signal: controller.signal, }) .then(response => responseJson(response)) .then(nextConfig => { if (active) setConfig({ ...defaultConfig, ...nextConfig }); }) .catch(error => { - if (!active) return; + if (!active || controller.signal.aborted) return; setStatus({ kind: 'info', text: @@ -284,15 +299,18 @@ export default function FlowConsole({ return () => { active = false; + controller.abort(); }; }, []); useEffect(() => { let active = true; + const controller = new AbortController(); fetch(apiUrl(`/flow/messages?folder=${backendFolderFor(activeFolder)}`), { credentials: 'include', headers: flowHeaders(), + signal: controller.signal, }) .then(response => responseJson(response)) .then(data => { @@ -301,7 +319,7 @@ export default function FlowConsole({ setFolderCounts(mapCounts(data.counts)); }) .catch(error => { - if (!active) return; + if (!active || controller.signal.aborted) return; setMessages([]); setStatus({ kind: 'info', @@ -317,6 +335,7 @@ export default function FlowConsole({ return () => { active = false; + controller.abort(); }; }, [activeFolder, refreshSeq]); @@ -1648,6 +1667,13 @@ export default function FlowConsole({ +
+ {unreadCount} unread + {starredCount} starred + {visibleThreads.length} conversations + {query !== debouncedQuery ? Refining search... : null} +
+ {status ? (
Loading mail...
) : visibleThreads.length ? ( - visibleThreads.map(thread => { - const message = thread.latest; - const contact = contactFromMessage(message); - - return ( -
( + openMessage(thread.id)} - onKeyDown={event => openMessageFromKeyboard(event, thread.id)} - role="button" - tabIndex={0} - > - toggleSelected(thread.id)} - onClick={event => event.stopPropagation()} - type="checkbox" - /> - - event.stopPropagation()} - > - - - - - - - {thread.subject} - {message.preview || message.body ? ( - - {message.preview || message.body} - ) : null} - {message.direction === 'outbound' ? ( - - {sentTrackingLabel(message, true)} - - ) : null} - {thread.count > 1 ? ( - - {thread.count} - - ) : null} - - -
- ); - }) + onKeyDown={openMessageFromKeyboard} + onOpenCompose={openComposeToContact} + onSelect={openMessage} + onShowStatus={showContactToolStatus} + onToggleSelect={toggleSelected} + onToggleStarred={toggleStarred} + thread={thread} + /> + )) ) : (
diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts index 37cb069..2e12260 100644 --- a/src/hooks/useAsync.ts +++ b/src/hooks/useAsync.ts @@ -19,6 +19,8 @@ export function useAsync( const mountedRef = useRef(true); const execute = useCallback(async () => { + if (!mountedRef.current) return; + setState({ status: 'pending', data: null, error: null }); try { const response = await asyncFunction(); @@ -37,6 +39,7 @@ export function useAsync( }, [asyncFunction]); useEffect(() => { + let cancelled = false; mountedRef.current = true; if (immediate) { @@ -44,6 +47,7 @@ export function useAsync( } return () => { + cancelled = true; mountedRef.current = false; }; }, [execute, immediate]);