@novacraft-engineering/mailbox 0.2.1 → 0.4.2
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 +7 -5
- package/app/api/mail/inbox/counts/route.ts +6 -5
- package/app/api/mail/inbox/route.ts +6 -5
- package/app/api/mail/mailboxes/route.ts +27 -0
- package/app/api/mail/threads/route.ts +2 -1
- package/app/mail/MailSelect.tsx +11 -2
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +123 -1
- package/app/mail/page.tsx +361 -88
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +34 -14
- package/lib/scope.ts +34 -0
- package/package.json +6 -7
- 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,49 @@ 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
13
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
/** Which product an address belongs to, so the picker can wear its mark. */
|
|
16
|
+
function productOf(address: string): 'vela' | 'hosting' | 'person' {
|
|
17
|
+
const local = address.split('@')[0].toLowerCase()
|
|
18
|
+
if (local === 'vela') return 'vela'
|
|
19
|
+
if (local === 'hosting') return 'hosting'
|
|
20
|
+
return 'person'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAILBOX_GLYPH: Record<'all' | 'vela' | 'hosting' | 'person', React.ReactNode> = {
|
|
24
|
+
all: (
|
|
25
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
26
|
+
<rect x="2.5" y="4.5" width="15" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
|
27
|
+
<path d="M3 6l7 5 7-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
28
|
+
</svg>
|
|
29
|
+
),
|
|
30
|
+
vela: (
|
|
31
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
32
|
+
<path d="M3.4 16h13.2L10 3.2z" fill="#2FB574" fillOpacity="0.55" />
|
|
33
|
+
<path d="M10 3.2V16" stroke="#6FD3A2" strokeWidth="1.6" />
|
|
34
|
+
</svg>
|
|
35
|
+
),
|
|
36
|
+
hosting: (
|
|
37
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
38
|
+
<path d="M10 2.6l6.4 3.7v7.4L10 17.4 3.6 13.7V6.3z" fill="#8B4FF5" fillOpacity="0.45" stroke="#A578FF" strokeWidth="1.4" strokeLinejoin="round" />
|
|
39
|
+
</svg>
|
|
40
|
+
),
|
|
41
|
+
person: (
|
|
42
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
43
|
+
<circle cx="10" cy="7" r="3.1" stroke="currentColor" strokeWidth="1.5" />
|
|
44
|
+
<path d="M4.2 16.4c.8-2.7 3-4.2 5.8-4.2s5 1.5 5.8 4.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
45
|
+
</svg>
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
|
|
49
|
+
import { splitQuotedTail, splitQuotedText } from './quoted'
|
|
15
50
|
import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
|
|
16
51
|
import { defaultSignature, fillSignature } from '@/lib/default-signature'
|
|
17
52
|
import styles from './page.module.css'
|
|
@@ -129,6 +164,7 @@ type SentEmail = {
|
|
|
129
164
|
createdAt: string
|
|
130
165
|
scheduledAt: string | null
|
|
131
166
|
lastEvent: string
|
|
167
|
+
attachmentCount?: number
|
|
132
168
|
starred: boolean
|
|
133
169
|
archived: boolean
|
|
134
170
|
trashed: boolean
|
|
@@ -303,6 +339,9 @@ type MailSettings = {
|
|
|
303
339
|
mobile: string
|
|
304
340
|
replyAllDefault: boolean
|
|
305
341
|
replyStyle: 'panel' | 'mini'
|
|
342
|
+
/** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
|
|
343
|
+
threadMessages?: 'collapsible' | 'open'
|
|
344
|
+
ticker?: TickerSettings
|
|
306
345
|
notifyEmail: string
|
|
307
346
|
fonts: CustomFont[]
|
|
308
347
|
defaultFont: BaseFont
|
|
@@ -1006,6 +1045,24 @@ function formatHtmlSource(html: string): string {
|
|
|
1006
1045
|
return out.join('\n')
|
|
1007
1046
|
}
|
|
1008
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* When a message in a conversation arrived, said outright rather than as "3h".
|
|
1050
|
+
* A relative age suits a list being scanned; a thread being read is a record, and a record
|
|
1051
|
+
* wants the day and the hour. The year appears only once it is no longer the current one.
|
|
1052
|
+
*/
|
|
1053
|
+
function formatStamp(iso: string): string {
|
|
1054
|
+
const date = new Date(iso)
|
|
1055
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
1056
|
+
const sameYear = date.getFullYear() === new Date().getFullYear()
|
|
1057
|
+
return date.toLocaleString([], {
|
|
1058
|
+
month: 'short',
|
|
1059
|
+
day: 'numeric',
|
|
1060
|
+
...(sameYear ? {} : { year: 'numeric' }),
|
|
1061
|
+
hour: 'numeric',
|
|
1062
|
+
minute: '2-digit',
|
|
1063
|
+
})
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1009
1066
|
function formatRelative(iso: string, now: number): string {
|
|
1010
1067
|
const time = new Date(iso).getTime()
|
|
1011
1068
|
if (Number.isNaN(time)) return ''
|
|
@@ -1363,9 +1420,11 @@ export default function DevMailPage() {
|
|
|
1363
1420
|
const [threadOrder, setThreadOrder] = useState<'newest' | 'oldest'>('newest')
|
|
1364
1421
|
const [threadOpening, setThreadOpening] = useState<string | null>(null)
|
|
1365
1422
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
1366
|
-
const [searchPick, setSearchPick] = useState(
|
|
1423
|
+
const [searchPick, setSearchPick] = useState(-1)
|
|
1367
1424
|
const [threadExpanded, setThreadExpanded] = useState<Set<string>>(new Set())
|
|
1368
1425
|
const [threadEverOpen, setThreadEverOpen] = useState<Set<string>>(new Set())
|
|
1426
|
+
const [quoteOpen, setQuoteOpen] = useState<Set<string>>(new Set())
|
|
1427
|
+
const [msgDetails, setMsgDetails] = useState<Set<string>>(new Set())
|
|
1369
1428
|
const [linkVerdicts, setLinkVerdicts] = useState<Record<string, { verdict: LinkVerdict; reasons: string[] }>>({})
|
|
1370
1429
|
const scanLinksRef = useRef<(urls: string[]) => void>(() => {})
|
|
1371
1430
|
const [preview, setPreview] = useState<{ items: PreviewItem[]; index: number } | null>(null)
|
|
@@ -1396,6 +1455,8 @@ export default function DevMailPage() {
|
|
|
1396
1455
|
} catch {}
|
|
1397
1456
|
}, [])
|
|
1398
1457
|
const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
|
|
1458
|
+
const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
|
|
1459
|
+
const messagesAlwaysOpen = settings.threadMessages === 'open'
|
|
1399
1460
|
const { canInstall, installed, install } = useInstall()
|
|
1400
1461
|
const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
|
|
1401
1462
|
settings.desktopNotifications,
|
|
@@ -1556,6 +1617,40 @@ export default function DevMailPage() {
|
|
|
1556
1617
|
}),
|
|
1557
1618
|
[email, password],
|
|
1558
1619
|
)
|
|
1620
|
+
const [mailboxes, setMailboxes] = useState<{ address: string; name: string }[]>([])
|
|
1621
|
+
const [mailboxAll, setMailboxAll] = useState(false)
|
|
1622
|
+
|
|
1623
|
+
useEffect(() => {
|
|
1624
|
+
if (!isLoggedIn) return
|
|
1625
|
+
let live = true
|
|
1626
|
+
fetch('/api/mail/mailboxes', { headers: apiHeaders() })
|
|
1627
|
+
.then(response => response.json())
|
|
1628
|
+
.then(data => {
|
|
1629
|
+
if (!live || !data?.ok) return
|
|
1630
|
+
setMailboxAll(Boolean(data.all))
|
|
1631
|
+
setMailboxes(Array.isArray(data.mailboxes) ? data.mailboxes : [])
|
|
1632
|
+
})
|
|
1633
|
+
.catch(() => {})
|
|
1634
|
+
return () => {
|
|
1635
|
+
live = false
|
|
1636
|
+
}
|
|
1637
|
+
}, [isLoggedIn, apiHeaders])
|
|
1638
|
+
|
|
1639
|
+
const mailboxQuery = mailboxAll && mailbox !== 'all' ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
|
|
1640
|
+
|
|
1641
|
+
const mailboxOptions = useMemo(
|
|
1642
|
+
() => [
|
|
1643
|
+
{ value: 'all', label: 'All mail', hint: 'every mailbox', icon: MAILBOX_GLYPH.all },
|
|
1644
|
+
...mailboxes.map(entry => ({
|
|
1645
|
+
value: entry.address,
|
|
1646
|
+
label: entry.name,
|
|
1647
|
+
hint: entry.address,
|
|
1648
|
+
icon: MAILBOX_GLYPH[productOf(entry.address)],
|
|
1649
|
+
})),
|
|
1650
|
+
],
|
|
1651
|
+
[mailboxes],
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1559
1654
|
const [threads, setThreads] = useState<ConversationRow[]>([])
|
|
1560
1655
|
const [threadsResolved, setThreadsResolved] = useState(false)
|
|
1561
1656
|
const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
|
|
@@ -1563,17 +1658,31 @@ export default function DevMailPage() {
|
|
|
1563
1658
|
const threadsLoadedFolder = useRef<string | null>(null)
|
|
1564
1659
|
const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
|
|
1565
1660
|
const THREAD_PAGE = 500
|
|
1661
|
+
// The key carries the mailbox as well as the folder: switching tabs has to refetch, and
|
|
1662
|
+
// the guard would otherwise treat the other mailbox's list as already loaded.
|
|
1663
|
+
const threadKey = `${mailboxQuery}|${threadFolder}`
|
|
1664
|
+
|
|
1665
|
+
useEffect(() => {
|
|
1666
|
+
setThreads([])
|
|
1667
|
+
setThreadsCursor(null)
|
|
1668
|
+
setThreadsResolved(false)
|
|
1669
|
+
threadsFetch.current = null
|
|
1670
|
+
threadsLoadedFolder.current = null
|
|
1671
|
+
}, [mailboxQuery])
|
|
1566
1672
|
const loadThreads = useCallback(async () => {
|
|
1567
1673
|
if (!isLoggedIn) return
|
|
1568
|
-
if (threadsFetch.current ===
|
|
1569
|
-
threadsFetch.current =
|
|
1674
|
+
if (threadsFetch.current === threadKey) return
|
|
1675
|
+
threadsFetch.current = threadKey
|
|
1570
1676
|
try {
|
|
1571
|
-
const
|
|
1677
|
+
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1678
|
+
params.set('folder', threadFolder)
|
|
1679
|
+
params.set('limit', String(THREAD_PAGE))
|
|
1680
|
+
const response = await fetch(`/api/mail/threads?${params.toString()}`, { headers: apiHeaders() })
|
|
1572
1681
|
const data = await response.json().catch(() => null)
|
|
1573
1682
|
if (data?.ok && Array.isArray(data.threads)) {
|
|
1574
1683
|
const fresh = data.threads as ConversationRow[]
|
|
1575
|
-
const replace = threadsLoadedFolder.current !==
|
|
1576
|
-
threadsLoadedFolder.current =
|
|
1684
|
+
const replace = threadsLoadedFolder.current !== threadKey
|
|
1685
|
+
threadsLoadedFolder.current = threadKey
|
|
1577
1686
|
setThreads(current => {
|
|
1578
1687
|
if (replace || current.length === 0) return fresh
|
|
1579
1688
|
const freshIds = new Set(fresh.map(entry => entry.threadId))
|
|
@@ -1583,10 +1692,10 @@ export default function DevMailPage() {
|
|
|
1583
1692
|
}
|
|
1584
1693
|
} catch {
|
|
1585
1694
|
} finally {
|
|
1586
|
-
if (threadsFetch.current ===
|
|
1695
|
+
if (threadsFetch.current === threadKey) threadsFetch.current = null
|
|
1587
1696
|
setThreadsResolved(true)
|
|
1588
1697
|
}
|
|
1589
|
-
}, [isLoggedIn, threadFolder, apiHeaders])
|
|
1698
|
+
}, [isLoggedIn, threadFolder, threadKey, mailboxQuery, apiHeaders])
|
|
1590
1699
|
|
|
1591
1700
|
const threadPageInFlight = useRef(false)
|
|
1592
1701
|
const loadMoreThreads = useCallback(async () => {
|
|
@@ -1594,7 +1703,11 @@ export default function DevMailPage() {
|
|
|
1594
1703
|
threadPageInFlight.current = true
|
|
1595
1704
|
setLoadingMore(true)
|
|
1596
1705
|
try {
|
|
1597
|
-
const
|
|
1706
|
+
const moreParams = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1707
|
+
moreParams.set('folder', threadFolder)
|
|
1708
|
+
moreParams.set('limit', String(THREAD_PAGE))
|
|
1709
|
+
moreParams.set('cursor', threadsCursor)
|
|
1710
|
+
const response = await fetch(`/api/mail/threads?${moreParams.toString()}`, { headers: apiHeaders() })
|
|
1598
1711
|
const data = await response.json().catch(() => null)
|
|
1599
1712
|
if (!data?.ok || !Array.isArray(data.threads)) return
|
|
1600
1713
|
const fresh = data.threads as ConversationRow[]
|
|
@@ -1608,7 +1721,7 @@ export default function DevMailPage() {
|
|
|
1608
1721
|
threadPageInFlight.current = false
|
|
1609
1722
|
setLoadingMore(false)
|
|
1610
1723
|
}
|
|
1611
|
-
}, [threadsCursor, threadFolder, apiHeaders])
|
|
1724
|
+
}, [threadsCursor, threadFolder, mailboxQuery, apiHeaders])
|
|
1612
1725
|
|
|
1613
1726
|
useEffect(() => {
|
|
1614
1727
|
setThreadsResolved(false)
|
|
@@ -1821,10 +1934,8 @@ export default function DevMailPage() {
|
|
|
1821
1934
|
}
|
|
1822
1935
|
}, [])
|
|
1823
1936
|
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
// Nobody browses another mailbox any more, so mail always goes out as the signed-in
|
|
1827
|
-
// account and there is no one to act as.
|
|
1937
|
+
// Mail always goes out as the signed-in account, so browsing another mailbox never
|
|
1938
|
+
// changes who a reply comes from.
|
|
1828
1939
|
const actingAs: string | null = null
|
|
1829
1940
|
|
|
1830
1941
|
const decorateSent = (data: {
|
|
@@ -1859,8 +1970,16 @@ export default function DevMailPage() {
|
|
|
1859
1970
|
* not looked. Null means "no search", so the folder shows the ordinary list.
|
|
1860
1971
|
*/
|
|
1861
1972
|
const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
|
|
1862
|
-
const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
|
|
1973
|
+
const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; query: string; count: number; more: boolean } | null>(null)
|
|
1863
1974
|
const crossFolderSeq = useRef(0)
|
|
1975
|
+
const crossFolderLabel = crossFolder
|
|
1976
|
+
? `Also ${crossFolder.count.toLocaleString()}${crossFolder.more ? '+' : ''} in ${crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →`
|
|
1977
|
+
: ''
|
|
1978
|
+
const followCrossFolder = () => {
|
|
1979
|
+
if (!crossFolder) return
|
|
1980
|
+
setFolder(crossFolder.folder)
|
|
1981
|
+
setSearch(crossFolder.query)
|
|
1982
|
+
}
|
|
1864
1983
|
useEffect(() => {
|
|
1865
1984
|
const text = search.trim()
|
|
1866
1985
|
const seq = ++crossFolderSeq.current
|
|
@@ -1868,19 +1987,21 @@ export default function DevMailPage() {
|
|
|
1868
1987
|
setCrossFolder(null)
|
|
1869
1988
|
return
|
|
1870
1989
|
}
|
|
1990
|
+
const mirrored = text.replace(/(^|\s)(from|to):/gi, (_, lead: string, field: string) => `${lead}${field.toLowerCase() === 'from' ? 'to' : 'from'}:`)
|
|
1871
1991
|
const timer = window.setTimeout(async () => {
|
|
1872
1992
|
try {
|
|
1873
1993
|
if (folder === 'sent') {
|
|
1874
|
-
const
|
|
1994
|
+
const params = new URLSearchParams({ limit: '1', folder: 'inbox', ...serverSearchParams(mirrored) })
|
|
1995
|
+
const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
|
|
1875
1996
|
const data = await response.json()
|
|
1876
1997
|
if (seq !== crossFolderSeq.current) return
|
|
1877
|
-
setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
|
|
1998
|
+
setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', query: mirrored, count: data.total, more: false } : null)
|
|
1878
1999
|
} else {
|
|
1879
|
-
const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(
|
|
2000
|
+
const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(mirrored)}`, { headers: apiHeaders() })
|
|
1880
2001
|
const data = await response.json()
|
|
1881
2002
|
if (seq !== crossFolderSeq.current) return
|
|
1882
2003
|
const count = Array.isArray(data.emails) ? data.emails.length : 0
|
|
1883
|
-
setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
|
|
2004
|
+
setCrossFolder(data.ok && count > 0 ? { folder: 'sent', query: mirrored, count, more: Boolean(data.truncated) } : null)
|
|
1884
2005
|
}
|
|
1885
2006
|
} catch {
|
|
1886
2007
|
if (seq === crossFolderSeq.current) setCrossFolder(null)
|
|
@@ -1940,8 +2061,7 @@ export default function DevMailPage() {
|
|
|
1940
2061
|
params.set('folder', threadFolder)
|
|
1941
2062
|
// A cursor asks for the page after a known row; without one this is the first page.
|
|
1942
2063
|
if (cursor) params.set('cursor', cursor)
|
|
1943
|
-
const
|
|
1944
|
-
if (text) params.set('q', text)
|
|
2064
|
+
for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
|
|
1945
2065
|
const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
|
|
1946
2066
|
if (!response.ok) throw new Error(describeHttp(response.status))
|
|
1947
2067
|
const data = await response.json().catch(() => null)
|
|
@@ -1986,8 +2106,7 @@ export default function DevMailPage() {
|
|
|
1986
2106
|
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1987
2107
|
params.set('limit', String(INBOX_PAGE))
|
|
1988
2108
|
params.set('folder', threadFolder)
|
|
1989
|
-
const
|
|
1990
|
-
if (text) params.set('q', text)
|
|
2109
|
+
for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
|
|
1991
2110
|
const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
|
|
1992
2111
|
if (!response.ok) throw new Error(describeHttp(response.status))
|
|
1993
2112
|
const data = await response.json().catch(() => null)
|
|
@@ -2609,12 +2728,12 @@ export default function DevMailPage() {
|
|
|
2609
2728
|
.then(response => response.json())
|
|
2610
2729
|
.then(data => {
|
|
2611
2730
|
if (data.ok && data.settings) {
|
|
2612
|
-
const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
|
|
2731
|
+
const stored = data.settings as MailSettings & { density?: string; replyStyle?: string; ticker?: unknown; threadMessages?: string }
|
|
2613
2732
|
// Only the roomy setting is named; anything else — unset, or the retired
|
|
2614
2733
|
// "comfortable" — is the default. Treating every unrecognised value as roomy put
|
|
2615
2734
|
// six of the seven mailboxes into a reduced reader none of them had asked for.
|
|
2616
2735
|
const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
|
|
2617
|
-
setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
|
|
2736
|
+
setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
|
|
2618
2737
|
const prefs = (data.settings as MailSettings).prefs
|
|
2619
2738
|
if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
|
|
2620
2739
|
if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
|
|
@@ -2737,11 +2856,12 @@ export default function DevMailPage() {
|
|
|
2737
2856
|
|
|
2738
2857
|
|
|
2739
2858
|
const searchQuery = useMemo(() => parseQuery(search), [search])
|
|
2859
|
+
const residualQuery = useMemo(() => splitForServer(searchQuery).residual, [searchQuery])
|
|
2740
2860
|
|
|
2741
2861
|
const matchesInbound = useCallback(
|
|
2742
2862
|
(entry: InboundEmail) => {
|
|
2743
|
-
if (
|
|
2744
|
-
return matchesQuery(
|
|
2863
|
+
if (residualQuery.isEmpty) return true
|
|
2864
|
+
return matchesQuery(residualQuery, {
|
|
2745
2865
|
from: entry.from,
|
|
2746
2866
|
to: entry.to.join(' '),
|
|
2747
2867
|
cc: (entry.cc ?? []).join(' '),
|
|
@@ -2758,7 +2878,7 @@ export default function DevMailPage() {
|
|
|
2758
2878
|
folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
|
|
2759
2879
|
})
|
|
2760
2880
|
},
|
|
2761
|
-
[
|
|
2881
|
+
[residualQuery],
|
|
2762
2882
|
)
|
|
2763
2883
|
|
|
2764
2884
|
const matchesSent = useCallback(
|
|
@@ -2776,7 +2896,7 @@ export default function DevMailPage() {
|
|
|
2776
2896
|
size: null,
|
|
2777
2897
|
read: true,
|
|
2778
2898
|
starred: entry.starred,
|
|
2779
|
-
hasAttachment:
|
|
2899
|
+
hasAttachment: (entry.attachmentCount ?? 0) > 0,
|
|
2780
2900
|
labels: [],
|
|
2781
2901
|
folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'sent',
|
|
2782
2902
|
})
|
|
@@ -2977,7 +3097,7 @@ export default function DevMailPage() {
|
|
|
2977
3097
|
: formatRelative(entry.createdAt, now),
|
|
2978
3098
|
unread: false,
|
|
2979
3099
|
starred: entry.starred,
|
|
2980
|
-
hasAttachment:
|
|
3100
|
+
hasAttachment: (entry.attachmentCount ?? 0) > 0,
|
|
2981
3101
|
chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
|
|
2982
3102
|
threadCount: 1,
|
|
2983
3103
|
latestAt: new Date(entry.createdAt).getTime(),
|
|
@@ -5204,7 +5324,7 @@ export default function DevMailPage() {
|
|
|
5204
5324
|
)}
|
|
5205
5325
|
</button>
|
|
5206
5326
|
<div className={styles.attachInfo}>
|
|
5207
|
-
<Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
|
|
5327
|
+
<Ticker className={styles.attachName} title={attachment.filename} enabled={tickerOn('attachName')}>{attachment.filename}</Ticker>
|
|
5208
5328
|
<span className={styles.attachMeta}>
|
|
5209
5329
|
{attachment.size ? formatSize(attachment.size) : null}
|
|
5210
5330
|
{attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
|
|
@@ -5337,7 +5457,7 @@ export default function DevMailPage() {
|
|
|
5337
5457
|
<span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
|
|
5338
5458
|
</span>
|
|
5339
5459
|
)}
|
|
5340
|
-
<Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
|
|
5460
|
+
<Ticker className={styles.attachChipName} title={attachment.filename} enabled={tickerOn('attachChip')}>{attachment.filename}</Ticker>
|
|
5341
5461
|
<span className={styles.attachChipSize}>{state}</span>
|
|
5342
5462
|
{attachment.error && attachment.file && (
|
|
5343
5463
|
<button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
|
|
@@ -5415,6 +5535,26 @@ export default function DevMailPage() {
|
|
|
5415
5535
|
return list.length ? renderAttachmentTiles(list) : null
|
|
5416
5536
|
}
|
|
5417
5537
|
|
|
5538
|
+
const toggleQuote = (id: string) =>
|
|
5539
|
+
setQuoteOpen(current => {
|
|
5540
|
+
const next = new Set(current)
|
|
5541
|
+
if (next.has(id)) next.delete(id)
|
|
5542
|
+
else next.add(id)
|
|
5543
|
+
return next
|
|
5544
|
+
})
|
|
5545
|
+
|
|
5546
|
+
/** The line offering the repeated thread underneath a reply. */
|
|
5547
|
+
const renderQuoteLine = (id: string, shown: boolean) => (
|
|
5548
|
+
<button type="button" className={styles.quoteLine} aria-expanded={shown} onClick={() => toggleQuote(id)}>
|
|
5549
|
+
<span className={styles.quoteLineRule} aria-hidden />
|
|
5550
|
+
<span className={styles.quoteLineLabel}>
|
|
5551
|
+
{ICONS.chevron}
|
|
5552
|
+
{shown ? 'Hide earlier messages' : 'Show earlier messages'}
|
|
5553
|
+
</span>
|
|
5554
|
+
<span className={styles.quoteLineRule} aria-hidden />
|
|
5555
|
+
</button>
|
|
5556
|
+
)
|
|
5557
|
+
|
|
5418
5558
|
const renderInboundBody = (message: InboundEmail, framed: boolean) => {
|
|
5419
5559
|
if (readerMode === 'plain') {
|
|
5420
5560
|
return <pre className={styles.readerText}>{message.text?.trim() || htmlToSnippetText(message.html, null) || '(no text part)'}</pre>
|
|
@@ -5422,26 +5562,46 @@ export default function DevMailPage() {
|
|
|
5422
5562
|
if (readerMode === 'html') return <pre className={styles.readerSource}>{formatHtmlSource(message.html ?? '')}</pre>
|
|
5423
5563
|
if (readerMode === 'raw') return <pre className={styles.readerSource}>{rawInboundMessage(message)}</pre>
|
|
5424
5564
|
// preview
|
|
5425
|
-
if (!message.html)
|
|
5565
|
+
if (!message.html) {
|
|
5566
|
+
const plain = splitQuotedText(message.text ?? '')
|
|
5567
|
+
const textShown = quoteOpen.has(message.id)
|
|
5568
|
+
return (
|
|
5569
|
+
<>
|
|
5570
|
+
<pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
|
|
5571
|
+
{plain.tail && renderQuoteLine(message.id, textShown)}
|
|
5572
|
+
</>
|
|
5573
|
+
)
|
|
5574
|
+
}
|
|
5575
|
+
const split = splitQuotedTail(message.html)
|
|
5576
|
+
const quoteShown = quoteOpen.has(message.id)
|
|
5577
|
+
const bodyHtml = split.tail && !quoteShown ? split.head : message.html
|
|
5426
5578
|
if (framed) {
|
|
5427
5579
|
// Thread messages auto-size to content (whole conversation scrolls as one).
|
|
5428
5580
|
// sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
|
|
5429
5581
|
return (
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5582
|
+
<>
|
|
5583
|
+
<iframe
|
|
5584
|
+
className={styles.threadFrame}
|
|
5585
|
+
sandbox="allow-same-origin"
|
|
5586
|
+
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
|
|
5587
|
+
title="Email content"
|
|
5588
|
+
onLoad={event => {
|
|
5589
|
+
try {
|
|
5590
|
+
const doc = event.currentTarget.contentDocument
|
|
5591
|
+
if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
|
|
5592
|
+
} catch {}
|
|
5593
|
+
}}
|
|
5594
|
+
/>
|
|
5595
|
+
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
5596
|
+
</>
|
|
5442
5597
|
)
|
|
5443
5598
|
}
|
|
5444
|
-
return
|
|
5599
|
+
return (
|
|
5600
|
+
<>
|
|
5601
|
+
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5602
|
+
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
5603
|
+
</>
|
|
5604
|
+
)
|
|
5445
5605
|
}
|
|
5446
5606
|
|
|
5447
5607
|
// One message open at a time — opening another collapses the rest.
|
|
@@ -5592,11 +5752,23 @@ export default function DevMailPage() {
|
|
|
5592
5752
|
mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
|
|
5593
5753
|
).slice(0, 100)
|
|
5594
5754
|
const unread = !mine && !item.inbound.read
|
|
5755
|
+
// Who each reply actually went to. A thread quietly gains and loses people in Cc, and
|
|
5756
|
+
// that is the change most worth noticing partway down a conversation.
|
|
5757
|
+
const recips = mine
|
|
5758
|
+
? { to: item.sent.to, cc: detail?.cc ?? [], bcc: detail?.bcc ?? [] }
|
|
5759
|
+
: { to: item.inbound.to, cc: item.inbound.cc ?? [], bcc: item.inbound.bcc ?? [] }
|
|
5760
|
+
const recipsLine = [
|
|
5761
|
+
`to ${recips.to.join(', ') || 'you'}`,
|
|
5762
|
+
recips.cc.length ? `cc ${recips.cc.join(', ')}` : '',
|
|
5763
|
+
recips.bcc.length ? `bcc ${recips.bcc.join(', ')}` : '',
|
|
5764
|
+
].filter(Boolean).join(' · ')
|
|
5765
|
+
const detailsOpen = msgDetails.has(item.id)
|
|
5595
5766
|
const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
|
|
5767
|
+
const folds = !messagesAlwaysOpen || layout === 'bubbles'
|
|
5596
5768
|
const shellClass =
|
|
5597
5769
|
layout === 'bubbles'
|
|
5598
5770
|
? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
|
|
5599
|
-
: `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''}`
|
|
5771
|
+
: `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''} ${folds ? '' : styles.threadMsgStatic}`
|
|
5600
5772
|
// Body stays mounted once opened so collapsing animates too, instead of snapping shut.
|
|
5601
5773
|
const toggleOpen = () => {
|
|
5602
5774
|
setThreadEverOpen(current => (current.has(item.id) ? current : new Set(current).add(item.id)))
|
|
@@ -5609,24 +5781,50 @@ export default function DevMailPage() {
|
|
|
5609
5781
|
the attachment mark, and a button cannot hold another button. */}
|
|
5610
5782
|
<div
|
|
5611
5783
|
className={styles.threadMsgHead}
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5784
|
+
{...(folds
|
|
5785
|
+
? {
|
|
5786
|
+
role: 'button' as const,
|
|
5787
|
+
tabIndex: 0,
|
|
5788
|
+
'aria-expanded': open,
|
|
5789
|
+
onClick: toggleOpen,
|
|
5790
|
+
onKeyDown: (event: React.KeyboardEvent) => {
|
|
5791
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
5792
|
+
event.preventDefault()
|
|
5793
|
+
toggleOpen()
|
|
5794
|
+
}
|
|
5795
|
+
},
|
|
5796
|
+
}
|
|
5797
|
+
: {})}
|
|
5622
5798
|
>
|
|
5623
5799
|
<span className={styles.avatar}>{initial}</span>
|
|
5624
5800
|
<span className={styles.threadMsgMeta}>
|
|
5625
5801
|
<span className={styles.threadMsgFrom}>
|
|
5626
5802
|
{unread && <span className={styles.unreadDot} />}
|
|
5627
|
-
<Ticker className={styles.threadMsgFromText}>{from}</Ticker>
|
|
5803
|
+
<Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
|
|
5804
|
+
{folds && !open && <span className={styles.threadMsgHint}>(click to open)</span>}
|
|
5628
5805
|
</span>
|
|
5629
|
-
{!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
|
|
5806
|
+
{!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
|
|
5807
|
+
{open && layout !== 'bubbles' && (
|
|
5808
|
+
<span className={styles.threadMsgRecips}>
|
|
5809
|
+
<Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>{recipsLine}</Ticker>
|
|
5810
|
+
<button
|
|
5811
|
+
type="button"
|
|
5812
|
+
className={styles.threadMsgDetails}
|
|
5813
|
+
aria-expanded={detailsOpen}
|
|
5814
|
+
onClick={event => {
|
|
5815
|
+
event.stopPropagation()
|
|
5816
|
+
setMsgDetails(current => {
|
|
5817
|
+
const next = new Set(current)
|
|
5818
|
+
if (next.has(item.id)) next.delete(item.id)
|
|
5819
|
+
else next.add(item.id)
|
|
5820
|
+
return next
|
|
5821
|
+
})
|
|
5822
|
+
}}
|
|
5823
|
+
>
|
|
5824
|
+
{detailsOpen ? 'Hide details' : 'Details'}
|
|
5825
|
+
</button>
|
|
5826
|
+
</span>
|
|
5827
|
+
)}
|
|
5630
5828
|
</span>
|
|
5631
5829
|
{hasAttach && (
|
|
5632
5830
|
<span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
|
|
@@ -5667,11 +5865,20 @@ export default function DevMailPage() {
|
|
|
5667
5865
|
{ICONS.print}
|
|
5668
5866
|
</button>
|
|
5669
5867
|
)}
|
|
5670
|
-
<span className={styles.threadMsgDate}>{
|
|
5671
|
-
<span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>
|
|
5868
|
+
<span className={styles.threadMsgDate} title={new Date(when).toLocaleString()}>{formatStamp(when)}</span>
|
|
5869
|
+
{folds && <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>}
|
|
5672
5870
|
</div>
|
|
5673
5871
|
{(open || threadEverOpen.has(item.id)) && (
|
|
5674
5872
|
<div className={`${styles.threadMsgWrap} ${open ? styles.threadMsgWrapOpen : ''}`}>
|
|
5873
|
+
{open && detailsOpen && layout !== 'bubbles' && (
|
|
5874
|
+
<dl className={styles.fullHeaders}>
|
|
5875
|
+
<div><dt>From</dt><dd>{mine ? 'You' : item.inbound.from}</dd></div>
|
|
5876
|
+
<div><dt>To</dt><dd>{recips.to.join(', ') || '—'}</dd></div>
|
|
5877
|
+
{recips.cc.length > 0 && <div><dt>Cc</dt><dd>{recips.cc.join(', ')}</dd></div>}
|
|
5878
|
+
{recips.bcc.length > 0 && <div><dt>Bcc</dt><dd>{recips.bcc.join(', ')}</dd></div>}
|
|
5879
|
+
<div><dt>Date</dt><dd>{new Date(when).toLocaleString()}</dd></div>
|
|
5880
|
+
</dl>
|
|
5881
|
+
)}
|
|
5675
5882
|
<div className={styles.threadMsgBody}>
|
|
5676
5883
|
{layout === 'bubbles' ? (
|
|
5677
5884
|
mine && !detail ? (
|
|
@@ -5748,16 +5955,21 @@ export default function DevMailPage() {
|
|
|
5748
5955
|
<div className={styles.readerMeta}>
|
|
5749
5956
|
<div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
|
|
5750
5957
|
<div className={styles.readerMetaText}>
|
|
5751
|
-
<Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{inbound.from}</Ticker>
|
|
5752
|
-
<
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5958
|
+
<Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{inbound.from}</Ticker>
|
|
5959
|
+
<span className={styles.threadMsgRecips}>
|
|
5960
|
+
<Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>
|
|
5961
|
+
{[
|
|
5962
|
+
`to ${inbound.to.join(', ') || 'you'}`,
|
|
5963
|
+
inbound.cc.length ? `cc ${inbound.cc.join(', ')}` : '',
|
|
5964
|
+
inbound.bcc.length ? `bcc ${inbound.bcc.join(', ')}` : '',
|
|
5965
|
+
].filter(Boolean).join(' · ')}
|
|
5966
|
+
</Ticker>
|
|
5967
|
+
<button className={styles.threadMsgDetails} aria-expanded={showFullHeaders} onClick={() => setShowFullHeaders(open => !open)}>
|
|
5968
|
+
{showFullHeaders ? 'Hide details' : 'Details'}
|
|
5969
|
+
</button>
|
|
5970
|
+
</span>
|
|
5760
5971
|
</div>
|
|
5972
|
+
<div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
|
|
5761
5973
|
</div>
|
|
5762
5974
|
{showFullHeaders && (
|
|
5763
5975
|
<dl className={styles.fullHeaders}>
|
|
@@ -5968,7 +6180,7 @@ export default function DevMailPage() {
|
|
|
5968
6180
|
<div className={readerLayout === 'bubbles' ? styles.bubbles : styles.thread}>
|
|
5969
6181
|
{/* Bubbles stay chronological — a chat reads oldest to newest. */}
|
|
5970
6182
|
{(readerLayout === 'bubbles' || threadOrder === 'oldest' ? unified : [...unified].reverse()).map(item =>
|
|
5971
|
-
renderThreadMessage(item, threadExpanded.has(item.id), readerLayout),
|
|
6183
|
+
renderThreadMessage(item, messagesAlwaysOpen || threadExpanded.has(item.id), readerLayout),
|
|
5972
6184
|
)}
|
|
5973
6185
|
</div>
|
|
5974
6186
|
) : (
|
|
@@ -6114,7 +6326,7 @@ export default function DevMailPage() {
|
|
|
6114
6326
|
title="Recipients — Cc, Bcc"
|
|
6115
6327
|
>
|
|
6116
6328
|
<span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
|
|
6117
|
-
<Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
|
|
6329
|
+
<Ticker className={styles.replyRecipsSummary} enabled={tickerOn('replyRecipients')}>{recipSummary}</Ticker>
|
|
6118
6330
|
</button>
|
|
6119
6331
|
</div>
|
|
6120
6332
|
{replyRecipsOpen && (
|
|
@@ -6333,8 +6545,8 @@ export default function DevMailPage() {
|
|
|
6333
6545
|
<div className={styles.readerMeta}>
|
|
6334
6546
|
<div className={styles.avatar}>N</div>
|
|
6335
6547
|
<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>
|
|
6548
|
+
<Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
|
|
6549
|
+
<Ticker className={`${styles.readerTo} ${styles.tickerBlock}`} enabled={tickerOn('readerTo')}>to {selectedSent.to.join(', ')}</Ticker>
|
|
6338
6550
|
</div>
|
|
6339
6551
|
<div className={styles.readerDate}>
|
|
6340
6552
|
{isScheduled && selectedSent.scheduledAt
|
|
@@ -6880,9 +7092,20 @@ export default function DevMailPage() {
|
|
|
6880
7092
|
{ICONS.refresh}
|
|
6881
7093
|
</button>
|
|
6882
7094
|
</div>
|
|
6883
|
-
{
|
|
6884
|
-
<
|
|
6885
|
-
|
|
7095
|
+
{mailboxAll && mailboxes.length > 1 && (
|
|
7096
|
+
<div className={styles.mailboxPick}>
|
|
7097
|
+
<MailSelect
|
|
7098
|
+
value={mailbox}
|
|
7099
|
+
options={mailboxOptions}
|
|
7100
|
+
ariaLabel="Mailbox"
|
|
7101
|
+
buttonClassName={styles.mailboxPickBtn}
|
|
7102
|
+
onChange={value => { setMailbox(value); setSelectedId(null) }}
|
|
7103
|
+
/>
|
|
7104
|
+
</div>
|
|
7105
|
+
)}
|
|
7106
|
+
{crossFolder && listItems.length > 0 && (
|
|
7107
|
+
<button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
|
|
7108
|
+
{crossFolderLabel}
|
|
6886
7109
|
</button>
|
|
6887
7110
|
)}
|
|
6888
7111
|
<div className={styles.search}>
|
|
@@ -6893,7 +7116,7 @@ export default function DevMailPage() {
|
|
|
6893
7116
|
onChange={event => {
|
|
6894
7117
|
setSearch(event.target.value)
|
|
6895
7118
|
setSearchOpen(true)
|
|
6896
|
-
setSearchPick(
|
|
7119
|
+
setSearchPick(-1)
|
|
6897
7120
|
}}
|
|
6898
7121
|
onFocus={() => setSearchOpen(true)}
|
|
6899
7122
|
onBlur={() => window.setTimeout(() => setSearchOpen(false), 120)}
|
|
@@ -6904,11 +7127,12 @@ export default function DevMailPage() {
|
|
|
6904
7127
|
setSearchPick(current => (current + 1) % searchSuggestions.length)
|
|
6905
7128
|
} else if (event.key === 'ArrowUp') {
|
|
6906
7129
|
event.preventDefault()
|
|
6907
|
-
setSearchPick(current => (current
|
|
7130
|
+
setSearchPick(current => (current <= 0 ? searchSuggestions.length : current) - 1)
|
|
6908
7131
|
} else if (event.key === 'Enter') {
|
|
6909
7132
|
event.preventDefault()
|
|
6910
|
-
setSearch(searchSuggestions[searchPick].apply)
|
|
6911
|
-
setSearchPick(
|
|
7133
|
+
if (searchPick >= 0) setSearch(searchSuggestions[searchPick].apply)
|
|
7134
|
+
setSearchPick(-1)
|
|
7135
|
+
setSearchOpen(false)
|
|
6912
7136
|
} else if (event.key === 'Escape') {
|
|
6913
7137
|
setSearchOpen(false)
|
|
6914
7138
|
}
|
|
@@ -6920,7 +7144,7 @@ export default function DevMailPage() {
|
|
|
6920
7144
|
</button>
|
|
6921
7145
|
)}
|
|
6922
7146
|
{searchOpen && searchSuggestions.length > 0 && (
|
|
6923
|
-
<div className={styles.searchSuggest} role="listbox">
|
|
7147
|
+
<div className={styles.searchSuggest} role="listbox" onMouseLeave={() => setSearchPick(-1)}>
|
|
6924
7148
|
{searchSuggestions.map((suggestion, index) => (
|
|
6925
7149
|
<button
|
|
6926
7150
|
key={suggestion.apply}
|
|
@@ -6932,7 +7156,7 @@ export default function DevMailPage() {
|
|
|
6932
7156
|
onMouseDown={event => {
|
|
6933
7157
|
event.preventDefault()
|
|
6934
7158
|
setSearch(suggestion.apply)
|
|
6935
|
-
setSearchPick(
|
|
7159
|
+
setSearchPick(-1)
|
|
6936
7160
|
}}
|
|
6937
7161
|
>
|
|
6938
7162
|
<span className={styles.searchOptionLabel}>{suggestion.label}</span>
|
|
@@ -7085,6 +7309,17 @@ export default function DevMailPage() {
|
|
|
7085
7309
|
Try again
|
|
7086
7310
|
</button>
|
|
7087
7311
|
</div>
|
|
7312
|
+
) : listItems.length === 0 && search.trim() ? (
|
|
7313
|
+
<div className={styles.emptyList}>
|
|
7314
|
+
<span className={styles.emptyHex} />
|
|
7315
|
+
<p className={styles.emptyTitle}>Nothing matches here</p>
|
|
7316
|
+
<p className={styles.emptySub}>No mail in {folderTitles[folder]} matches “{search.trim()}”.</p>
|
|
7317
|
+
{crossFolder && (
|
|
7318
|
+
<button type="button" className={`${styles.crossFolderHint} ${styles.emptyNudge}`} onClick={followCrossFolder}>
|
|
7319
|
+
{crossFolderLabel}
|
|
7320
|
+
</button>
|
|
7321
|
+
)}
|
|
7322
|
+
</div>
|
|
7088
7323
|
) : listItems.length === 0 ? (
|
|
7089
7324
|
<div className={styles.emptyList}>
|
|
7090
7325
|
<span className={styles.emptyHex} />
|
|
@@ -7181,15 +7416,15 @@ export default function DevMailPage() {
|
|
|
7181
7416
|
</span>
|
|
7182
7417
|
<span className={styles.itemBody}>
|
|
7183
7418
|
<span className={styles.itemTop}>
|
|
7184
|
-
<Ticker className={styles.itemFrom}>{item.primary}</Ticker>
|
|
7419
|
+
<Ticker className={styles.itemFrom} enabled={tickerOn('listFrom')}>{item.primary}</Ticker>
|
|
7185
7420
|
{item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
|
|
7186
7421
|
<span className={styles.itemTime}>{item.time}</span>
|
|
7187
7422
|
</span>
|
|
7188
7423
|
<p className={styles.itemSubject}>
|
|
7189
7424
|
{item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
|
|
7190
|
-
<Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
|
|
7425
|
+
<Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
|
|
7191
7426
|
</p>
|
|
7192
|
-
{item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
|
|
7427
|
+
{item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
|
|
7193
7428
|
{item.labels.length > 0 && (
|
|
7194
7429
|
<span className={styles.itemLabels}>
|
|
7195
7430
|
{item.labels.map(labelId => {
|
|
@@ -7940,6 +8175,44 @@ export default function DevMailPage() {
|
|
|
7940
8175
|
))}
|
|
7941
8176
|
</div>
|
|
7942
8177
|
</div>
|
|
8178
|
+
<div className={styles.settingsField}>
|
|
8179
|
+
<span>Conversation messages</span>
|
|
8180
|
+
<div className={styles.themeRow}>
|
|
8181
|
+
{([
|
|
8182
|
+
['collapsible', 'Collapsible'],
|
|
8183
|
+
['open', 'Always open'],
|
|
8184
|
+
] as Array<[NonNullable<MailSettings['threadMessages']>, string]>).map(([value, label]) => (
|
|
8185
|
+
<button
|
|
8186
|
+
key={value}
|
|
8187
|
+
type="button"
|
|
8188
|
+
className={`${styles.themeChip} ${(settings.threadMessages ?? 'collapsible') === value ? styles.themeChipOn : ''}`}
|
|
8189
|
+
onClick={() => setMailSettings(current => ({ ...current, threadMessages: value }))}
|
|
8190
|
+
>
|
|
8191
|
+
{label}
|
|
8192
|
+
</button>
|
|
8193
|
+
))}
|
|
8194
|
+
</div>
|
|
8195
|
+
<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>
|
|
8196
|
+
</div>
|
|
8197
|
+
<div className={styles.settingsField}>
|
|
8198
|
+
<span>Scrolling text</span>
|
|
8199
|
+
<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>
|
|
8200
|
+
{TICKER_SPOTS.map(group => (
|
|
8201
|
+
<div key={group.group} className={styles.tickerGroup}>
|
|
8202
|
+
<span className={styles.tickerGroupName}>{group.group}</span>
|
|
8203
|
+
{group.spots.map(([spot, label]) => (
|
|
8204
|
+
<label key={spot} className={styles.settingsToggle}>
|
|
8205
|
+
<input
|
|
8206
|
+
type="checkbox"
|
|
8207
|
+
checked={tickerOn(spot)}
|
|
8208
|
+
onChange={event => setMailSettings(current => ({ ...current, ticker: { ...current.ticker, [spot]: event.target.checked } }))}
|
|
8209
|
+
/>
|
|
8210
|
+
<span>{label}</span>
|
|
8211
|
+
</label>
|
|
8212
|
+
))}
|
|
8213
|
+
</div>
|
|
8214
|
+
))}
|
|
8215
|
+
</div>
|
|
7943
8216
|
</>)}
|
|
7944
8217
|
|
|
7945
8218
|
{settingsTab === 'profile' && (<>
|