From 2463f983f8810c0111cbd0f2b61a0e924250eaf2 Mon Sep 17 00:00:00 2001 From: CheFu Date: Sat, 20 Jun 2026 10:19:56 +0200 Subject: [PATCH] Wire placeholder console actions --- src/components/FlowConsole.module.css | 264 ++++++++++++++++ src/components/FlowConsole.tsx | 439 +++++++++++++++++++------- src/components/MessageRow.tsx | 25 +- src/hooks/useAsync.ts | 8 +- 4 files changed, 594 insertions(+), 142 deletions(-) diff --git a/src/components/FlowConsole.module.css b/src/components/FlowConsole.module.css index 1cd7b00..09f71fe 100644 --- a/src/components/FlowConsole.module.css +++ b/src/components/FlowConsole.module.css @@ -1827,3 +1827,267 @@ 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); +} + +.headerPanel { + position: absolute; + top: 58px; + right: 72px; + z-index: 55; + display: grid; + min-width: 230px; + gap: 6px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 18px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.16); + padding: 10px; + backdrop-filter: blur(18px); +} + +.headerPanel strong { + color: #0f172a; + font-size: 13px; + padding: 6px 8px; +} + +.headerPanel button, +.inlineActionMenu button { + display: flex; + width: 100%; + align-items: center; + border: 0; + border-radius: 10px; + background: transparent; + color: #334155; + font: inherit; + font-size: 13px; + justify-content: flex-start; + padding: 8px 10px; + text-align: left; +} + +.headerPanel button:hover, +.inlineActionMenu button:hover { + background: rgba(15, 118, 110, 0.1); + color: #0f766e; +} + +.inlineActionMenu { + display: flex; + width: max-content; + max-width: 100%; + flex-wrap: wrap; + gap: 4px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 14px; + background: #ffffff; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.12); + padding: 6px; +} + +.inlineActionMenu button { + width: auto; + min-width: 38px; + justify-content: center; +} + +.compactDensity .messageRow { + min-height: 38px; +} + +.compactDensity .listPane, +.compactDensity .reader { + margin: 8px; +} + +.compactDensity .insightBar { + padding-block: 7px; +} diff --git a/src/components/FlowConsole.tsx b/src/components/FlowConsole.tsx index 802131f..189400e 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, @@ -141,6 +142,9 @@ export default function FlowConsole({ const sendLockRef = useRef(false); const [accountOpen, setAccountOpen] = useState(false); const [activeFolder, setActiveFolder] = useState('inbox'); + const [activeHeaderPanel, setActiveHeaderPanel] = useState< + 'apps' | 'help' | 'settings' | null + >(null); const [composeAttachments, setComposeAttachments] = useState< ComposeAttachment[] >([]); @@ -161,6 +165,7 @@ export default function FlowConsole({ const [formatToolbarOpen, setFormatToolbarOpen] = useState(true); const [moreToolsOpen, setMoreToolsOpen] = useState(false); const [messages, setMessages] = useState([]); + const [messageMenuId, setMessageMenuId] = useState(null); const [query, setQuery] = useState(''); const [readerMoreOpen, setReaderMoreOpen] = useState(false); const [readerMoveOpen, setReaderMoveOpen] = useState(false); @@ -173,14 +178,21 @@ export default function FlowConsole({ ); const [sidebarOpen, setSidebarOpen] = useState(true); const [status, setStatus] = useState(null); + const [themeDensity, setThemeDensity] = useState<'comfortable' | 'compact'>( + 'comfortable', + ); + + 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 +210,7 @@ export default function FlowConsole({ .toLowerCase() .includes(cleanQuery); }); - }, [allThreads, query]); + }, [allThreads, debouncedQuery]); const selectedThread = useMemo( () => allThreads.find(thread => thread.id === selectedMessageId) || null, @@ -207,7 +219,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 +255,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 +282,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 +306,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 +326,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 +342,7 @@ export default function FlowConsole({ return () => { active = false; + controller.abort(); }; }, [activeFolder, refreshSeq]); @@ -393,6 +419,7 @@ export default function FlowConsole({ setActiveFolder(folder); setReaderMoreOpen(false); setReaderMoveOpen(false); + setMessageMenuId(null); setSelectedIds([]); setSelectedMessageId(null); setStatus(null); @@ -413,6 +440,7 @@ export default function FlowConsole({ setSelectedMessageId(threadId); setReaderMoreOpen(false); setReaderMoveOpen(false); + setMessageMenuId(null); setSelectedIds([]); if (!unreadMessageIds.length) return; @@ -629,9 +657,72 @@ export default function FlowConsole({ const showReaderToolStatus = (text: string) => { setReaderMoreOpen(false); setReaderMoveOpen(false); + setMessageMenuId(null); setStatus({ kind: 'info', text }); }; + const downloadTextFile = ( + filename: string, + content: string, + type = 'text/plain', + ) => { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename.replace(/[\\/:*?"<>|]+/g, '-'); + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + + const threadToPlainText = () => + selectedThread?.allMessages + .map(message => { + const recipients = message.to.join(', '); + return [ + `From: ${message.from}`, + `To: ${recipients}`, + `Date: ${formatMessageDate(message.date)}`, + `Subject: ${message.subject}`, + '', + message.body || message.preview, + ].join('\n'); + }) + .join('\n\n---\n\n') || ''; + + const showOriginalSource = () => { + if (!selectedThread) return; + + const popup = window.open('', '_blank', 'noopener,noreferrer'); + if (!popup) { + showReaderToolStatus('Allow pop-ups to view the original source.'); + return; + } + + const pre = popup.document.createElement('pre'); + pre.style.whiteSpace = 'pre-wrap'; + pre.style.wordBreak = 'break-word'; + pre.style.font = '13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace'; + pre.textContent = threadToPlainText(); + popup.document.title = `Original source - ${selectedThread.subject}`; + popup.document.body.style.margin = '24px'; + popup.document.body.append(pre); + setReaderMoreOpen(false); + }; + + const downloadThread = () => { + if (!selectedThread) return; + + downloadTextFile( + `${selectedThread.subject || 'conversation'}.txt`, + threadToPlainText(), + ); + setReaderMoreOpen(false); + setStatus({ kind: 'success', text: 'Conversation downloaded.' }); + }; + const openThreadDocument = (print = false) => { if (!selectedThread) return; @@ -1027,11 +1118,121 @@ export default function FlowConsole({ setStatus(null); }; + const savedContactsFromStorage = () => { + const rawContacts = window.localStorage.getItem('flowSavedContacts'); + if (!rawContacts) return [] as ContactPreview[]; + + try { + const parsed = JSON.parse(rawContacts) as ContactPreview[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return [] as ContactPreview[]; + } + }; + const showContactToolStatus = (tool: string, contact: ContactPreview) => { - setStatus({ - kind: 'info', - text: `${tool} for ${contact.name} is ready for integration.`, - }); + const safeEmail = encodeURIComponent(contact.email); + const safeName = encodeURIComponent(contact.name || contact.email); + + if (tool === 'Contact card') { + const savedContacts = savedContactsFromStorage(); + const nextContacts = [ + ...savedContacts.filter(item => item.email !== contact.email), + contact, + ]; + window.localStorage.setItem( + 'flowSavedContacts', + JSON.stringify(nextContacts), + ); + setStatus({ kind: 'success', text: `${contact.name} saved to contacts.` }); + return; + } + + if (tool === 'Chat') { + window.location.href = `mailto:${safeEmail}?subject=${encodeURIComponent( + 'Quick chat', + )}`; + return; + } + + if (tool === 'Video call') { + window.open( + `https://meet.google.com/new?hs=180&authuser=0&pli=1&email=${safeEmail}`, + '_blank', + 'noopener,noreferrer', + ); + return; + } + + if (tool === 'Calendar') { + const details = encodeURIComponent( + `Meeting with ${contact.name} <${contact.email}>`, + ); + window.open( + `https://calendar.google.com/calendar/render?action=TEMPLATE&text=Meeting%20with%20${safeName}&details=${details}`, + '_blank', + 'noopener,noreferrer', + ); + } + }; + + const addReactionToMessage = (messageId: string, emoji: string) => { + setMessages(current => + current.map(message => + message.id === messageId + ? { + ...message, + reactionCount: (message.reactionCount || 0) + 1, + reactionEmoji: emoji, + reactionFrom: accessSession.keyLabel, + } + : message, + ), + ); + setMessageMenuId(null); + setStatus({ kind: 'success', text: `Reaction ${emoji} added.` }); + }; + + const copyMessageLink = async (message: MailMessage) => { + const url = `${window.location.origin}${ + window.location.pathname + }#${encodeURIComponent(message.id)}`; + try { + await navigator.clipboard.writeText(url); + setStatus({ kind: 'success', text: 'Message link copied.' }); + } catch { + window.location.hash = message.id; + setStatus({ kind: 'info', text: 'Message link added to the address bar.' }); + } + setMessageMenuId(null); + }; + + const downloadMessage = (message: MailMessage) => { + downloadTextFile( + `${message.subject || message.id}.txt`, + [ + `From: ${message.from}`, + `To: ${message.to.join(', ')}`, + `Date: ${formatMessageDate(message.date)}`, + `Subject: ${message.subject}`, + '', + message.body || message.preview, + ].join('\n'), + ); + setMessageMenuId(null); + setStatus({ kind: 'success', text: 'Message downloaded.' }); + }; + + const openHelpPanel = () => { + setActiveHeaderPanel(panel => (panel === 'help' ? null : 'help')); + }; + + const openSettingsPanel = () => { + setActiveHeaderPanel(panel => (panel === 'settings' ? null : 'settings')); + }; + + const openAppsPanel = () => { + setActiveHeaderPanel(panel => (panel === 'apps' ? null : 'apps')); }; const submitCompose = async (event: FormEvent) => { @@ -1101,7 +1302,11 @@ export default function FlowConsole({ }; return ( -
+
+ {activeHeaderPanel ? ( +
+ {activeHeaderPanel === 'help' ? ( + <> + Flow shortcuts + + + + + ) : null} + {activeHeaderPanel === 'settings' ? ( + <> + Display settings + + + + + ) : null} + {activeHeaderPanel === 'apps' ? ( + <> + Flow apps + + + + + ) : null} +
+ ) : null}
+ {messageMenuId === `reaction-${message.id}` ? ( +
+ {composeEmojis.slice(0, 8).map(emoji => ( + + ))} +
+ ) : null} + {messageMenuId === message.id ? ( +
+ + + +
+ ) : null} {message.direction === 'outbound' ? (
+
+ {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/components/MessageRow.tsx b/src/components/MessageRow.tsx index cdd7323..238c912 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -1,28 +1,12 @@ 'use client'; import { Star } from 'lucide-react'; -import type { MouseEvent } from 'react'; +import type { KeyboardEvent, MouseEvent } from 'react'; import { memo } from 'react'; import { ContactHoverCard } from '@/components/flow-console/ContactHoverCard'; -import { formatListDate, getMessageFolderLabel, sentTrackingKind, sentTrackingLabel } from '@/lib/flow-console/format'; +import { formatListDate, sentTrackingKind, sentTrackingLabel } from '@/lib/flow-console/format'; import { contactFromMessage } from '@/lib/flow-console/mail'; -import type { MailThread } from '@/lib/flow-console/types'; -import styles from './FlowConsole.module.css'; - -interface MessageRowProps { - thread: MailThread; - isSelected: boolean; - onSelect: (threadId: string) => void; - onToggleSelect: (threadId: string) => void; - onToggleStarred: (event: MouseEvent, messageId: string) => void; -import { Star } from 'lucide-react'; -import type { MouseEvent } from 'react'; -import { memo } from 'react'; -import { ContactHoverCard } from '`@/components/flow-console/ContactHoverCard`'; -import type { ContactPreview } from '`@/lib/flow-console/types`'; -import { formatListDate, getMessageFolderLabel, sentTrackingKind, sentTrackingLabel } from '`@/lib/flow-console/format`'; -import { contactFromMessage } from '`@/lib/flow-console/mail`'; -import type { MailThread } from '`@/lib/flow-console/types`'; +import type { ContactPreview, MailThread } from '@/lib/flow-console/types'; import styles from './FlowConsole.module.css'; interface MessageRowProps { @@ -33,8 +17,7 @@ interface MessageRowProps { onToggleStarred: (event: MouseEvent, messageId: string) => void; onOpenCompose: (contact: ContactPreview) => void; onShowStatus: (tool: string, contact: ContactPreview) => void; - onKeyDown: (event: React.KeyboardEvent, threadId: string) => void; -} + onKeyDown: (event: KeyboardEvent, threadId: string) => void; } export const MessageRow = memo(function MessageRow({ diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts index 20e5d53..f014a54 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,13 +39,17 @@ export function useAsync( }, [asyncFunction]); useEffect(() => { + let cancelled = false; mountedRef.current = true; if (immediate) { - execute(); + void Promise.resolve().then(() => { + if (!cancelled) void execute(); + }); } return () => { + cancelled = true; mountedRef.current = false; }; }, [execute, immediate]);