EmailConversation.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. 'use client'
  2. import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
  3. import { useRouter } from 'next/navigation'
  4. import { useTranslations } from 'next-intl'
  5. import { toast } from 'sonner'
  6. import {
  7. AlertCircle,
  8. FileText,
  9. Image as ImageIcon,
  10. Loader2,
  11. Mail,
  12. Send,
  13. ShieldAlert,
  14. } from 'lucide-react'
  15. import { Button } from '@/components/ui/button'
  16. import { Textarea } from '@/components/ui/textarea'
  17. import { cn } from '@/lib/utils'
  18. import type { EmailChannelState } from '../Actions/inboxActions'
  19. import { getEmailConversation, sendEmailMessage } from '../Actions/emailThreadActions'
  20. import { replySubject, splitQuotedReply } from '../Lib/inboundMail'
  21. import { MessageText } from './MessageText'
  22. export interface EmailThread {
  23. key: string
  24. customerId: string | null
  25. name: string
  26. address: string
  27. }
  28. /** Taken from what the action returns, so the view cannot drift from it. */
  29. type EmailConversationData = NonNullable<Awaited<ReturnType<typeof getEmailConversation>>['data']>
  30. type EmailMessageRow = EmailConversationData['messages'][number]
  31. type EmailAttachmentRow = EmailMessageRow['attachments'][number]
  32. /**
  33. * One email conversation, read like a chat.
  34. *
  35. * Mail carries what chat does not: a subject, a quoted copy of everything
  36. * before it, files. The subject is shown only where it changes, the quote is
  37. * folded away, and the files sit under the text, so a thread of replies still
  38. * reads as a conversation rather than a stack of envelopes.
  39. *
  40. * Whether there is a box to answer in depends on where replies go. A workshop
  41. * that sends through its own vendor gets its replies in its own mailbox, so
  42. * the thread here is history and the answer is a link to the mail app.
  43. */
  44. export function EmailConversation({
  45. thread,
  46. emailState,
  47. onSent,
  48. refreshKey,
  49. className,
  50. }: {
  51. thread: EmailThread
  52. emailState: EmailChannelState
  53. /** Lets the surrounding list reload once a message is on its way. */
  54. onSent?: () => void
  55. /**
  56. * Changes when the list hears of a newer message on this thread, so the
  57. * history is re-read without being reopened.
  58. */
  59. refreshKey?: string
  60. /** How the surrounding pane wants this sized. */
  61. className?: string
  62. }) {
  63. const t = useTranslations('messaging.email')
  64. const router = useRouter()
  65. const [messages, setMessages] = useState<EmailMessageRow[]>([])
  66. const [address, setAddress] = useState(thread.address)
  67. const [loading, setLoading] = useState(true)
  68. const [body, setBody] = useState('')
  69. /** Null until the user edits it, so the subject follows the thread. */
  70. const [subjectDraft, setSubjectDraft] = useState<string | null>(null)
  71. const [isSending, startSending] = useTransition()
  72. const [mounted, setMounted] = useState(false)
  73. const endRef = useRef<HTMLDivElement>(null)
  74. const scrollAreaRef = useRef<HTMLDivElement>(null)
  75. /** Whether the reader is at the newest message, so growth may follow. */
  76. const atBottom = useRef(true)
  77. useEffect(() => setMounted(true), [])
  78. const load = useCallback(async () => {
  79. const result = await getEmailConversation({
  80. customerId: thread.customerId,
  81. contact: thread.address,
  82. })
  83. if (result.success && result.data) {
  84. setMessages(result.data.messages)
  85. setAddress(result.data.address)
  86. }
  87. setLoading(false)
  88. }, [thread.customerId, thread.address])
  89. useEffect(() => {
  90. setLoading(true)
  91. load()
  92. }, [load])
  93. // A reply that arrived while this was open: re-read quietly, keeping what
  94. // is on screen until the new rows are in.
  95. const lastRefreshKey = useRef(refreshKey)
  96. useEffect(() => {
  97. if (refreshKey === lastRefreshKey.current) return
  98. lastRefreshKey.current = refreshKey
  99. void load()
  100. }, [refreshKey, load])
  101. // Land on the newest message once the history is on screen, not before:
  102. // scrolling an empty list leaves the reader at the top.
  103. const initialScrollDone = useRef(false)
  104. useEffect(() => {
  105. const el = scrollAreaRef.current
  106. if (!el || loading || messages.length === 0) return
  107. if (initialScrollDone.current) {
  108. el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
  109. return
  110. }
  111. initialScrollDone.current = true
  112. // Two frames: one for the messages to render, one for layout to settle.
  113. requestAnimationFrame(() => {
  114. requestAnimationFrame(() => {
  115. el.scrollTop = el.scrollHeight
  116. })
  117. })
  118. }, [messages.length, loading])
  119. // Thumbnails have no height until they load. Follow the growth, but only
  120. // while the reader is already at the bottom.
  121. useEffect(() => {
  122. const el = scrollAreaRef.current
  123. if (!el || typeof ResizeObserver === 'undefined') return
  124. const observer = new ResizeObserver(() => {
  125. if (atBottom.current) el.scrollTop = el.scrollHeight
  126. })
  127. for (const child of Array.from(el.children)) observer.observe(child)
  128. return () => observer.disconnect()
  129. }, [messages.length, loading])
  130. const onScroll = () => {
  131. const el = scrollAreaRef.current
  132. if (!el) return
  133. atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
  134. }
  135. /** The subject a reply continues, and the mail it answers. */
  136. const { lastSubject, answeringId } = useMemo(() => {
  137. let subject: string | null = null
  138. let inboundId: string | null = null
  139. for (let index = messages.length - 1; index >= 0; index--) {
  140. const message = messages[index]
  141. if (!subject && message.subject?.trim()) subject = message.subject
  142. if (!inboundId && message.direction === 'inbound') inboundId = message.id
  143. if (subject && inboundId) break
  144. }
  145. return { lastSubject: subject, answeringId: inboundId }
  146. }, [messages])
  147. const defaultSubject = lastSubject ? replySubject(lastSubject) : ''
  148. const subject = subjectDraft ?? defaultSubject
  149. const handleSend = () => {
  150. const customerId = thread.customerId
  151. // Enter in the box is not disabled while a send is running, so the
  152. // guard is here: the body is only cleared once the first send is through.
  153. if (isSending || !customerId || !body.trim()) return
  154. startSending(async () => {
  155. const result = await sendEmailMessage({
  156. customerId,
  157. subject: subject.trim(),
  158. body: body.trim(),
  159. replyToId: answeringId,
  160. })
  161. if (!result.success) {
  162. toast.error(result.error ?? t('sendFailed'))
  163. return
  164. }
  165. setBody('')
  166. setSubjectDraft(null)
  167. await load()
  168. // The thread list is server-rendered, so a first mail to someone new
  169. // only appears there once the page reloads its data.
  170. router.refresh()
  171. onSent?.()
  172. endRef.current?.scrollIntoView({ behavior: 'smooth' })
  173. })
  174. }
  175. const mailto = `mailto:${address}${
  176. lastSubject ? `?subject=${encodeURIComponent(replySubject(lastSubject))}` : ''
  177. }`
  178. return (
  179. <div className={cn('flex h-full min-h-0 flex-col', className)}>
  180. <div
  181. ref={scrollAreaRef}
  182. onScroll={onScroll}
  183. className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4"
  184. >
  185. {loading ? (
  186. <div className="flex justify-center py-8">
  187. <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
  188. </div>
  189. ) : messages.length === 0 ? (
  190. <p className="py-8 text-center text-sm text-muted-foreground">{t('empty')}</p>
  191. ) : (
  192. messages.map((message, index) => (
  193. <EmailMessageItem
  194. key={message.id}
  195. message={message}
  196. showSubject={
  197. index === 0 ||
  198. subjectKey(messages[index - 1].subject) !== subjectKey(message.subject)
  199. }
  200. mounted={mounted}
  201. />
  202. ))
  203. )}
  204. <div ref={endRef} />
  205. </div>
  206. {emailState === 'active' && (
  207. <div className="shrink-0 border-t bg-background p-3 sm:p-4">
  208. {!thread.customerId ? (
  209. <p className="text-sm text-muted-foreground">{t('unknownSender')}</p>
  210. ) : (
  211. // One card shaped like the mail it becomes: the subject line on
  212. // top, the body under it, and the send control in a footer, so
  213. // the subject is not mistaken for a search box above a chat.
  214. <div className="overflow-hidden rounded-xl border bg-card shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
  215. <label className="flex items-center gap-3 border-b px-3">
  216. <span className="shrink-0 text-xs font-medium text-muted-foreground">
  217. {t('subjectPlaceholder')}
  218. </span>
  219. <input
  220. value={subject}
  221. onChange={(event) => setSubjectDraft(event.target.value)}
  222. maxLength={200}
  223. className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none"
  224. />
  225. </label>
  226. <Textarea
  227. value={body}
  228. onChange={(event) => setBody(event.target.value)}
  229. placeholder={t('placeholder')}
  230. rows={3}
  231. className="max-h-60 min-h-[76px] resize-none rounded-none border-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
  232. onKeyDown={(event) => {
  233. if (event.key === 'Enter' && !event.shiftKey) {
  234. event.preventDefault()
  235. handleSend()
  236. }
  237. }}
  238. />
  239. <div className="flex items-center justify-between gap-3 border-t bg-muted/40 px-3 py-2">
  240. <span className="hidden truncate text-[11px] text-muted-foreground sm:block">
  241. {t('sendHint')}
  242. </span>
  243. <Button
  244. type="button"
  245. size="sm"
  246. onClick={handleSend}
  247. disabled={isSending || !body.trim()}
  248. className="ml-auto"
  249. >
  250. {isSending ? (
  251. <Loader2 className="h-4 w-4 animate-spin" />
  252. ) : (
  253. <Send className="h-4 w-4" />
  254. )}
  255. {t('send')}
  256. </Button>
  257. </div>
  258. </div>
  259. )}
  260. </div>
  261. )}
  262. {emailState === 'ownVendor' && (
  263. <div className="shrink-0 border-t bg-background p-4">
  264. <p className="flex items-start gap-2 rounded-lg border bg-muted/50 p-3 text-xs text-muted-foreground">
  265. <Mail className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  266. <span>
  267. {t('ownVendorThread')}
  268. <a href={mailto} className="ml-1 font-medium text-primary hover:underline">
  269. {t('replyFromMailApp')}
  270. </a>
  271. </span>
  272. </p>
  273. </div>
  274. )}
  275. </div>
  276. )
  277. }
  278. /**
  279. * A subject with its reply prefixes and case set aside, so "Re: Brakes"
  280. * under "Brakes" is the same conversation and does not repeat its heading.
  281. */
  282. function subjectKey(subject: string | null | undefined): string {
  283. let base = (subject ?? '').trim()
  284. const prefix = /^(re|sv|aw|antw|res|odp|ynt|atb|fw|fwd|vs|wg|tr|ответ)\s*:\s*/i
  285. while (prefix.test(base)) base = base.replace(prefix, '')
  286. return base.toLowerCase()
  287. }
  288. function formatSize(bytes: number): string {
  289. if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
  290. return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
  291. }
  292. /** Today's mail shows the time; older mail needs the day too. */
  293. function formatWhen(value: string | Date): string {
  294. const date = new Date(value)
  295. const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
  296. if (date.toDateString() === new Date().toDateString()) return time
  297. return `${date.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}`
  298. }
  299. /**
  300. * One mail in the thread. A document the app sent (an invoice, a quote, a
  301. * reminder) is an event in the conversation rather than something someone
  302. * wrote, so it is a quiet line in the middle instead of a bubble.
  303. */
  304. function EmailMessageItem({
  305. message,
  306. showSubject,
  307. mounted,
  308. }: {
  309. message: EmailMessageRow
  310. showSubject: boolean
  311. mounted: boolean
  312. }) {
  313. const t = useTranslations('messaging.email')
  314. const [showQuoted, setShowQuoted] = useState(false)
  315. const isOutbound = message.direction === 'outbound'
  316. const failed = message.status === 'failed'
  317. const flagged = message.spamVerdict === 'FAIL' || message.spamVerdict === 'GRAY'
  318. const time = mounted ? formatWhen(message.createdAt) : ''
  319. if (message.kind === 'document') {
  320. const note = message.body.trim()
  321. return (
  322. <div className="flex justify-center">
  323. <div className="flex max-w-[85%] flex-col items-center gap-1 rounded-lg border bg-muted/40 px-3 py-2 text-center text-xs text-muted-foreground">
  324. <span className="flex items-center gap-1.5 font-medium text-foreground/80">
  325. <FileText className="h-3.5 w-3.5 shrink-0" />
  326. <span className="break-words">
  327. {t('sentDocument', { subject: message.subject ?? t('attachment') })}
  328. </span>
  329. </span>
  330. {note && <MessageText body={note} className="text-xs" />}
  331. {message.attachments.length > 0 && (
  332. <AttachmentChips attachments={message.attachments} isOutbound={false} />
  333. )}
  334. <span className="text-[10px]">{time}</span>
  335. {failed && message.errorMessage && (
  336. <p className="flex items-start gap-1.5 text-[11px] text-destructive">
  337. <AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
  338. {message.errorMessage}
  339. </p>
  340. )}
  341. </div>
  342. </div>
  343. )
  344. }
  345. const { reply, quoted } = isOutbound
  346. ? { reply: message.body, quoted: '' }
  347. : splitQuotedReply(message.body)
  348. const images = message.attachments.filter((file) => file.contentType.startsWith('image/'))
  349. return (
  350. <div className={cn('flex', isOutbound ? 'justify-end' : 'justify-start')}>
  351. <div
  352. className={cn(
  353. 'min-w-0 max-w-[75%] space-y-2 rounded-2xl px-4 py-2',
  354. isOutbound ? 'bg-primary text-primary-foreground' : 'bg-muted',
  355. failed && 'bg-destructive/10 text-foreground'
  356. )}
  357. >
  358. {showSubject && message.subject && (
  359. <p className="break-words text-xs font-semibold">{message.subject}</p>
  360. )}
  361. {reply.trim() && <MessageText body={reply.trim()} />}
  362. {quoted.trim() && (
  363. <div className="space-y-1">
  364. <Button
  365. type="button"
  366. variant="ghost"
  367. size="sm"
  368. className="-ml-2 h-6 px-2 text-xs text-muted-foreground"
  369. onClick={() => setShowQuoted((value) => !value)}
  370. >
  371. {showQuoted ? t('hideQuoted') : t('showQuoted')}
  372. </Button>
  373. {showQuoted && (
  374. <p className="whitespace-pre-wrap break-words border-l-2 pl-3 text-xs text-muted-foreground">
  375. {quoted.trim()}
  376. </p>
  377. )}
  378. </div>
  379. )}
  380. {images.length > 0 && (
  381. <div className="flex flex-wrap gap-2">
  382. {images.map((file) => (
  383. <a key={file.id} href={file.url} target="_blank" rel="noopener noreferrer">
  384. {/* Served by our own file route, which next/image would not add anything to. */}
  385. <img
  386. src={file.url}
  387. alt={file.fileName}
  388. loading="lazy"
  389. className="max-h-40 max-w-full rounded-lg object-cover"
  390. />
  391. </a>
  392. ))}
  393. </div>
  394. )}
  395. {message.attachments.length > 0 && (
  396. <AttachmentChips attachments={message.attachments} isOutbound={isOutbound && !failed} />
  397. )}
  398. <div
  399. className={cn(
  400. 'flex items-center gap-1.5 text-[10px]',
  401. isOutbound && !failed
  402. ? 'justify-end text-primary-foreground/70'
  403. : 'text-muted-foreground'
  404. )}
  405. >
  406. <span>{time}</span>
  407. {isOutbound && <span className="capitalize">· {message.status}</span>}
  408. </div>
  409. {flagged && (
  410. <p className="flex items-start gap-1.5 text-[11px] text-amber-700 dark:text-amber-300">
  411. <ShieldAlert className="mt-0.5 h-3 w-3 shrink-0" />
  412. {t('spamFlag')}
  413. </p>
  414. )}
  415. {failed && message.errorMessage && (
  416. <p className="flex items-start gap-1.5 text-[11px] text-destructive">
  417. <AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
  418. {message.errorMessage}
  419. </p>
  420. )}
  421. </div>
  422. </div>
  423. )
  424. }
  425. function AttachmentChips({
  426. attachments,
  427. isOutbound,
  428. }: {
  429. attachments: EmailAttachmentRow[]
  430. isOutbound: boolean
  431. }) {
  432. return (
  433. <div className="flex flex-wrap gap-1.5">
  434. {attachments.map((file) => {
  435. const Icon = file.contentType.startsWith('image/') ? ImageIcon : FileText
  436. return (
  437. <a
  438. key={file.id}
  439. href={file.url}
  440. target="_blank"
  441. rel="noopener noreferrer"
  442. className={cn(
  443. 'flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors',
  444. isOutbound
  445. ? 'border-primary-foreground/20 bg-primary-foreground/10 hover:bg-primary-foreground/20'
  446. : 'bg-background hover:bg-background/70'
  447. )}
  448. >
  449. <Icon className="h-3.5 w-3.5 shrink-0" />
  450. <span className="min-w-0 truncate">{file.fileName}</span>
  451. <span className={isOutbound ? 'text-primary-foreground/70' : 'text-muted-foreground'}>
  452. {formatSize(file.size)}
  453. </span>
  454. </a>
  455. )
  456. })}
  457. </div>
  458. )
  459. }