@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/api/mail/emails/route.ts +3 -2
- package/app/api/mail/inbox/route.ts +1 -1
- package/app/mail/MailSelect.tsx +21 -13
- package/app/mail/RichEditor.tsx +11 -13
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +106 -12
- package/app/mail/page.tsx +259 -82
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +4 -0
- package/package.json +1 -2
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
- package/tools/import-mbox.py +36 -11
- package/app/api/mail/upload/route.ts +0 -43
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
2744
|
-
return matchesQuery(
|
|
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
|
-
[
|
|
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:
|
|
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:
|
|
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)
|
|
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
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
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
|
|
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
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
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}>{
|
|
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
|
-
<
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
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={
|
|
6885
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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={
|
|
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=
|
|
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 => {
|