diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 6220ee6e..809e24a0 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -135,8 +135,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx deleted file mode 100644 index f4845258..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { X, Loader2, Send, Paperclip } from 'lucide-react'; -import { toast } from 'sonner'; -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; -import { useClient } from 'hooks/useClient'; -import { useGlobal } from 'hooks/useGlobal'; - -export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string }; - -// Shared open/close handle. Set a draft to open the composer; null closes it. -export const useComposer = () => useGlobal('EMAIL_COMPOSE', null); - -type Contact = { address: string; name: string }; - -// A recipient field with contact autocomplete on the last comma-separated segment. -const RecipientInput = ({ - value, - onChange, - placeholder, - autoFocus, -}: { - value: string; - onChange: (v: string) => void; - placeholder: string; - autoFocus?: boolean; -}) => { - const client = useClient(); - const [suggestions, setSuggestions] = useState([]); - const [open, setOpen] = useState(false); - const seg = value.split(',').pop()?.trim() ?? ''; - - useEffect(() => { - if (seg.length < 1) { - setSuggestions([]); - return; - } - const t = setTimeout(() => { - client - .get(`/email/contacts?q=${encodeURIComponent(seg)}`) - .then(setSuggestions) - .catch(() => {}); - }, 180); - return () => clearTimeout(t); - }, [seg]); - - const pick = (address: string) => { - const segs = value.split(','); - segs[segs.length - 1] = ` ${address}`; - onChange(segs.join(',').replace(/^\s+/, '') + ', '); - setOpen(false); - }; - - return ( -
- { - onChange(ev.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onBlur={() => setTimeout(() => setOpen(false), 150)} - placeholder={placeholder} - className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40" - /> - {open && suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} -
- ); -}; - -const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); - -export const ComposeModal = () => { - const client = useClient(); - const queryClient = useQueryClient(); - const [draft, setDraft] = useComposer(); - const [to, setTo] = useState(''); - const [cc, setCc] = useState(''); - const [showCc, setShowCc] = useState(false); - const [bcc, setBcc] = useState(''); - const [showBcc, setShowBcc] = useState(false); - const [subject, setSubject] = useState(''); - const [files, setFiles] = useState([]); - const [dragging, setDragging] = useState(false); - const [bodyEmpty, setBodyEmpty] = useState(true); - const [sending, setSending] = useState(false); - const seeded = useRef(null); - const fileInput = useRef(null); - const editorRef = useRef(null); - const inlineMap = useRef(new Map()); // data-inline-id → File, for the images embedded in the body - const nextImgId = useRef(0); - - const refreshEmpty = () => { - const el = editorRef.current; - setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img'))); - }; - - // Free object URLs for any images still in the editor, and clear the id→File map. - const revokeInline = () => { - editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => { - const src = (img as HTMLImageElement).src; - if (src.startsWith('blob:')) URL.revokeObjectURL(src); - }); - inlineMap.current.clear(); - }; - - useEffect(() => { - if (draft && draft !== seeded.current) { - seeded.current = draft; - setTo(draft.to ?? ''); - setCc(draft.cc ?? ''); - setShowCc(!!draft.cc); - setBcc(''); - setShowBcc(false); - setSubject(draft.subject ?? ''); - setFiles([]); - inlineMap.current.clear(); - nextImgId.current = 0; - // Seed the contenteditable body directly (uncontrolled — React never re-renders its content). - if (editorRef.current) - editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '
') : ''; - refreshEmpty(); - } - if (!draft) seeded.current = null; - }, [draft]); - - // Clipboard images often come nameless — give them a sensible filename. - const named = (f: File) => - f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }); - - // Attach button (and non-image paste/drop): everything goes as a regular attachment. - const addAttachments = (incoming: FileList | File[]) => { - const list = Array.from(incoming).map(named); - if (list.length) setFiles((prev) => [...prev, ...list]); - }; - - // Insert an image into the body at the given range (or the current caret, or the end). - const insertImage = (file: File, at?: Range | null) => { - const el = editorRef.current; - if (!el) return; - const id = `img-${nextImgId.current++}`; - inlineMap.current.set(id, file); - const img = document.createElement('img'); - img.src = URL.createObjectURL(file); - img.setAttribute('data-inline-id', id); - img.style.maxWidth = '100%'; - img.style.height = 'auto'; - - const sel = window.getSelection(); - const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null); - if (caret && el.contains(caret.startContainer)) { - caret.deleteContents(); - caret.insertNode(img); - caret.setStartAfter(img); - caret.collapse(true); - sel?.removeAllRanges(); - sel?.addRange(caret); - } else { - el.appendChild(img); - } - refreshEmpty(); - }; - - // Paste inside the body: images insert at the caret, other files attach, text pastes as plain text. - const onEditorPaste = (ev: React.ClipboardEvent) => { - const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file'); - if (fileItems.length) { - ev.preventDefault(); - fileItems - .map((it) => it.getAsFile()) - .filter((f): f is File => !!f) - .map(named) - .forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f]))); - return; - } - const text = ev.clipboardData?.getData('text/plain'); - if (text) { - ev.preventDefault(); - document.execCommand('insertText', false, text); - } - }; - - const onDrop = (ev: React.DragEvent) => { - ev.preventDefault(); - setDragging(false); - const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named); - if (!dropped.length) return; - // Place images at the drop point if it lands inside the body, otherwise append at the end. - const el = editorRef.current; - const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null; - const range = point && el?.contains(point.startContainer) ? point : null; - dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f]))); - }; - - const close = () => { - revokeInline(); - setDraft(null); - }; - - const send = async () => { - if (!to.trim()) { - toast.error('Add at least one recipient'); - return; - } - setSending(true); - try { - const fd = new FormData(); - fd.append('to', to); - if (cc.trim()) fd.append('cc', cc); - if (bcc.trim()) fd.append('bcc', bcc); - fd.append('subject', subject); - if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo); - files.forEach((f) => fd.append('files', f)); - - // Serialize the body. Clone it, rewrite each inline to a `cid:inline-N` ref (backend - // matches by order), and collect those files. Plain text goes as the fallback part. - const el = editorRef.current; - const inlineFiles: File[] = []; - if (el) { - const clone = el.cloneNode(true) as HTMLElement; - clone.querySelectorAll('img[data-inline-id]').forEach((node) => { - const img = node as HTMLImageElement; - const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? ''); - if (!file) return img.remove(); - img.setAttribute('src', `cid:inline-${inlineFiles.length}`); - img.removeAttribute('data-inline-id'); - inlineFiles.push(file); - }); - fd.append('body', el.innerText); - if (inlineFiles.length) { - fd.append('html', clone.innerHTML); - inlineFiles.forEach((f) => fd.append('inline', f)); - } - } - - await client.post('/email/send', fd); - toast.success('Email sent'); - // Gmail files the sent copy in Sent, so refresh the list to surface it. - queryClient.invalidateQueries({ queryKey: ['email-messages'] }); - close(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to send'); - } finally { - setSending(false); - } - }; - - const fmtSize = (n: number) => - n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`; - - if (!draft) return null; - - return ( - !o && close()}> - { - ev.preventDefault(); - setDragging(true); - }} - onDragLeave={(ev) => { - if (ev.currentTarget === ev.target) setDragging(false); - }} - > - {dragging && ( -
- Drop images to inline · other files attach -
- )} -
- {/^re:/i.test(subject) ? 'Reply' : 'New message'} -
- -
- To -
- -
-
- {!showCc && ( - - )} - {!showBcc && ( - - )} -
-
- {showCc && ( -
- Cc -
- -
-
- )} - {showBcc && ( -
- Bcc -
- -
-
- )} - setSubject(ev.target.value)} - placeholder="Subject" - className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40" - /> -
- {bodyEmpty && ( -
Write your message…
- )} -
-
- - {files.length > 0 && ( -
- {files.map((f, i) => ( - - - {f.name} - {fmtSize(f.size)} - - - ))} -
- )} - -
- { - if (ev.target.files?.length) addAttachments(ev.target.files); - ev.target.value = ''; - }} - /> - - - -
- -
- ); -}; - -// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't -// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading -// is a follow-up: store the Message-Id header on ingest.) -export const replyDraft = (m: { - from: string; - subject: string; - date: string; - text?: string; - snippet?: string; -}): ComposeDraft => { - const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim(); - const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`; - const original = (m.text || m.snippet || '').trim(); - const quoted = original - ? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original - .split('\n') - .map((l) => `> ${l}`) - .join('\n')}` - : ''; - return { to: addr, subject, body: quoted }; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx deleted file mode 100644 index faf30bc5..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router'; -import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; -import { - ChevronLeft, - ChevronRight, - Inbox, - Loader2, - Mail, - Paperclip, - RefreshCw, - Search, - Send, - ShieldAlert, - SquarePen, - Trash2, - X, -} from 'lucide-react'; -import { toast } from 'sonner'; -import { Button } from '@/components/ui/button'; -import { useClient } from 'hooks/useClient'; -import type { EmailSummary } from 'types'; -import { useComposer } from './Compose'; -import { emailPath, useSelectedEmailId } from './shared'; - -type EmailAccountRow = { - id: number; - provider: string; - email: string; - status: string; -}; - -const LIMIT = 50; - -const FOLDERS = [ - { key: 'inbox', label: 'Inbox', icon: Inbox }, - { key: 'all', label: 'All', icon: Mail }, - { key: 'sent', label: 'Sent', icon: Send }, - { key: 'spam', label: 'Spam', icon: ShieldAlert }, - { key: 'trash', label: 'Trash', icon: Trash2 }, -] as const; - -const formatDate = (iso: string) => { - const date = new Date(iso); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - const isThisYear = date.getFullYear() === now.getFullYear(); - if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); - return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); -}; - -export const EmailList = () => { - const client = useClient(); - const queryClient = useQueryClient(); - const [, openCompose] = useComposer(); - const navigate = useNavigate(); - const selectedId = useSelectedEmailId(); - // Which folder you are reading is addressable state, so it is a query param rather than a global. - // It stays a *param* and not a path segment because the path already spells which email is open: - // `/email/:emailId` is the thing, the folder is the view you found it through, and switching folders - // with a message open should not close it. - const [searchParams, setSearchParams] = useSearchParams(); - const folder = searchParams.get('folder') ?? 'inbox'; - const qs = searchParams.toString() ? `?${searchParams}` : ''; - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const isSearching = debouncedSearch.length > 0; - useEffect(() => { - const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); - return () => clearTimeout(t); - }, [search]); - // Page 1 on a new search *or* a new folder. The folder half used to live in the click handler; it - // cannot any more, because the folder pills are links and nobody handles their click. - useEffect(() => { - setPage(1); - }, [debouncedSearch, folder]); - - const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({ - queryKey: ['email-accounts'], - queryFn: () => client.get('/email/accounts'), - }); - const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced'); - const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued'); - const hasAccounts = emailAccounts.length > 0; - - const { data, isLoading } = useQuery({ - queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder], - queryFn: () => - isSearching - ? client.get<{ messages: EmailSummary[]; total: number }>( - `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`, - ) - : client.get<{ messages: EmailSummary[]; total: number }>( - `/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`, - ), - // Keep the previous results visible while the next query loads, so switching search terms/pages - // never bails to the full-panel loading view (which would unmount the search input and drop focus). - placeholderData: keepPreviousData, - }); - - // Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching) - const { data: allCount } = useQuery({ - queryKey: ['email-messages-all-count'], - queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'), - enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0, - }); - - useEffect(() => { - if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) { - // `replace`: this is the app correcting its own default, not a place you chose to be, so Back - // should leave /email rather than bounce you between inbox and all. - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.set('folder', 'all'); - return next; - }, - { replace: true }, - ); - } - }, [isSearching, folder, data?.total, allCount, setSearchParams]); - - // Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch. - // The EventSource closes automatically when this component unmounts (i.e. when you leave /email). - useEffect(() => { - const token = localStorage.getItem('BEARER_TOKEN'); - if (!token) return; - const es = new EventSource(`/api/email/events?token=${encodeURIComponent(token)}`); - es.onmessage = (ev) => { - try { - if ((JSON.parse(ev.data) as { type?: string }).type === 'new-mail') { - queryClient.invalidateQueries({ queryKey: ['email-messages'] }); - queryClient.invalidateQueries({ queryKey: ['email-messages-all-count'] }); - queryClient.invalidateQueries({ queryKey: ['email-accounts'] }); - } - } catch { - /* ignore non-JSON keepalives */ - } - }; - return () => es.close(); - }, [queryClient]); - - const [syncing, setSyncing] = useState(false); - - const handleSync = async () => { - if (!syncableAccount) return; - setSyncing(true); - try { - const result = await client.post<{ ok: boolean; saved?: number }>( - `/email/accounts/${syncableAccount.id}/sync`, - {}, - ); - if (result.saved !== undefined) { - toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails'); - queryClient.invalidateQueries({ queryKey: ['email-messages'] }); - } else { - toast.success('Sync started'); - } - refetchAccounts(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to sync'); - } finally { - setSyncing(false); - } - }; - - // Poll accounts while first sync is running (job-based), refresh email list when done - const prevSyncing = useRef(false); - useEffect(() => { - if (isSyncing) { - const interval = setInterval(refetchAccounts, 5000); - return () => clearInterval(interval); - } - if (prevSyncing.current && !isSyncing) { - queryClient.invalidateQueries({ queryKey: ['email-messages'] }); - } - prevSyncing.current = isSyncing; - }, [isSyncing, queryClient]); - - const messages = data?.messages ?? []; - const total = data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / LIMIT)); - - // Arrow key navigation - useEffect(() => { - const handleKeyDown = (ev: KeyboardEvent) => { - if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return; - if (messages.length === 0) return; - ev.preventDefault(); - const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1; - let nextId: string | undefined; - if (ev.key === 'ArrowDown') { - const next = currentIndex + 1; - if (next < messages.length) nextId = messages[next]!.id; - } else { - const prev = currentIndex - 1; - if (prev >= 0) nextId = messages[prev]!.id; - } - if (nextId) { - // `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one - // history entry per row, and Back would then walk the sweep instead of leaving the mailbox. - // A click is a real link and does push. - navigate(emailPath(nextId, qs), { replace: true }); - document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' }); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [messages, selectedId, navigate, qs]); - - if (isLoading) { - return
Loading emails...
; - } - - // Show onboarding empty state only when no emails exist at all (never while searching) - const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total; - if (hasNoEmails && !isLoading && page === 1) { - return ( -
- - {!hasAccounts ? ( -
- Add an email account to get started - -
- ) : syncableAccount ? ( -
- No emails synced yet - -
- ) : isSyncing ? ( -
- - Syncing emails... -
- ) : ( - No emails synced yet - )} -
- ); - } - - return ( -
-
-
- {FOLDERS.map((f) => { - const Icon = f.icon; - const isActive = folder === f.key; - // Search-only `to`, so react-router keeps the current pathname — the open email survives a - // folder switch. Built from the live params rather than written flat, so anything else in - // the query string comes along. - const next = new URLSearchParams(searchParams); - next.set('folder', f.key); - return ( - - - {f.label} - - ); - })} -
- {total} - {syncing || isSyncing ? ( - - - - ) : syncableAccount ? ( - - ) : null} - - {totalPages > 1 && ( -
- - - {page}/{totalPages} - - -
- )} -
- -
- - setSearch(ev.target.value)} - placeholder="Search — try from:, subject:, has:attachment…" - className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40" - /> - {isSearching && ( - - {total} result{total === 1 ? '' : 's'} - - )} - {search && ( - - )} -
- {messages.length === 0 ? ( -
- {isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'} -
- ) : ( -
- {messages.map((msg: EmailSummary) => { - const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read; - return ( - -
-
- - {msg.from} - - {!!msg.threadCount && ( - - {msg.threadCount} - - )} -
- {formatDate(msg.date)} -
- {msg.subject} -
- {!!msg.attachmentCount && ( - - - {msg.attachmentCount} - - )} - {msg.snippet} -
- - ); - })} -
- )} -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx deleted file mode 100644 index dbe7c884..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Mail, Reply, Paperclip } from 'lucide-react'; -import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev'; -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; -import { useClient } from 'hooks/useClient'; -import type { EmailMessage, EmailThread } from 'types'; -import { useComposer, replyDraft } from './Compose'; -import { useSelectedEmailId } from './shared'; - -type OpenAttachment = { - filePath: string; - fileName: string; - root: string; -}; - -const senderName = (from: string) => from.replace(/\s*<[^>]+>$/, '').trim() || from; - -const HtmlBody = ({ html }: { html: string }) => { - const iframeRef = useRef(null); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - const doc = iframe.contentDocument; - if (!doc) return; - doc.open(); - doc.write(` - - - - - - ${html} - - `); - doc.close(); - // Auto-size to content so each message sits at its natural height inside the stacked thread. - const resize = () => { - if (doc.body) iframe.style.height = `${doc.body.scrollHeight + 8}px`; - }; - resize(); - doc.querySelectorAll('img').forEach((img) => img.addEventListener('load', resize)); - const t = setTimeout(resize, 300); - return () => clearTimeout(t); - }, [html]); - - return