@novacraft-engineering/mailbox 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/mail/page.tsx CHANGED
@@ -4,14 +4,15 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'rea
4
4
  import { extractUrls, inspectUrl, type LinkVerdict } from '@/lib/link-safety'
5
5
  import AttachmentLightbox, { attachmentKind, formatSize, type PreviewItem } from './AttachmentLightbox'
6
6
  import AccessCheck from './AccessCheck'
7
- import { parseQuery, matchesQuery } from './search'
7
+ import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './search'
8
8
  import { useConfirm } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
10
  import RichEditor from './RichEditor'
11
11
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
12
12
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
13
- import MailSelect from './MailSelect'
14
- import Ticker from './Ticker'
13
+ import MailSelect, { GLYPH } from './MailSelect'
14
+ import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
15
+ import { splitQuotedTail, splitQuotedText } from './quoted'
15
16
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
16
17
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
17
18
  import styles from './page.module.css'
@@ -129,6 +130,7 @@ type SentEmail = {
129
130
  createdAt: string
130
131
  scheduledAt: string | null
131
132
  lastEvent: string
133
+ attachmentCount?: number
132
134
  starred: boolean
133
135
  archived: boolean
134
136
  trashed: boolean
@@ -303,6 +305,9 @@ type MailSettings = {
303
305
  mobile: string
304
306
  replyAllDefault: boolean
305
307
  replyStyle: 'panel' | 'mini'
308
+ /** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
309
+ threadMessages?: 'collapsible' | 'open'
310
+ ticker?: TickerSettings
306
311
  notifyEmail: string
307
312
  fonts: CustomFont[]
308
313
  defaultFont: BaseFont
@@ -1006,6 +1011,24 @@ function formatHtmlSource(html: string): string {
1006
1011
  return out.join('\n')
1007
1012
  }
1008
1013
 
1014
+ /**
1015
+ * When a message in a conversation arrived, said outright rather than as "3h".
1016
+ * A relative age suits a list being scanned; a thread being read is a record, and a record
1017
+ * wants the day and the hour. The year appears only once it is no longer the current one.
1018
+ */
1019
+ function formatStamp(iso: string): string {
1020
+ const date = new Date(iso)
1021
+ if (Number.isNaN(date.getTime())) return ''
1022
+ const sameYear = date.getFullYear() === new Date().getFullYear()
1023
+ return date.toLocaleString([], {
1024
+ month: 'short',
1025
+ day: 'numeric',
1026
+ ...(sameYear ? {} : { year: 'numeric' }),
1027
+ hour: 'numeric',
1028
+ minute: '2-digit',
1029
+ })
1030
+ }
1031
+
1009
1032
  function formatRelative(iso: string, now: number): string {
1010
1033
  const time = new Date(iso).getTime()
1011
1034
  if (Number.isNaN(time)) return ''
@@ -1363,9 +1386,11 @@ export default function DevMailPage() {
1363
1386
  const [threadOrder, setThreadOrder] = useState<'newest' | 'oldest'>('newest')
1364
1387
  const [threadOpening, setThreadOpening] = useState<string | null>(null)
1365
1388
  const [searchOpen, setSearchOpen] = useState(false)
1366
- const [searchPick, setSearchPick] = useState(0)
1389
+ const [searchPick, setSearchPick] = useState(-1)
1367
1390
  const [threadExpanded, setThreadExpanded] = useState<Set<string>>(new Set())
1368
1391
  const [threadEverOpen, setThreadEverOpen] = useState<Set<string>>(new Set())
1392
+ const [quoteOpen, setQuoteOpen] = useState<Set<string>>(new Set())
1393
+ const [msgDetails, setMsgDetails] = useState<Set<string>>(new Set())
1369
1394
  const [linkVerdicts, setLinkVerdicts] = useState<Record<string, { verdict: LinkVerdict; reasons: string[] }>>({})
1370
1395
  const scanLinksRef = useRef<(urls: string[]) => void>(() => {})
1371
1396
  const [preview, setPreview] = useState<{ items: PreviewItem[]; index: number } | null>(null)
@@ -1396,6 +1421,8 @@ export default function DevMailPage() {
1396
1421
  } catch {}
1397
1422
  }, [])
1398
1423
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1424
+ const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1425
+ const messagesAlwaysOpen = settings.threadMessages === 'open'
1399
1426
  const { canInstall, installed, install } = useInstall()
1400
1427
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
1401
1428
  settings.desktopNotifications,
@@ -1859,8 +1886,16 @@ export default function DevMailPage() {
1859
1886
  * not looked. Null means "no search", so the folder shows the ordinary list.
1860
1887
  */
1861
1888
  const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
1862
- const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
1889
+ const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; query: string; count: number; more: boolean } | null>(null)
1863
1890
  const crossFolderSeq = useRef(0)
1891
+ const crossFolderLabel = crossFolder
1892
+ ? `Also ${crossFolder.count.toLocaleString()}${crossFolder.more ? '+' : ''} in ${crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →`
1893
+ : ''
1894
+ const followCrossFolder = () => {
1895
+ if (!crossFolder) return
1896
+ setFolder(crossFolder.folder)
1897
+ setSearch(crossFolder.query)
1898
+ }
1864
1899
  useEffect(() => {
1865
1900
  const text = search.trim()
1866
1901
  const seq = ++crossFolderSeq.current
@@ -1868,19 +1903,21 @@ export default function DevMailPage() {
1868
1903
  setCrossFolder(null)
1869
1904
  return
1870
1905
  }
1906
+ const mirrored = text.replace(/(^|\s)(from|to):/gi, (_, lead: string, field: string) => `${lead}${field.toLowerCase() === 'from' ? 'to' : 'from'}:`)
1871
1907
  const timer = window.setTimeout(async () => {
1872
1908
  try {
1873
1909
  if (folder === 'sent') {
1874
- const response = await fetch(`/api/mail/inbox?limit=1&folder=inbox&q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1910
+ const params = new URLSearchParams({ limit: '1', folder: 'inbox', ...serverSearchParams(mirrored) })
1911
+ const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1875
1912
  const data = await response.json()
1876
1913
  if (seq !== crossFolderSeq.current) return
1877
- setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
1914
+ setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', query: mirrored, count: data.total, more: false } : null)
1878
1915
  } else {
1879
- const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1916
+ const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(mirrored)}`, { headers: apiHeaders() })
1880
1917
  const data = await response.json()
1881
1918
  if (seq !== crossFolderSeq.current) return
1882
1919
  const count = Array.isArray(data.emails) ? data.emails.length : 0
1883
- setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
1920
+ setCrossFolder(data.ok && count > 0 ? { folder: 'sent', query: mirrored, count, more: Boolean(data.truncated) } : null)
1884
1921
  }
1885
1922
  } catch {
1886
1923
  if (seq === crossFolderSeq.current) setCrossFolder(null)
@@ -1940,8 +1977,7 @@ export default function DevMailPage() {
1940
1977
  params.set('folder', threadFolder)
1941
1978
  // A cursor asks for the page after a known row; without one this is the first page.
1942
1979
  if (cursor) params.set('cursor', cursor)
1943
- const text = search.trim()
1944
- if (text) params.set('q', text)
1980
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1945
1981
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1946
1982
  if (!response.ok) throw new Error(describeHttp(response.status))
1947
1983
  const data = await response.json().catch(() => null)
@@ -1986,8 +2022,7 @@ export default function DevMailPage() {
1986
2022
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1987
2023
  params.set('limit', String(INBOX_PAGE))
1988
2024
  params.set('folder', threadFolder)
1989
- const text = search.trim()
1990
- if (text) params.set('q', text)
2025
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1991
2026
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1992
2027
  if (!response.ok) throw new Error(describeHttp(response.status))
1993
2028
  const data = await response.json().catch(() => null)
@@ -2609,12 +2644,12 @@ export default function DevMailPage() {
2609
2644
  .then(response => response.json())
2610
2645
  .then(data => {
2611
2646
  if (data.ok && data.settings) {
2612
- const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
2647
+ const stored = data.settings as MailSettings & { density?: string; replyStyle?: string; ticker?: unknown; threadMessages?: string }
2613
2648
  // Only the roomy setting is named; anything else — unset, or the retired
2614
2649
  // "comfortable" — is the default. Treating every unrecognised value as roomy put
2615
2650
  // six of the seven mailboxes into a reduced reader none of them had asked for.
2616
2651
  const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2617
- setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
2652
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
2618
2653
  const prefs = (data.settings as MailSettings).prefs
2619
2654
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2620
2655
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2737,11 +2772,12 @@ export default function DevMailPage() {
2737
2772
 
2738
2773
 
2739
2774
  const searchQuery = useMemo(() => parseQuery(search), [search])
2775
+ const residualQuery = useMemo(() => splitForServer(searchQuery).residual, [searchQuery])
2740
2776
 
2741
2777
  const matchesInbound = useCallback(
2742
2778
  (entry: InboundEmail) => {
2743
- if (searchQuery.isEmpty) return true
2744
- return matchesQuery(searchQuery, {
2779
+ if (residualQuery.isEmpty) return true
2780
+ return matchesQuery(residualQuery, {
2745
2781
  from: entry.from,
2746
2782
  to: entry.to.join(' '),
2747
2783
  cc: (entry.cc ?? []).join(' '),
@@ -2758,7 +2794,7 @@ export default function DevMailPage() {
2758
2794
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
2759
2795
  })
2760
2796
  },
2761
- [searchQuery],
2797
+ [residualQuery],
2762
2798
  )
2763
2799
 
2764
2800
  const matchesSent = useCallback(
@@ -2776,7 +2812,7 @@ export default function DevMailPage() {
2776
2812
  size: null,
2777
2813
  read: true,
2778
2814
  starred: entry.starred,
2779
- hasAttachment: false,
2815
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2780
2816
  labels: [],
2781
2817
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'sent',
2782
2818
  })
@@ -2977,7 +3013,7 @@ export default function DevMailPage() {
2977
3013
  : formatRelative(entry.createdAt, now),
2978
3014
  unread: false,
2979
3015
  starred: entry.starred,
2980
- hasAttachment: false,
3016
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2981
3017
  chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
2982
3018
  threadCount: 1,
2983
3019
  latestAt: new Date(entry.createdAt).getTime(),
@@ -5204,7 +5240,7 @@ export default function DevMailPage() {
5204
5240
  )}
5205
5241
  </button>
5206
5242
  <div className={styles.attachInfo}>
5207
- <Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
5243
+ <Ticker className={styles.attachName} title={attachment.filename} enabled={tickerOn('attachName')}>{attachment.filename}</Ticker>
5208
5244
  <span className={styles.attachMeta}>
5209
5245
  {attachment.size ? formatSize(attachment.size) : null}
5210
5246
  {attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
@@ -5337,7 +5373,7 @@ export default function DevMailPage() {
5337
5373
  <span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
5338
5374
  </span>
5339
5375
  )}
5340
- <Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
5376
+ <Ticker className={styles.attachChipName} title={attachment.filename} enabled={tickerOn('attachChip')}>{attachment.filename}</Ticker>
5341
5377
  <span className={styles.attachChipSize}>{state}</span>
5342
5378
  {attachment.error && attachment.file && (
5343
5379
  <button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
@@ -5415,6 +5451,26 @@ export default function DevMailPage() {
5415
5451
  return list.length ? renderAttachmentTiles(list) : null
5416
5452
  }
5417
5453
 
5454
+ const toggleQuote = (id: string) =>
5455
+ setQuoteOpen(current => {
5456
+ const next = new Set(current)
5457
+ if (next.has(id)) next.delete(id)
5458
+ else next.add(id)
5459
+ return next
5460
+ })
5461
+
5462
+ /** The line offering the repeated thread underneath a reply. */
5463
+ const renderQuoteLine = (id: string, shown: boolean) => (
5464
+ <button type="button" className={styles.quoteLine} aria-expanded={shown} onClick={() => toggleQuote(id)}>
5465
+ <span className={styles.quoteLineRule} aria-hidden />
5466
+ <span className={styles.quoteLineLabel}>
5467
+ {ICONS.chevron}
5468
+ {shown ? 'Hide earlier messages' : 'Show earlier messages'}
5469
+ </span>
5470
+ <span className={styles.quoteLineRule} aria-hidden />
5471
+ </button>
5472
+ )
5473
+
5418
5474
  const renderInboundBody = (message: InboundEmail, framed: boolean) => {
5419
5475
  if (readerMode === 'plain') {
5420
5476
  return <pre className={styles.readerText}>{message.text?.trim() || htmlToSnippetText(message.html, null) || '(no text part)'}</pre>
@@ -5422,26 +5478,46 @@ export default function DevMailPage() {
5422
5478
  if (readerMode === 'html') return <pre className={styles.readerSource}>{formatHtmlSource(message.html ?? '')}</pre>
5423
5479
  if (readerMode === 'raw') return <pre className={styles.readerSource}>{rawInboundMessage(message)}</pre>
5424
5480
  // preview
5425
- if (!message.html) return <pre className={styles.readerText}>{message.text ?? '(no content)'}</pre>
5481
+ if (!message.html) {
5482
+ const plain = splitQuotedText(message.text ?? '')
5483
+ const textShown = quoteOpen.has(message.id)
5484
+ return (
5485
+ <>
5486
+ <pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
5487
+ {plain.tail && renderQuoteLine(message.id, textShown)}
5488
+ </>
5489
+ )
5490
+ }
5491
+ const split = splitQuotedTail(message.html)
5492
+ const quoteShown = quoteOpen.has(message.id)
5493
+ const bodyHtml = split.tail && !quoteShown ? split.head : message.html
5426
5494
  if (framed) {
5427
5495
  // Thread messages auto-size to content (whole conversation scrolls as one).
5428
5496
  // sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
5429
5497
  return (
5430
- <iframe
5431
- className={styles.threadFrame}
5432
- sandbox="allow-same-origin"
5433
- srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')}
5434
- title="Email content"
5435
- onLoad={event => {
5436
- try {
5437
- const doc = event.currentTarget.contentDocument
5438
- if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
5439
- } catch {}
5440
- }}
5441
- />
5498
+ <>
5499
+ <iframe
5500
+ className={styles.threadFrame}
5501
+ sandbox="allow-same-origin"
5502
+ srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
5503
+ title="Email content"
5504
+ onLoad={event => {
5505
+ try {
5506
+ const doc = event.currentTarget.contentDocument
5507
+ if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
5508
+ } catch {}
5509
+ }}
5510
+ />
5511
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5512
+ </>
5442
5513
  )
5443
5514
  }
5444
- return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5515
+ return (
5516
+ <>
5517
+ <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5518
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5519
+ </>
5520
+ )
5445
5521
  }
5446
5522
 
5447
5523
  // One message open at a time — opening another collapses the rest.
@@ -5592,11 +5668,23 @@ export default function DevMailPage() {
5592
5668
  mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
5593
5669
  ).slice(0, 100)
5594
5670
  const unread = !mine && !item.inbound.read
5671
+ // Who each reply actually went to. A thread quietly gains and loses people in Cc, and
5672
+ // that is the change most worth noticing partway down a conversation.
5673
+ const recips = mine
5674
+ ? { to: item.sent.to, cc: detail?.cc ?? [], bcc: detail?.bcc ?? [] }
5675
+ : { to: item.inbound.to, cc: item.inbound.cc ?? [], bcc: item.inbound.bcc ?? [] }
5676
+ const recipsLine = [
5677
+ `to ${recips.to.join(', ') || 'you'}`,
5678
+ recips.cc.length ? `cc ${recips.cc.join(', ')}` : '',
5679
+ recips.bcc.length ? `bcc ${recips.bcc.join(', ')}` : '',
5680
+ ].filter(Boolean).join(' · ')
5681
+ const detailsOpen = msgDetails.has(item.id)
5595
5682
  const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
5683
+ const folds = !messagesAlwaysOpen || layout === 'bubbles'
5596
5684
  const shellClass =
5597
5685
  layout === 'bubbles'
5598
5686
  ? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
5599
- : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''}`
5687
+ : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''} ${folds ? '' : styles.threadMsgStatic}`
5600
5688
  // Body stays mounted once opened so collapsing animates too, instead of snapping shut.
5601
5689
  const toggleOpen = () => {
5602
5690
  setThreadEverOpen(current => (current.has(item.id) ? current : new Set(current).add(item.id)))
@@ -5609,24 +5697,49 @@ export default function DevMailPage() {
5609
5697
  the attachment mark, and a button cannot hold another button. */}
5610
5698
  <div
5611
5699
  className={styles.threadMsgHead}
5612
- role="button"
5613
- tabIndex={0}
5614
- aria-expanded={open}
5615
- onClick={toggleOpen}
5616
- onKeyDown={event => {
5617
- if (event.key === 'Enter' || event.key === ' ') {
5618
- event.preventDefault()
5619
- toggleOpen()
5620
- }
5621
- }}
5700
+ {...(folds
5701
+ ? {
5702
+ role: 'button' as const,
5703
+ tabIndex: 0,
5704
+ 'aria-expanded': open,
5705
+ onClick: toggleOpen,
5706
+ onKeyDown: (event: React.KeyboardEvent) => {
5707
+ if (event.key === 'Enter' || event.key === ' ') {
5708
+ event.preventDefault()
5709
+ toggleOpen()
5710
+ }
5711
+ },
5712
+ }
5713
+ : {})}
5622
5714
  >
5623
5715
  <span className={styles.avatar}>{initial}</span>
5624
5716
  <span className={styles.threadMsgMeta}>
5625
5717
  <span className={styles.threadMsgFrom}>
5626
5718
  {unread && <span className={styles.unreadDot} />}
5627
- <Ticker className={styles.threadMsgFromText}>{from}</Ticker>
5719
+ <Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
5628
5720
  </span>
5629
- {!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
5721
+ {!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
5722
+ {open && layout !== 'bubbles' && (
5723
+ <span className={styles.threadMsgRecips}>
5724
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>{recipsLine}</Ticker>
5725
+ <button
5726
+ type="button"
5727
+ className={styles.threadMsgDetails}
5728
+ aria-expanded={detailsOpen}
5729
+ onClick={event => {
5730
+ event.stopPropagation()
5731
+ setMsgDetails(current => {
5732
+ const next = new Set(current)
5733
+ if (next.has(item.id)) next.delete(item.id)
5734
+ else next.add(item.id)
5735
+ return next
5736
+ })
5737
+ }}
5738
+ >
5739
+ {detailsOpen ? 'Hide details' : 'Details'}
5740
+ </button>
5741
+ </span>
5742
+ )}
5630
5743
  </span>
5631
5744
  {hasAttach && (
5632
5745
  <span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
@@ -5667,11 +5780,20 @@ export default function DevMailPage() {
5667
5780
  {ICONS.print}
5668
5781
  </button>
5669
5782
  )}
5670
- <span className={styles.threadMsgDate}>{formatRelative(when, now)}</span>
5671
- <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>
5783
+ <span className={styles.threadMsgDate} title={new Date(when).toLocaleString()}>{formatStamp(when)}</span>
5784
+ {folds && <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>}
5672
5785
  </div>
5673
5786
  {(open || threadEverOpen.has(item.id)) && (
5674
5787
  <div className={`${styles.threadMsgWrap} ${open ? styles.threadMsgWrapOpen : ''}`}>
5788
+ {open && detailsOpen && layout !== 'bubbles' && (
5789
+ <dl className={styles.fullHeaders}>
5790
+ <div><dt>From</dt><dd>{mine ? 'You' : item.inbound.from}</dd></div>
5791
+ <div><dt>To</dt><dd>{recips.to.join(', ') || '—'}</dd></div>
5792
+ {recips.cc.length > 0 && <div><dt>Cc</dt><dd>{recips.cc.join(', ')}</dd></div>}
5793
+ {recips.bcc.length > 0 && <div><dt>Bcc</dt><dd>{recips.bcc.join(', ')}</dd></div>}
5794
+ <div><dt>Date</dt><dd>{new Date(when).toLocaleString()}</dd></div>
5795
+ </dl>
5796
+ )}
5675
5797
  <div className={styles.threadMsgBody}>
5676
5798
  {layout === 'bubbles' ? (
5677
5799
  mine && !detail ? (
@@ -5748,16 +5870,21 @@ export default function DevMailPage() {
5748
5870
  <div className={styles.readerMeta}>
5749
5871
  <div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
5750
5872
  <div className={styles.readerMetaText}>
5751
- <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{inbound.from}</Ticker>
5752
- <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {inbound.to.join(', ') || 'you'}</Ticker>
5753
- {inbound.cc.length > 0 && <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>cc {inbound.cc.join(', ')}</Ticker>}
5754
- </div>
5755
- <div className={styles.readerDate}>
5756
- {new Date(inbound.receivedAt).toLocaleString()}
5757
- <button className={styles.headerToggle} onClick={() => setShowFullHeaders(open => !open)}>
5758
- {showFullHeaders ? 'Hide details' : 'Details'}
5759
- </button>
5873
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{inbound.from}</Ticker>
5874
+ <span className={styles.threadMsgRecips}>
5875
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>
5876
+ {[
5877
+ `to ${inbound.to.join(', ') || 'you'}`,
5878
+ inbound.cc.length ? `cc ${inbound.cc.join(', ')}` : '',
5879
+ inbound.bcc.length ? `bcc ${inbound.bcc.join(', ')}` : '',
5880
+ ].filter(Boolean).join(' · ')}
5881
+ </Ticker>
5882
+ <button className={styles.threadMsgDetails} aria-expanded={showFullHeaders} onClick={() => setShowFullHeaders(open => !open)}>
5883
+ {showFullHeaders ? 'Hide details' : 'Details'}
5884
+ </button>
5885
+ </span>
5760
5886
  </div>
5887
+ <div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
5761
5888
  </div>
5762
5889
  {showFullHeaders && (
5763
5890
  <dl className={styles.fullHeaders}>
@@ -5968,7 +6095,7 @@ export default function DevMailPage() {
5968
6095
  <div className={readerLayout === 'bubbles' ? styles.bubbles : styles.thread}>
5969
6096
  {/* Bubbles stay chronological — a chat reads oldest to newest. */}
5970
6097
  {(readerLayout === 'bubbles' || threadOrder === 'oldest' ? unified : [...unified].reverse()).map(item =>
5971
- renderThreadMessage(item, threadExpanded.has(item.id), readerLayout),
6098
+ renderThreadMessage(item, messagesAlwaysOpen || threadExpanded.has(item.id), readerLayout),
5972
6099
  )}
5973
6100
  </div>
5974
6101
  ) : (
@@ -6114,7 +6241,7 @@ export default function DevMailPage() {
6114
6241
  title="Recipients — Cc, Bcc"
6115
6242
  >
6116
6243
  <span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
6117
- <Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
6244
+ <Ticker className={styles.replyRecipsSummary} enabled={tickerOn('replyRecipients')}>{recipSummary}</Ticker>
6118
6245
  </button>
6119
6246
  </div>
6120
6247
  {replyRecipsOpen && (
@@ -6333,8 +6460,8 @@ export default function DevMailPage() {
6333
6460
  <div className={styles.readerMeta}>
6334
6461
  <div className={styles.avatar}>N</div>
6335
6462
  <div className={styles.readerMetaText}>
6336
- <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6337
- <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {selectedSent.to.join(', ')}</Ticker>
6463
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6464
+ <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`} enabled={tickerOn('readerTo')}>to {selectedSent.to.join(', ')}</Ticker>
6338
6465
  </div>
6339
6466
  <div className={styles.readerDate}>
6340
6467
  {isScheduled && selectedSent.scheduledAt
@@ -6880,9 +7007,9 @@ export default function DevMailPage() {
6880
7007
  {ICONS.refresh}
6881
7008
  </button>
6882
7009
  </div>
6883
- {crossFolder && (
6884
- <button type="button" className={styles.crossFolderHint} onClick={() => setFolder(crossFolder.folder)}>
6885
- Also {crossFolder.count.toLocaleString()}{crossFolder.more ? '+' : ''} in {crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →
7010
+ {crossFolder && listItems.length > 0 && (
7011
+ <button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
7012
+ {crossFolderLabel}
6886
7013
  </button>
6887
7014
  )}
6888
7015
  <div className={styles.search}>
@@ -6893,7 +7020,7 @@ export default function DevMailPage() {
6893
7020
  onChange={event => {
6894
7021
  setSearch(event.target.value)
6895
7022
  setSearchOpen(true)
6896
- setSearchPick(0)
7023
+ setSearchPick(-1)
6897
7024
  }}
6898
7025
  onFocus={() => setSearchOpen(true)}
6899
7026
  onBlur={() => window.setTimeout(() => setSearchOpen(false), 120)}
@@ -6904,11 +7031,12 @@ export default function DevMailPage() {
6904
7031
  setSearchPick(current => (current + 1) % searchSuggestions.length)
6905
7032
  } else if (event.key === 'ArrowUp') {
6906
7033
  event.preventDefault()
6907
- setSearchPick(current => (current - 1 + searchSuggestions.length) % searchSuggestions.length)
7034
+ setSearchPick(current => (current <= 0 ? searchSuggestions.length : current) - 1)
6908
7035
  } else if (event.key === 'Enter') {
6909
7036
  event.preventDefault()
6910
- setSearch(searchSuggestions[searchPick].apply)
6911
- setSearchPick(0)
7037
+ if (searchPick >= 0) setSearch(searchSuggestions[searchPick].apply)
7038
+ setSearchPick(-1)
7039
+ setSearchOpen(false)
6912
7040
  } else if (event.key === 'Escape') {
6913
7041
  setSearchOpen(false)
6914
7042
  }
@@ -6920,7 +7048,7 @@ export default function DevMailPage() {
6920
7048
  </button>
6921
7049
  )}
6922
7050
  {searchOpen && searchSuggestions.length > 0 && (
6923
- <div className={styles.searchSuggest} role="listbox">
7051
+ <div className={styles.searchSuggest} role="listbox" onMouseLeave={() => setSearchPick(-1)}>
6924
7052
  {searchSuggestions.map((suggestion, index) => (
6925
7053
  <button
6926
7054
  key={suggestion.apply}
@@ -6932,7 +7060,7 @@ export default function DevMailPage() {
6932
7060
  onMouseDown={event => {
6933
7061
  event.preventDefault()
6934
7062
  setSearch(suggestion.apply)
6935
- setSearchPick(0)
7063
+ setSearchPick(-1)
6936
7064
  }}
6937
7065
  >
6938
7066
  <span className={styles.searchOptionLabel}>{suggestion.label}</span>
@@ -7085,6 +7213,17 @@ export default function DevMailPage() {
7085
7213
  Try again
7086
7214
  </button>
7087
7215
  </div>
7216
+ ) : listItems.length === 0 && search.trim() ? (
7217
+ <div className={styles.emptyList}>
7218
+ <span className={styles.emptyHex} />
7219
+ <p className={styles.emptyTitle}>Nothing matches here</p>
7220
+ <p className={styles.emptySub}>No mail in {folderTitles[folder]} matches “{search.trim()}”.</p>
7221
+ {crossFolder && (
7222
+ <button type="button" className={`${styles.crossFolderHint} ${styles.emptyNudge}`} onClick={followCrossFolder}>
7223
+ {crossFolderLabel}
7224
+ </button>
7225
+ )}
7226
+ </div>
7088
7227
  ) : listItems.length === 0 ? (
7089
7228
  <div className={styles.emptyList}>
7090
7229
  <span className={styles.emptyHex} />
@@ -7181,15 +7320,15 @@ export default function DevMailPage() {
7181
7320
  </span>
7182
7321
  <span className={styles.itemBody}>
7183
7322
  <span className={styles.itemTop}>
7184
- <Ticker className={styles.itemFrom}>{item.primary}</Ticker>
7323
+ <Ticker className={styles.itemFrom} enabled={tickerOn('listFrom')}>{item.primary}</Ticker>
7185
7324
  {item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
7186
7325
  <span className={styles.itemTime}>{item.time}</span>
7187
7326
  </span>
7188
7327
  <p className={styles.itemSubject}>
7189
7328
  {item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
7190
- <Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
7329
+ <Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
7191
7330
  </p>
7192
- {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
7331
+ {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
7193
7332
  {item.labels.length > 0 && (
7194
7333
  <span className={styles.itemLabels}>
7195
7334
  {item.labels.map(labelId => {
@@ -7940,6 +8079,44 @@ export default function DevMailPage() {
7940
8079
  ))}
7941
8080
  </div>
7942
8081
  </div>
8082
+ <div className={styles.settingsField}>
8083
+ <span>Conversation messages</span>
8084
+ <div className={styles.themeRow}>
8085
+ {([
8086
+ ['collapsible', 'Collapsible'],
8087
+ ['open', 'Always open'],
8088
+ ] as Array<[NonNullable<MailSettings['threadMessages']>, string]>).map(([value, label]) => (
8089
+ <button
8090
+ key={value}
8091
+ type="button"
8092
+ className={`${styles.themeChip} ${(settings.threadMessages ?? 'collapsible') === value ? styles.themeChipOn : ''}`}
8093
+ onClick={() => setMailSettings(current => ({ ...current, threadMessages: value }))}
8094
+ >
8095
+ {label}
8096
+ </button>
8097
+ ))}
8098
+ </div>
8099
+ <p className={styles.settingsNote}>Collapsible folds each message away until you click it. Always open lays the whole conversation out to read straight through.</p>
8100
+ </div>
8101
+ <div className={styles.settingsField}>
8102
+ <span>Scrolling text</span>
8103
+ <p className={styles.settingsNote}>A line that does not fit glides back and forth. Turn that off anywhere you would rather see it cut short.</p>
8104
+ {TICKER_SPOTS.map(group => (
8105
+ <div key={group.group} className={styles.tickerGroup}>
8106
+ <span className={styles.tickerGroupName}>{group.group}</span>
8107
+ {group.spots.map(([spot, label]) => (
8108
+ <label key={spot} className={styles.settingsToggle}>
8109
+ <input
8110
+ type="checkbox"
8111
+ checked={tickerOn(spot)}
8112
+ onChange={event => setMailSettings(current => ({ ...current, ticker: { ...current.ticker, [spot]: event.target.checked } }))}
8113
+ />
8114
+ <span>{label}</span>
8115
+ </label>
8116
+ ))}
8117
+ </div>
8118
+ ))}
8119
+ </div>
7943
8120
  </>)}
7944
8121
 
7945
8122
  {settingsTab === 'profile' && (<>
@@ -8221,19 +8398,19 @@ export default function DevMailPage() {
8221
8398
  <div className={styles.fontAdd}>
8222
8399
  <MailSelect
8223
8400
  ariaLabel="Default font"
8224
- prefix={<span className={styles.selectGlyphSerif}>A</span>}
8401
+ prefix={GLYPH.font}
8225
8402
  value={settings.defaultFont?.family ?? ''}
8226
- options={[{ value: '', label: 'Arial (standard)' }, ...[...BUILTIN_FONTS, ...(settings.fonts ?? []).map(font => font.name)].filter((font, index, all) => all.indexOf(font) === index).map(font => ({ value: font, label: font }))]}
8403
+ options={[{ value: '', label: 'Arial (standard)', closed: 'Arial' }, ...[...BUILTIN_FONTS, ...(settings.fonts ?? []).map(font => font.name)].filter((font, index, all) => all.indexOf(font) === index).map(font => ({ value: font, label: font }))]}
8227
8404
  optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
8228
8405
  onChange={family => setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), family } }))}
8229
8406
  />
8230
8407
  <MailSelect
8231
8408
  ariaLabel="Default size"
8232
- prefix="tT"
8409
+ prefix={GLYPH.size}
8233
8410
  editable
8234
8411
  placeholder="15"
8235
8412
  value={settings.defaultFont?.size ?? ''}
8236
- options={[{ value: '', label: '15 (standard)' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
8413
+ options={[{ value: '', label: '15 (standard)', closed: '15' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
8237
8414
  onChange={raw => {
8238
8415
  const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
8239
8416
  if (size && !/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) return
@@ -8248,12 +8425,12 @@ export default function DevMailPage() {
8248
8425
  <div className={styles.fontAdd}>
8249
8426
  <MailSelect
8250
8427
  ariaLabel="Line spacing"
8251
- prefix="↕"
8428
+ prefix={GLYPH.spacing}
8252
8429
  editable
8253
8430
  placeholder={String(DEFAULT_LINE_SPACING)}
8254
8431
  value={settings.defaultFont?.lineSpacing ? String(settings.defaultFont.lineSpacing) : ''}
8255
8432
  options={[
8256
- { value: '', label: `${DEFAULT_LINE_SPACING} (standard)` },
8433
+ { value: '', label: `${DEFAULT_LINE_SPACING} (standard)`, closed: String(DEFAULT_LINE_SPACING) },
8257
8434
  ...LINE_SPACINGS.filter(spacing => spacing !== DEFAULT_LINE_SPACING).map(spacing => ({ value: String(spacing), label: String(spacing) })),
8258
8435
  ]}
8259
8436
  onChange={raw => {