@novacraft-engineering/mailbox 0.1.0 → 0.2.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/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/app/mail/page.tsx
CHANGED
|
@@ -8,9 +8,10 @@ import { parseQuery, matchesQuery } from './search'
|
|
|
8
8
|
import { useConfirm } from './ConfirmDialog'
|
|
9
9
|
import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
|
|
10
10
|
import RichEditor from './RichEditor'
|
|
11
|
-
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages } from '@/lib/email-html'
|
|
12
|
-
import { BUILTIN_FONTS, EMPTY_FONT, FONT_SIZES, fontFaceCss, fontStack, type BaseFont, type CustomFont } from '@/lib/fonts'
|
|
11
|
+
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
|
|
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 from './MailSelect'
|
|
14
|
+
import Ticker from './Ticker'
|
|
14
15
|
import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
|
|
15
16
|
import { defaultSignature, fillSignature } from '@/lib/default-signature'
|
|
16
17
|
import styles from './page.module.css'
|
|
@@ -115,7 +116,7 @@ type Accessor = {
|
|
|
115
116
|
invitedBy: string | null
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
type Folder = 'inbox' | 'starred' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
|
|
119
|
+
type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
|
|
119
120
|
|
|
120
121
|
type SentEmail = {
|
|
121
122
|
id: string
|
|
@@ -138,7 +139,7 @@ type SentEmail = {
|
|
|
138
139
|
inReplyTo?: string | null
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string }
|
|
142
|
+
type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string; contentId?: string }
|
|
142
143
|
|
|
143
144
|
type SentDetail = SentEmail & {
|
|
144
145
|
cc: string[]
|
|
@@ -151,6 +152,7 @@ type SentDetail = SentEmail & {
|
|
|
151
152
|
|
|
152
153
|
type ConversationRow = {
|
|
153
154
|
threadId: string
|
|
155
|
+
snoozedUntil?: string | null
|
|
154
156
|
subject: string
|
|
155
157
|
firstAt: string
|
|
156
158
|
latestAt: string
|
|
@@ -169,6 +171,7 @@ type ConversationRow = {
|
|
|
169
171
|
|
|
170
172
|
type InboundEmail = {
|
|
171
173
|
id: string
|
|
174
|
+
snoozedUntil?: string | null
|
|
172
175
|
from: string
|
|
173
176
|
to: string[]
|
|
174
177
|
cc: string[]
|
|
@@ -180,7 +183,7 @@ type InboundEmail = {
|
|
|
180
183
|
headers: Record<string, unknown>
|
|
181
184
|
receivedAt: string
|
|
182
185
|
read: boolean
|
|
183
|
-
attachments: Array<{ filename: string; contentType?: string; size?: number; shareId?: string; url?: string }>
|
|
186
|
+
attachments: Array<{ filename: string; contentType?: string; size?: number; shareId?: string; url?: string; contentId?: string }>
|
|
184
187
|
starred: boolean
|
|
185
188
|
archived: boolean
|
|
186
189
|
trashed: boolean
|
|
@@ -299,6 +302,7 @@ type MailSettings = {
|
|
|
299
302
|
uiScale: number
|
|
300
303
|
mobile: string
|
|
301
304
|
replyAllDefault: boolean
|
|
305
|
+
replyStyle: 'panel' | 'mini'
|
|
302
306
|
notifyEmail: string
|
|
303
307
|
fonts: CustomFont[]
|
|
304
308
|
defaultFont: BaseFont
|
|
@@ -306,6 +310,7 @@ type MailSettings = {
|
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
|
313
|
+
const NOTIFY_NOTICE_KEY = 'mail:notify-notice-until'
|
|
309
314
|
|
|
310
315
|
const DEFAULT_SETTINGS: MailSettings = {
|
|
311
316
|
signature: '',
|
|
@@ -320,6 +325,7 @@ const DEFAULT_SETTINGS: MailSettings = {
|
|
|
320
325
|
uiScale: 100,
|
|
321
326
|
mobile: '',
|
|
322
327
|
replyAllDefault: false,
|
|
328
|
+
replyStyle: 'panel',
|
|
323
329
|
fonts: [],
|
|
324
330
|
defaultFont: EMPTY_FONT,
|
|
325
331
|
}
|
|
@@ -425,6 +431,14 @@ function inlineMd(src: string): string {
|
|
|
425
431
|
const asRichHtml = (value: string): string =>
|
|
426
432
|
/<[a-z][\s\S]*>/i.test(value) ? value : markdownToHtml(value)
|
|
427
433
|
|
|
434
|
+
/** A signature is set solid: single line spacing and no paragraph gaps, whatever the message uses. */
|
|
435
|
+
const tightenSignature = (html: string): string =>
|
|
436
|
+
html.replace(/<(p|div|li|td|th)(\s[^>]*)?>/gi, (match, tag: string, attrs = '') => {
|
|
437
|
+
const tight = 'line-height:1;margin:0 0 2px;'
|
|
438
|
+
if (/\sstyle="/i.test(attrs)) return `<${tag}${attrs.replace(/\sstyle="([^"]*)"/i, (_whole: string, css: string) => ` style="${css.trim().replace(/;?$/, ';')}${tight}"`)}>`
|
|
439
|
+
return `<${tag}${attrs} style="${tight}">`
|
|
440
|
+
})
|
|
441
|
+
|
|
428
442
|
// A long reply chain re-embeds the sender's signature and social icons on every hop, so
|
|
429
443
|
// one message can carry a hundred of them. They are part of the body, not files anyone
|
|
430
444
|
// attached, and listing them buries the two or three real documents.
|
|
@@ -504,7 +518,7 @@ function buildEmailHtml(data: ComposeData, signatureHtml: string, fontCss = '',
|
|
|
504
518
|
const body = data.bodyHtml.trim() ? data.bodyHtml : markdownToHtml(data.markdown)
|
|
505
519
|
const inner = body + (data.useSignature ? signatureHtml : '')
|
|
506
520
|
const fonts = fontCss ? `<style>${fontCss}</style>` : ''
|
|
507
|
-
return `${fonts}<div style="font-family:${fontStack(base.family)};font-size:${base.size || '15px'};line-height
|
|
521
|
+
return `${fonts}<div style="font-family:${fontStack(base.family)};font-size:${base.size || '15px'};line-height:${lineSpacingOf(base)};color:#030712;">${outlookSafeImages(inlineEmailStyles(inner, base))}</div>${data.quoteHtml ?? ''}`
|
|
508
522
|
}
|
|
509
523
|
|
|
510
524
|
function buildEmailText(data: ComposeData, signatureText: string): string {
|
|
@@ -580,6 +594,31 @@ function htmlToQuoteText(html: string | null, text: string | null): string {
|
|
|
580
594
|
.trim()
|
|
581
595
|
}
|
|
582
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Parts the body embeds — a sender's signature icons, pasted pictures — are not files to list.
|
|
599
|
+
* Only embedded parts carry a Content-ID, and stored bodies hold them as data: URIs, so a
|
|
600
|
+
* small image with an id is embedded whether or not a cid: reference survives. Archives
|
|
601
|
+
* imported without ids fall back to the names mail clients give such parts. Anything
|
|
602
|
+
* larger, or not an image, is a real file.
|
|
603
|
+
*/
|
|
604
|
+
const INLINE_IMAGE_MAX_BYTES = 150_000
|
|
605
|
+
const INLINE_IMAGE_NAME = /^(image\d*|outlook-[\w-]+|blocked|[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(png|gif|jpe?g)$/i
|
|
606
|
+
function inlineAttachmentNames(message: InboundEmail): Set<string> {
|
|
607
|
+
const html = message.html ?? ''
|
|
608
|
+
const referenced = new Set((html.match(/cid:([^"'\s>)]+)/gi) ?? []).map(match => match.slice(4).toLowerCase()))
|
|
609
|
+
const names = new Set<string>()
|
|
610
|
+
for (const entry of message.attachments) {
|
|
611
|
+
if (!entry.contentType?.startsWith('image/') || (entry.size ?? 0) > INLINE_IMAGE_MAX_BYTES) continue
|
|
612
|
+
const id = entry.contentId?.replace(/^<|>$/g, '').toLowerCase()
|
|
613
|
+
if (id || INLINE_IMAGE_NAME.test(entry.filename) || (id && referenced.has(id))) names.add(entry.filename)
|
|
614
|
+
}
|
|
615
|
+
return names
|
|
616
|
+
}
|
|
617
|
+
function visibleAttachments(message: InboundEmail): InboundEmail['attachments'] {
|
|
618
|
+
const inline = inlineAttachmentNames(message)
|
|
619
|
+
return message.attachments.filter(entry => !inline.has(entry.filename))
|
|
620
|
+
}
|
|
621
|
+
|
|
583
622
|
// Real, visible images in an email (skips the 1x1 tracking pixel and inline cid: parts).
|
|
584
623
|
function extractBubbleImages(html: string | null): string[] {
|
|
585
624
|
if (!html) return []
|
|
@@ -621,10 +660,6 @@ function parseAddress(raw: string): string {
|
|
|
621
660
|
return (match ? match[1] : raw).trim()
|
|
622
661
|
}
|
|
623
662
|
|
|
624
|
-
function isForwarded(subject: string): boolean {
|
|
625
|
-
// Fwd: markers anywhere, plus the app's own "New mail: …" forward/notification prefix.
|
|
626
|
-
return /(^|\s)fwd?\s*:/i.test(subject) || /^\s*new mail\s*:/i.test(subject)
|
|
627
|
-
}
|
|
628
663
|
|
|
629
664
|
/**
|
|
630
665
|
* Inbound noise generated by this app itself. Deliberately narrower than isForwarded:
|
|
@@ -660,6 +695,27 @@ function describeHttp(status: number): string {
|
|
|
660
695
|
return `The mail server refused the request (${status}).`
|
|
661
696
|
}
|
|
662
697
|
|
|
698
|
+
function ReaderSkeleton() {
|
|
699
|
+
return (
|
|
700
|
+
<div className={styles.readerSkeleton} role="status" aria-label="Opening conversation">
|
|
701
|
+
<span className={`${styles.skelBar} ${styles.skelSubject}`} />
|
|
702
|
+
<div className={styles.skelHeadRow}>
|
|
703
|
+
<span className={styles.skelAvatar} />
|
|
704
|
+
<div className={styles.skelHeadLines}>
|
|
705
|
+
<span className={styles.skelBar} />
|
|
706
|
+
<span className={styles.skelBar} />
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
<div className={styles.skelActions}>
|
|
710
|
+
<span className={styles.skelBar} />
|
|
711
|
+
<span className={styles.skelBar} />
|
|
712
|
+
<span className={styles.skelBar} />
|
|
713
|
+
</div>
|
|
714
|
+
<BodySkeleton />
|
|
715
|
+
</div>
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
663
719
|
function BodySkeleton() {
|
|
664
720
|
return (
|
|
665
721
|
<div className={styles.bodySkeleton} role="status" aria-label="Loading email">
|
|
@@ -690,27 +746,27 @@ function countRemoteRefs(html: string | null): number {
|
|
|
690
746
|
// Show the email in its true colours on a light "paper" card, framed by the dark
|
|
691
747
|
// reader. The markup is left untouched — the card supplies a legible white surface
|
|
692
748
|
// for bare fragments, and emails that paint their own background render as designed.
|
|
693
|
-
const
|
|
749
|
+
const readerTheme = (spacing: number, dark: boolean) => `<style>
|
|
694
750
|
:root { color-scheme: light; }
|
|
695
751
|
/* Transparent so the framed document takes the app's themed surface from the
|
|
696
752
|
iframe element behind it. Theme variables do not cross the document boundary,
|
|
697
753
|
so anything set here would be a colour frozen against one theme. */
|
|
698
754
|
html { background: transparent; }
|
|
699
|
-
body { margin: 0; background: transparent; padding:
|
|
755
|
+
body { margin: 0; background: transparent; padding: 0 0 12px; }
|
|
700
756
|
.nc-paper {
|
|
701
|
-
max-width:
|
|
702
|
-
margin: 0 auto;
|
|
703
|
-
background: #ffffff;
|
|
757
|
+
max-width: 860px;
|
|
704
758
|
color: #1A1030;
|
|
705
|
-
|
|
706
|
-
|
|
759
|
+
background: ${dark ? '#ffffff' : 'transparent'};
|
|
760
|
+
border-radius: ${dark ? '6px' : '0'};
|
|
761
|
+
padding: ${dark ? '14px 16px' : '6px 0 12px'};
|
|
707
762
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
708
763
|
font-size: 15px;
|
|
709
|
-
line-height:
|
|
764
|
+
line-height: ${spacing};
|
|
710
765
|
-webkit-font-smoothing: antialiased;
|
|
711
766
|
word-break: break-word;
|
|
712
767
|
overflow-wrap: anywhere;
|
|
713
768
|
}
|
|
769
|
+
.nc-paper p { margin: 0 0 ${paragraphGap(spacing)}; }
|
|
714
770
|
.nc-paper img { max-width: 100%; height: auto; }
|
|
715
771
|
.nc-paper a { color: #5418C2; }
|
|
716
772
|
</style>`
|
|
@@ -821,10 +877,6 @@ function pastedMarkdown(event: React.ClipboardEvent): string | null {
|
|
|
821
877
|
|
|
822
878
|
// Reading our own sent mail must not register as the recipient opening it, so the
|
|
823
879
|
// tracking pixel is removed before the message is ever rendered in this app.
|
|
824
|
-
function stripOwnPixel(html: string): string {
|
|
825
|
-
return html.replace(/<img[^>]*\/api\/dev\/mail\/pixel\/[^>]*>/gi, '')
|
|
826
|
-
}
|
|
827
|
-
|
|
828
880
|
/**
|
|
829
881
|
* Our own hosts stay allowed even with remote images off. Blocking a sender's images
|
|
830
882
|
* is what that setting is for; the mark in our own signature is not a tracking pixel,
|
|
@@ -833,11 +885,11 @@ function stripOwnPixel(html: string): string {
|
|
|
833
885
|
const OWN_IMAGE_HOSTS = `${CLIENT_BRAND.websiteUrl} ${CLIENT_BRAND.publicUrl}`
|
|
834
886
|
|
|
835
887
|
// When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
|
|
836
|
-
function frameHtml(html: string, allowRemote: boolean): string {
|
|
888
|
+
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, dark = false): string {
|
|
837
889
|
const csp = allowRemote
|
|
838
890
|
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
|
|
839
891
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
|
|
840
|
-
return `<!doctype html><html><head><meta charset="utf-8">${csp}${
|
|
892
|
+
return `<!doctype html><html><head><meta charset="utf-8">${csp}${readerTheme(spacing, dark)}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(html)}</div></body>`
|
|
841
893
|
}
|
|
842
894
|
|
|
843
895
|
function headerValue(email: InboundEmail, key: string): string {
|
|
@@ -1146,6 +1198,7 @@ const ICONS = {
|
|
|
1146
1198
|
settings: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
|
|
1147
1199
|
sliders: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>,
|
|
1148
1200
|
send: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>,
|
|
1201
|
+
snooze: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="13" r="8"/><path d="M11 9v4l2.5 1.6"/><path d="M16.5 2.5h4l-4 4h4"/></svg>,
|
|
1149
1202
|
chevron: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>,
|
|
1150
1203
|
bell: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>,
|
|
1151
1204
|
bellOff: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13.7 21a2 2 0 0 1-3.4 0"/><path d="M18.6 13A16.7 16.7 0 0 1 18 8a6 6 0 0 0-9.3-5"/><path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h14"/><path d="m2 2 20 20"/></svg>,
|
|
@@ -1174,6 +1227,7 @@ const SETTINGS_TAB_ICONS: Record<SettingsTab, React.ReactNode> = {
|
|
|
1174
1227
|
const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
|
|
1175
1228
|
inbox: ICONS.inbox,
|
|
1176
1229
|
starred: ICONS.star,
|
|
1230
|
+
snoozed: ICONS.snooze,
|
|
1177
1231
|
sent: ICONS.sent,
|
|
1178
1232
|
scheduled: ICONS.scheduled,
|
|
1179
1233
|
drafts: ICONS.drafts,
|
|
@@ -1210,7 +1264,8 @@ export default function DevMailPage() {
|
|
|
1210
1264
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
1211
1265
|
const [inboxCursor, setInboxCursor] = useState<string | null>(null)
|
|
1212
1266
|
const inboxFetch = useRef<string | null>(null)
|
|
1213
|
-
|
|
1267
|
+
type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; snoozed: number }
|
|
1268
|
+
const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
|
|
1214
1269
|
const [countsLoading, setCountsLoading] = useState(false)
|
|
1215
1270
|
const [composeExpanded, setComposeExpanded] = useState(true)
|
|
1216
1271
|
const pwSectionRef = useRef<HTMLDivElement | null>(null)
|
|
@@ -1237,6 +1292,15 @@ export default function DevMailPage() {
|
|
|
1237
1292
|
media.addEventListener('change', apply)
|
|
1238
1293
|
return () => media.removeEventListener('change', apply)
|
|
1239
1294
|
}, [])
|
|
1295
|
+
// Below this the reader is the whole screen and a docked reply has nowhere to sit.
|
|
1296
|
+
const [isPhone, setIsPhone] = useState(false)
|
|
1297
|
+
useEffect(() => {
|
|
1298
|
+
const media = window.matchMedia('(max-width: 720px)')
|
|
1299
|
+
const apply = () => setIsPhone(media.matches)
|
|
1300
|
+
apply()
|
|
1301
|
+
media.addEventListener('change', apply)
|
|
1302
|
+
return () => media.removeEventListener('change', apply)
|
|
1303
|
+
}, [])
|
|
1240
1304
|
const [railCollapsed, setRailCollapsed] = useState(true)
|
|
1241
1305
|
useEffect(() => {
|
|
1242
1306
|
try {
|
|
@@ -1317,6 +1381,20 @@ export default function DevMailPage() {
|
|
|
1317
1381
|
// Dismissed for this session only: it should come back next sign-in while the
|
|
1318
1382
|
// password is still the one the mailbox was created with.
|
|
1319
1383
|
const [pwNoticeHidden, setPwNoticeHidden] = useState(false)
|
|
1384
|
+
const [notifyNoticeHidden, setNotifyNoticeHidden] = useState(true)
|
|
1385
|
+
useEffect(() => {
|
|
1386
|
+
try {
|
|
1387
|
+
if (Number(localStorage.getItem(NOTIFY_NOTICE_KEY) ?? 0) < Date.now()) setNotifyNoticeHidden(false)
|
|
1388
|
+
} catch {
|
|
1389
|
+
setNotifyNoticeHidden(false)
|
|
1390
|
+
}
|
|
1391
|
+
}, [])
|
|
1392
|
+
const dismissNotifyNotice = useCallback(() => {
|
|
1393
|
+
setNotifyNoticeHidden(true)
|
|
1394
|
+
try {
|
|
1395
|
+
localStorage.setItem(NOTIFY_NOTICE_KEY, String(Date.now() + 14 * 86400_000))
|
|
1396
|
+
} catch {}
|
|
1397
|
+
}, [])
|
|
1320
1398
|
const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
|
|
1321
1399
|
const { canInstall, installed, install } = useInstall()
|
|
1322
1400
|
const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
|
|
@@ -1406,25 +1484,14 @@ export default function DevMailPage() {
|
|
|
1406
1484
|
// Collapsed by default: the signature is long, and the composer is for the message.
|
|
1407
1485
|
const [sigExpanded, setSigExpanded] = useState(false)
|
|
1408
1486
|
const [sigPreviewOpen, setSigPreviewOpen] = useState(false)
|
|
1409
|
-
/**
|
|
1410
|
-
* Measured at the click, not on mount: before the content has settled it reports several
|
|
1411
|
-
* times its real height, and the reveal then finishes long before the animation does.
|
|
1412
|
-
*/
|
|
1413
1487
|
/** Clicking the folded signature opens it, unless the click was on a link inside it. */
|
|
1414
1488
|
const expandSignatureFromBody = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
1415
1489
|
if (sigExpanded) return
|
|
1416
1490
|
if ((event.target as HTMLElement).closest('a')) return
|
|
1417
|
-
const body = event.currentTarget
|
|
1418
|
-
body.style.setProperty('--sig-h', `${body.scrollHeight}px`)
|
|
1419
1491
|
setSigExpanded(true)
|
|
1420
1492
|
}, [sigExpanded])
|
|
1421
1493
|
|
|
1422
|
-
const toggleSignature = useCallback((
|
|
1423
|
-
const fold = event.currentTarget.closest(`.${styles.composeSignature}`)
|
|
1424
|
-
const body = fold?.querySelector<HTMLElement>(`.${styles.composeSignatureBody}`)
|
|
1425
|
-
if (body) body.style.setProperty('--sig-h', `${body.scrollHeight}px`)
|
|
1426
|
-
setSigExpanded(open => !open)
|
|
1427
|
-
}, [])
|
|
1494
|
+
const toggleSignature = useCallback(() => setSigExpanded(open => !open), [])
|
|
1428
1495
|
const [quickSending, setQuickSending] = useState(false)
|
|
1429
1496
|
const [replyMode, setReplyMode] = useState<ReplyMode>('write')
|
|
1430
1497
|
const [replySig, setReplySig] = useState(true)
|
|
@@ -1436,7 +1503,18 @@ export default function DevMailPage() {
|
|
|
1436
1503
|
const [replyRecipsOpen, setReplyRecipsOpen] = useState(false)
|
|
1437
1504
|
// Once the recipients have been touched they are the writer's, including when emptied.
|
|
1438
1505
|
const [replyRecipsEdited, setReplyRecipsEdited] = useState(false)
|
|
1506
|
+
const [replyBar, setReplyBar] = useState<null | 'reply' | 'all' | 'forward'>(null)
|
|
1439
1507
|
const quickReplyRef = useRef<HTMLTextAreaElement>(null)
|
|
1508
|
+
useEffect(() => {
|
|
1509
|
+
if (!replyBar) return
|
|
1510
|
+
const focus = () => quickReplyRef.current?.focus({ preventScroll: true })
|
|
1511
|
+
const frame = requestAnimationFrame(focus)
|
|
1512
|
+
const settled = window.setTimeout(focus, 280)
|
|
1513
|
+
return () => {
|
|
1514
|
+
cancelAnimationFrame(frame)
|
|
1515
|
+
window.clearTimeout(settled)
|
|
1516
|
+
}
|
|
1517
|
+
}, [replyBar])
|
|
1440
1518
|
useEffect(() => {
|
|
1441
1519
|
setQuickReply('')
|
|
1442
1520
|
setReplyMode('write')
|
|
@@ -1448,6 +1526,7 @@ export default function DevMailPage() {
|
|
|
1448
1526
|
setReplyBcc([])
|
|
1449
1527
|
setReplyRecipsOpen(false)
|
|
1450
1528
|
setReplyRecipsEdited(false)
|
|
1529
|
+
setReplyBar(null)
|
|
1451
1530
|
}, [selectedId])
|
|
1452
1531
|
// Grow the reply box to fit its content; the CSS max-height caps it (8 lines) and scrolls past it.
|
|
1453
1532
|
useEffect(() => {
|
|
@@ -1479,7 +1558,7 @@ export default function DevMailPage() {
|
|
|
1479
1558
|
)
|
|
1480
1559
|
const [threads, setThreads] = useState<ConversationRow[]>([])
|
|
1481
1560
|
const [threadsResolved, setThreadsResolved] = useState(false)
|
|
1482
|
-
const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : 'inbox'
|
|
1561
|
+
const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
|
|
1483
1562
|
const threadsFetch = useRef<string | null>(null)
|
|
1484
1563
|
const threadsLoadedFolder = useRef<string | null>(null)
|
|
1485
1564
|
const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
|
|
@@ -1748,28 +1827,101 @@ export default function DevMailPage() {
|
|
|
1748
1827
|
// account and there is no one to act as.
|
|
1749
1828
|
const actingAs: string | null = null
|
|
1750
1829
|
|
|
1830
|
+
const decorateSent = (data: {
|
|
1831
|
+
emails: Array<Omit<SentEmail, 'starred' | 'archived' | 'trashed' | 'opened' | 'openCount' | 'openedAt'>>
|
|
1832
|
+
flags?: Record<string, { starred: boolean; archived: boolean; trashed: boolean }>
|
|
1833
|
+
opens?: Record<string, { opened: boolean; openCount: number; openedAt: string | null }>
|
|
1834
|
+
}): SentEmail[] => {
|
|
1835
|
+
const flags = data.flags ?? {}
|
|
1836
|
+
const opens = data.opens ?? {}
|
|
1837
|
+
return data.emails.map(entry => ({
|
|
1838
|
+
...entry,
|
|
1839
|
+
starred: flags[entry.id]?.starred ?? false,
|
|
1840
|
+
archived: flags[entry.id]?.archived ?? false,
|
|
1841
|
+
trashed: flags[entry.id]?.trashed ?? false,
|
|
1842
|
+
opened: opens[entry.id]?.opened ?? false,
|
|
1843
|
+
openCount: opens[entry.id]?.openCount ?? 0,
|
|
1844
|
+
openedAt: opens[entry.id]?.openedAt ?? null,
|
|
1845
|
+
}))
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1751
1848
|
const loadSent = useCallback(async () => {
|
|
1752
1849
|
try {
|
|
1753
1850
|
const response = await fetch(`/api/mail/emails${mailboxQuery}`, { headers: apiHeaders() })
|
|
1754
1851
|
const data = await response.json()
|
|
1755
|
-
if (data.ok)
|
|
1756
|
-
const flags: Record<string, { starred: boolean; archived: boolean; trashed: boolean }> = data.flags ?? {}
|
|
1757
|
-
const opens: Record<string, { opened: boolean; openCount: number; openedAt: string | null }> = data.opens ?? {}
|
|
1758
|
-
setSentEmails(
|
|
1759
|
-
(data.emails as Array<Omit<SentEmail, 'starred' | 'archived' | 'trashed' | 'opened' | 'openCount' | 'openedAt'>>).map(entry => ({
|
|
1760
|
-
...entry,
|
|
1761
|
-
starred: flags[entry.id]?.starred ?? false,
|
|
1762
|
-
archived: flags[entry.id]?.archived ?? false,
|
|
1763
|
-
trashed: flags[entry.id]?.trashed ?? false,
|
|
1764
|
-
opened: opens[entry.id]?.opened ?? false,
|
|
1765
|
-
openCount: opens[entry.id]?.openCount ?? 0,
|
|
1766
|
-
openedAt: opens[entry.id]?.openedAt ?? null,
|
|
1767
|
-
})),
|
|
1768
|
-
)
|
|
1769
|
-
}
|
|
1852
|
+
if (data.ok) setSentEmails(decorateSent(data))
|
|
1770
1853
|
} catch {}
|
|
1771
1854
|
}, [apiHeaders, mailboxQuery])
|
|
1772
1855
|
|
|
1856
|
+
/**
|
|
1857
|
+
* Searching Sent asks the server. The list holds the newest few hundred sends and a
|
|
1858
|
+
* search that only looked through those found nothing older, with no sign that it had
|
|
1859
|
+
* not looked. Null means "no search", so the folder shows the ordinary list.
|
|
1860
|
+
*/
|
|
1861
|
+
const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
|
|
1862
|
+
const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
|
|
1863
|
+
const crossFolderSeq = useRef(0)
|
|
1864
|
+
useEffect(() => {
|
|
1865
|
+
const text = search.trim()
|
|
1866
|
+
const seq = ++crossFolderSeq.current
|
|
1867
|
+
if (!text || parseQuery(text).isEmpty || folder === 'drafts' || folder === 'scheduled') {
|
|
1868
|
+
setCrossFolder(null)
|
|
1869
|
+
return
|
|
1870
|
+
}
|
|
1871
|
+
const timer = window.setTimeout(async () => {
|
|
1872
|
+
try {
|
|
1873
|
+
if (folder === 'sent') {
|
|
1874
|
+
const response = await fetch(`/api/mail/inbox?limit=1&folder=inbox&q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
|
|
1875
|
+
const data = await response.json()
|
|
1876
|
+
if (seq !== crossFolderSeq.current) return
|
|
1877
|
+
setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
|
|
1878
|
+
} else {
|
|
1879
|
+
const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
|
|
1880
|
+
const data = await response.json()
|
|
1881
|
+
if (seq !== crossFolderSeq.current) return
|
|
1882
|
+
const count = Array.isArray(data.emails) ? data.emails.length : 0
|
|
1883
|
+
setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
|
|
1884
|
+
}
|
|
1885
|
+
} catch {
|
|
1886
|
+
if (seq === crossFolderSeq.current) setCrossFolder(null)
|
|
1887
|
+
}
|
|
1888
|
+
}, 350)
|
|
1889
|
+
return () => window.clearTimeout(timer)
|
|
1890
|
+
}, [search, folder, apiHeaders])
|
|
1891
|
+
const [sentSearching, setSentSearching] = useState(false)
|
|
1892
|
+
const [sentSearchTruncated, setSentSearchTruncated] = useState(false)
|
|
1893
|
+
const sentSearchSeq = useRef(0)
|
|
1894
|
+
const loadSentSearch = useCallback(
|
|
1895
|
+
async (text: string) => {
|
|
1896
|
+
const seq = ++sentSearchSeq.current
|
|
1897
|
+
try {
|
|
1898
|
+
const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
|
|
1899
|
+
const data = await response.json()
|
|
1900
|
+
if (seq !== sentSearchSeq.current) return
|
|
1901
|
+
setSentSearchResults(data.ok ? decorateSent(data) : [])
|
|
1902
|
+
setSentSearchTruncated(Boolean(data.ok && data.truncated))
|
|
1903
|
+
} catch {
|
|
1904
|
+
if (seq === sentSearchSeq.current) setSentSearchResults([])
|
|
1905
|
+
} finally {
|
|
1906
|
+
if (seq === sentSearchSeq.current) setSentSearching(false)
|
|
1907
|
+
}
|
|
1908
|
+
},
|
|
1909
|
+
[apiHeaders],
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
useEffect(() => {
|
|
1913
|
+
if (folder !== 'sent' || !search.trim() || parseQuery(search).isEmpty) {
|
|
1914
|
+
sentSearchSeq.current += 1
|
|
1915
|
+
setSentSearchResults(null)
|
|
1916
|
+
setSentSearching(false)
|
|
1917
|
+
setSentSearchTruncated(false)
|
|
1918
|
+
return
|
|
1919
|
+
}
|
|
1920
|
+
setSentSearching(true)
|
|
1921
|
+
const timer = window.setTimeout(() => { void loadSentSearch(search.trim()) }, 250)
|
|
1922
|
+
return () => window.clearTimeout(timer)
|
|
1923
|
+
}, [search, folder, loadSentSearch])
|
|
1924
|
+
|
|
1773
1925
|
// One request of 150 rows costs about two seconds; three of fifty cost five, because
|
|
1774
1926
|
// the wait is the distance, not the data. Fetch the screenful in one trip.
|
|
1775
1927
|
const INBOX_PAGE = 150
|
|
@@ -1779,12 +1931,13 @@ export default function DevMailPage() {
|
|
|
1779
1931
|
// Only the first page is ever asked for twice on mount. Paging must never be
|
|
1780
1932
|
// suppressed: a page that is skipped is a page that never arrives, and the cursor
|
|
1781
1933
|
// stops advancing with it.
|
|
1782
|
-
const key = cursor ? null : `${mailboxQuery}|${search.trim()}`
|
|
1934
|
+
const key = cursor ? null : `${mailboxQuery}|${threadFolder}|${search.trim()}`
|
|
1783
1935
|
if (key && inboxFetch.current === key) return
|
|
1784
1936
|
if (key) inboxFetch.current = key
|
|
1785
1937
|
try {
|
|
1786
1938
|
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1787
1939
|
params.set('limit', String(INBOX_PAGE))
|
|
1940
|
+
params.set('folder', threadFolder)
|
|
1788
1941
|
// A cursor asks for the page after a known row; without one this is the first page.
|
|
1789
1942
|
if (cursor) params.set('cursor', cursor)
|
|
1790
1943
|
const text = search.trim()
|
|
@@ -1813,7 +1966,7 @@ export default function DevMailPage() {
|
|
|
1813
1966
|
if (!cursor) setSearching(false)
|
|
1814
1967
|
}
|
|
1815
1968
|
},
|
|
1816
|
-
[apiHeaders, mailboxQuery, search],
|
|
1969
|
+
[apiHeaders, mailboxQuery, search, threadFolder],
|
|
1817
1970
|
)
|
|
1818
1971
|
|
|
1819
1972
|
/**
|
|
@@ -1826,12 +1979,13 @@ export default function DevMailPage() {
|
|
|
1826
1979
|
// A search re-runs when it is typed; re-running it on every poll tick charged the
|
|
1827
1980
|
// full-text scan again for a result set that was not changing.
|
|
1828
1981
|
if (search.trim()) return
|
|
1829
|
-
const key = `${mailboxQuery}|${search.trim()}`
|
|
1982
|
+
const key = `${mailboxQuery}|${threadFolder}|${search.trim()}`
|
|
1830
1983
|
if (inboxFetch.current === key) return
|
|
1831
1984
|
inboxFetch.current = key
|
|
1832
1985
|
try {
|
|
1833
1986
|
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1834
1987
|
params.set('limit', String(INBOX_PAGE))
|
|
1988
|
+
params.set('folder', threadFolder)
|
|
1835
1989
|
const text = search.trim()
|
|
1836
1990
|
if (text) params.set('q', text)
|
|
1837
1991
|
const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
|
|
@@ -1863,7 +2017,7 @@ export default function DevMailPage() {
|
|
|
1863
2017
|
} finally {
|
|
1864
2018
|
if (inboxFetch.current === key) inboxFetch.current = null
|
|
1865
2019
|
}
|
|
1866
|
-
}, [apiHeaders, mailboxQuery, search])
|
|
2020
|
+
}, [apiHeaders, mailboxQuery, search, threadFolder])
|
|
1867
2021
|
|
|
1868
2022
|
/**
|
|
1869
2023
|
* Folder totals come from the database rather than from the rows on hand: counting what
|
|
@@ -1880,6 +2034,10 @@ export default function DevMailPage() {
|
|
|
1880
2034
|
setCountsLoading(true)
|
|
1881
2035
|
try {
|
|
1882
2036
|
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
2037
|
+
// A forced count follows an action that just changed the figures, so it has to skip
|
|
2038
|
+
// the server's own cache as well as this one — otherwise a folder someone just moved
|
|
2039
|
+
// mail into reads zero for another twenty seconds.
|
|
2040
|
+
if (force) params.set('fresh', '1')
|
|
1883
2041
|
const response = await fetch(`/api/mail/inbox/counts?${params.toString()}`, { headers: apiHeaders() })
|
|
1884
2042
|
if (!response.ok) return
|
|
1885
2043
|
const data = await response.json().catch(() => null)
|
|
@@ -1933,8 +2091,9 @@ export default function DevMailPage() {
|
|
|
1933
2091
|
if (saved?.counts) setServerCounts(current => current ?? saved.counts!)
|
|
1934
2092
|
const rows = Array.isArray(saved?.rows) ? saved.rows : null
|
|
1935
2093
|
if (!rows?.length) return
|
|
2094
|
+
if (saved.folder !== threadFolder) return
|
|
1936
2095
|
setInboxEmails(current => (current.length ? current : rows))
|
|
1937
|
-
if (
|
|
2096
|
+
if (Array.isArray(saved.threads) && saved.threads.length) {
|
|
1938
2097
|
setThreads(current => (current.length ? current : saved.threads!))
|
|
1939
2098
|
}
|
|
1940
2099
|
setMailboxLoading(false)
|
|
@@ -1972,7 +2131,7 @@ export default function DevMailPage() {
|
|
|
1972
2131
|
return () => window.clearTimeout(timer)
|
|
1973
2132
|
// loadInbox changes identity on every keystroke; depending on it would loop.
|
|
1974
2133
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1975
|
-
}, [search, mailboxQuery])
|
|
2134
|
+
}, [search, mailboxQuery, threadFolder])
|
|
1976
2135
|
|
|
1977
2136
|
// A ref, not state: one page must be in flight at a time, and the check has to see the
|
|
1978
2137
|
// current value synchronously — two observer callbacks can fire in the same tick.
|
|
@@ -2013,6 +2172,202 @@ export default function DevMailPage() {
|
|
|
2013
2172
|
return () => node.removeEventListener('scroll', check)
|
|
2014
2173
|
}, [moreCursor])
|
|
2015
2174
|
|
|
2175
|
+
/**
|
|
2176
|
+
* Swiping a row. Desktop reveals a row's actions on hover, which a phone cannot do, so
|
|
2177
|
+
* the same two actions live on a horizontal drag instead. The action is named and
|
|
2178
|
+
* coloured behind the row before it commits, and nothing fires below the threshold.
|
|
2179
|
+
*/
|
|
2180
|
+
/**
|
|
2181
|
+
* Snooze. The times are the ones people actually mean when they say "not now": the rest
|
|
2182
|
+
* of today, first thing tomorrow, and the start of next week. Everything is computed in
|
|
2183
|
+
* the reader's own timezone, because "tomorrow morning" is a local idea.
|
|
2184
|
+
*/
|
|
2185
|
+
const snoozeChoices = useMemo(() => {
|
|
2186
|
+
const at = (days: number, hour: number) => {
|
|
2187
|
+
const when = new Date()
|
|
2188
|
+
when.setDate(when.getDate() + days)
|
|
2189
|
+
when.setHours(hour, 0, 0, 0)
|
|
2190
|
+
return when
|
|
2191
|
+
}
|
|
2192
|
+
const laterToday = new Date(Date.now() + 3 * 60 * 60 * 1000)
|
|
2193
|
+
const tomorrow = at(1, 8)
|
|
2194
|
+
const monday = (() => {
|
|
2195
|
+
const when = at(1, 8)
|
|
2196
|
+
while (when.getDay() !== 1) when.setDate(when.getDate() + 1)
|
|
2197
|
+
return when
|
|
2198
|
+
})()
|
|
2199
|
+
const weekend = (() => {
|
|
2200
|
+
const when = at(1, 9)
|
|
2201
|
+
while (when.getDay() !== 6) when.setDate(when.getDate() + 1)
|
|
2202
|
+
return when
|
|
2203
|
+
})()
|
|
2204
|
+
return [
|
|
2205
|
+
// Past the end of the working day this stops meaning "later today".
|
|
2206
|
+
...(laterToday.getHours() >= 7 && laterToday.getHours() <= 20
|
|
2207
|
+
? [{ key: 'later', label: 'Later today', at: laterToday }]
|
|
2208
|
+
: []),
|
|
2209
|
+
{ key: 'tomorrow', label: 'Tomorrow morning', at: tomorrow },
|
|
2210
|
+
{ key: 'weekend', label: 'This weekend', at: weekend },
|
|
2211
|
+
{ key: 'monday', label: 'Next week', at: monday },
|
|
2212
|
+
]
|
|
2213
|
+
}, [])
|
|
2214
|
+
|
|
2215
|
+
const [snoozeMenuOpen, setSnoozeMenuOpen] = useState(false)
|
|
2216
|
+
const applySnooze = useCallback(
|
|
2217
|
+
(threadId: string | null, until: Date | null) => {
|
|
2218
|
+
if (!threadId) return
|
|
2219
|
+
setSnoozeMenuOpen(false)
|
|
2220
|
+
setThreads(list => list.filter(thread => thread.threadId !== threadId))
|
|
2221
|
+
setSelectedId(null)
|
|
2222
|
+
setReaderOpenMobile(false)
|
|
2223
|
+
fetch('/api/mail/inbox', {
|
|
2224
|
+
method: 'PATCH',
|
|
2225
|
+
headers: apiHeaders(),
|
|
2226
|
+
body: JSON.stringify({ threadId, snoozedUntil: until ? until.toISOString() : null }),
|
|
2227
|
+
})
|
|
2228
|
+
.then(() => loadCounts(true))
|
|
2229
|
+
.catch(() => {})
|
|
2230
|
+
},
|
|
2231
|
+
[apiHeaders, loadCounts],
|
|
2232
|
+
)
|
|
2233
|
+
|
|
2234
|
+
const SWIPE_COMMIT = 96
|
|
2235
|
+
const [swipe, setSwipe] = useState<{ id: string; dx: number; top: number; height: number } | null>(null)
|
|
2236
|
+
const swipeRef = useRef<{ id: string; threadId: string | null; x: number; y: number; dx: number; axis: '' | 'x' | 'y'; moved: boolean; top: number; height: number } | null>(null)
|
|
2237
|
+
const swipeCommitRef = useRef<(id: string, threadId: string | null, flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>>) => void>(() => {})
|
|
2238
|
+
|
|
2239
|
+
const swipeActions = useMemo(() => {
|
|
2240
|
+
type SwipeAction = { label: string; flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>> } | null
|
|
2241
|
+
const set = (right: SwipeAction, left: SwipeAction) => ({ right, left })
|
|
2242
|
+
if (folder === 'trash') return set({ label: 'Restore', flags: { trashed: false } }, null)
|
|
2243
|
+
if (folder === 'archived') return set({ label: 'To inbox', flags: { archived: false } }, { label: 'Delete', flags: { trashed: true } })
|
|
2244
|
+
return set({ label: 'Archive', flags: { archived: true } }, { label: 'Delete', flags: { trashed: true } })
|
|
2245
|
+
}, [folder])
|
|
2246
|
+
|
|
2247
|
+
const onRowTouchStart = useCallback((id: string, threadId: string | null, event: React.TouchEvent<HTMLButtonElement>) => {
|
|
2248
|
+
if (event.touches.length !== 1) return
|
|
2249
|
+
const row = event.currentTarget
|
|
2250
|
+
swipeRef.current = {
|
|
2251
|
+
id,
|
|
2252
|
+
threadId,
|
|
2253
|
+
x: event.touches[0].clientX,
|
|
2254
|
+
y: event.touches[0].clientY,
|
|
2255
|
+
dx: 0,
|
|
2256
|
+
axis: '',
|
|
2257
|
+
moved: false,
|
|
2258
|
+
top: row.offsetTop,
|
|
2259
|
+
height: row.offsetHeight,
|
|
2260
|
+
}
|
|
2261
|
+
}, [])
|
|
2262
|
+
|
|
2263
|
+
const onRowTouchMove = useCallback((event: React.TouchEvent) => {
|
|
2264
|
+
const gesture = swipeRef.current
|
|
2265
|
+
if (!gesture) return
|
|
2266
|
+
const dx = event.touches[0].clientX - gesture.x
|
|
2267
|
+
const dy = event.touches[0].clientY - gesture.y
|
|
2268
|
+
if (!gesture.axis) {
|
|
2269
|
+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
|
|
2270
|
+
// Whichever way it went first owns the gesture; otherwise a scroll drags rows sideways.
|
|
2271
|
+
gesture.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
|
|
2272
|
+
}
|
|
2273
|
+
if (gesture.axis !== 'x') return
|
|
2274
|
+
event.stopPropagation()
|
|
2275
|
+
gesture.moved = true
|
|
2276
|
+
const allowed = dx > 0 ? swipeActions.right : swipeActions.left
|
|
2277
|
+
gesture.dx = allowed ? Math.max(-160, Math.min(160, dx)) : 0
|
|
2278
|
+
setSwipe({ id: gesture.id, dx: gesture.dx, top: gesture.top, height: gesture.height })
|
|
2279
|
+
}, [swipeActions])
|
|
2280
|
+
|
|
2281
|
+
const onRowTouchEnd = useCallback(() => {
|
|
2282
|
+
const gesture = swipeRef.current
|
|
2283
|
+
// Cleared first, so the touchcancel that can follow a touchend is a no-op.
|
|
2284
|
+
swipeRef.current = null
|
|
2285
|
+
setSwipe(null)
|
|
2286
|
+
if (!gesture || gesture.axis !== 'x') return
|
|
2287
|
+
const action = gesture.dx > 0 ? swipeActions.right : swipeActions.left
|
|
2288
|
+
// Deliberately not inside the state updater: React calls those twice in development
|
|
2289
|
+
// to check they are pure, and the archive went out twice because of it.
|
|
2290
|
+
if (action && Math.abs(gesture.dx) >= SWIPE_COMMIT) {
|
|
2291
|
+
swipeCommitRef.current(gesture.id, gesture.threadId, action.flags)
|
|
2292
|
+
}
|
|
2293
|
+
}, [swipeActions])
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* Pull to refresh. A phone has no hover and the header has room for one small button, so
|
|
2297
|
+
* the gesture people already expect is the one worth supporting. The listener is not
|
|
2298
|
+
* passive because the pull has to win over the scroll once it starts, and it only ever
|
|
2299
|
+
* starts when the list is already at the top.
|
|
2300
|
+
*/
|
|
2301
|
+
const PULL_TRIGGER = 72
|
|
2302
|
+
const [pullDistance, setPullDistance] = useState(0)
|
|
2303
|
+
useEffect(() => {
|
|
2304
|
+
const node = listRef.current
|
|
2305
|
+
if (!node) return
|
|
2306
|
+
let startY = 0
|
|
2307
|
+
let pulling = false
|
|
2308
|
+
|
|
2309
|
+
const start = (event: TouchEvent) => {
|
|
2310
|
+
if (node.scrollTop > 0 || event.touches.length !== 1) return
|
|
2311
|
+
startY = event.touches[0].clientY
|
|
2312
|
+
pulling = true
|
|
2313
|
+
}
|
|
2314
|
+
const move = (event: TouchEvent) => {
|
|
2315
|
+
if (!pulling) return
|
|
2316
|
+
const delta = event.touches[0].clientY - startY
|
|
2317
|
+
if (delta <= 0) {
|
|
2318
|
+
pulling = false
|
|
2319
|
+
setPullDistance(0)
|
|
2320
|
+
return
|
|
2321
|
+
}
|
|
2322
|
+
event.preventDefault()
|
|
2323
|
+
// Resistance, so the sheet follows the finger without tracking it one to one.
|
|
2324
|
+
setPullDistance(Math.min(PULL_TRIGGER * 1.5, delta * 0.45))
|
|
2325
|
+
}
|
|
2326
|
+
const end = () => {
|
|
2327
|
+
if (!pulling) return
|
|
2328
|
+
pulling = false
|
|
2329
|
+
setPullDistance(current => {
|
|
2330
|
+
if (current >= PULL_TRIGGER * 0.75) void refreshAllRef.current()
|
|
2331
|
+
return 0
|
|
2332
|
+
})
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
node.addEventListener('touchstart', start, { passive: true })
|
|
2336
|
+
node.addEventListener('touchmove', move, { passive: false })
|
|
2337
|
+
node.addEventListener('touchend', end)
|
|
2338
|
+
node.addEventListener('touchcancel', end)
|
|
2339
|
+
return () => {
|
|
2340
|
+
node.removeEventListener('touchstart', start)
|
|
2341
|
+
node.removeEventListener('touchmove', move)
|
|
2342
|
+
node.removeEventListener('touchend', end)
|
|
2343
|
+
node.removeEventListener('touchcancel', end)
|
|
2344
|
+
}
|
|
2345
|
+
}, [isLoggedIn])
|
|
2346
|
+
|
|
2347
|
+
// Far enough down that the button is wanted, not so far that it appears on a nudge.
|
|
2348
|
+
const TO_TOP_AFTER = 400
|
|
2349
|
+
const [listScrolled, setListScrolled] = useState(false)
|
|
2350
|
+
// Keyed on the sign-in, not on mount: the list does not exist until then, so an effect
|
|
2351
|
+
// that ran once on mount attached its listener to nothing and the button never appeared.
|
|
2352
|
+
useEffect(() => {
|
|
2353
|
+
const node = listRef.current
|
|
2354
|
+
if (!node) return
|
|
2355
|
+
const check = () => setListScrolled(node.scrollTop > TO_TOP_AFTER)
|
|
2356
|
+
check()
|
|
2357
|
+
node.addEventListener('scroll', check, { passive: true })
|
|
2358
|
+
return () => node.removeEventListener('scroll', check)
|
|
2359
|
+
}, [isLoggedIn])
|
|
2360
|
+
|
|
2361
|
+
const scrollListToTop = useCallback(() => {
|
|
2362
|
+
listRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
2363
|
+
}, [])
|
|
2364
|
+
|
|
2365
|
+
// Every folder starts at the top, so the button must not linger from the last one.
|
|
2366
|
+
useEffect(() => {
|
|
2367
|
+
listRef.current?.scrollTo({ top: 0 })
|
|
2368
|
+
setListScrolled(false)
|
|
2369
|
+
}, [folder])
|
|
2370
|
+
|
|
2016
2371
|
const loadEvents = useCallback(async () => {
|
|
2017
2372
|
try {
|
|
2018
2373
|
const response = await fetch('/api/mail/events', { headers: apiHeaders() })
|
|
@@ -2026,6 +2381,8 @@ export default function DevMailPage() {
|
|
|
2026
2381
|
}
|
|
2027
2382
|
}, [apiHeaders])
|
|
2028
2383
|
|
|
2384
|
+
const refreshAllRef = useRef<() => Promise<void>>(async () => {})
|
|
2385
|
+
|
|
2029
2386
|
const refreshAll = useCallback(async () => {
|
|
2030
2387
|
setRefreshing(true)
|
|
2031
2388
|
try {
|
|
@@ -2039,6 +2396,7 @@ export default function DevMailPage() {
|
|
|
2039
2396
|
setMailboxStale(false)
|
|
2040
2397
|
}
|
|
2041
2398
|
}, [loadSent, refreshInbox, loadEvents, loadThreads])
|
|
2399
|
+
useEffect(() => { refreshAllRef.current = refreshAll }, [refreshAll])
|
|
2042
2400
|
|
|
2043
2401
|
const loadAccessors = useCallback(async () => {
|
|
2044
2402
|
try {
|
|
@@ -2227,6 +2585,7 @@ export default function DevMailPage() {
|
|
|
2227
2585
|
}
|
|
2228
2586
|
setMailboxLoading(true)
|
|
2229
2587
|
setMailboxStale(false)
|
|
2588
|
+
cancelThreadOpen()
|
|
2230
2589
|
setSelectedId(null)
|
|
2231
2590
|
setReaderOpenMobile(false)
|
|
2232
2591
|
setSelectedBulk(new Set())
|
|
@@ -2250,11 +2609,12 @@ export default function DevMailPage() {
|
|
|
2250
2609
|
.then(response => response.json())
|
|
2251
2610
|
.then(data => {
|
|
2252
2611
|
if (data.ok && data.settings) {
|
|
2253
|
-
const stored = data.settings as MailSettings & { density?: string }
|
|
2254
|
-
//
|
|
2255
|
-
// the
|
|
2256
|
-
|
|
2257
|
-
|
|
2612
|
+
const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
|
|
2613
|
+
// Only the roomy setting is named; anything else — unset, or the retired
|
|
2614
|
+
// "comfortable" — is the default. Treating every unrecognised value as roomy put
|
|
2615
|
+
// six of the seven mailboxes into a reduced reader none of them had asked for.
|
|
2616
|
+
const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
|
|
2617
|
+
setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
|
|
2258
2618
|
const prefs = (data.settings as MailSettings).prefs
|
|
2259
2619
|
if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
|
|
2260
2620
|
if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
|
|
@@ -2316,21 +2676,26 @@ export default function DevMailPage() {
|
|
|
2316
2676
|
// Server figure when there is one: counting loaded rows undercounts a mailbox this size,
|
|
2317
2677
|
// and counted unread messages against grouped conversations, which is how an unread
|
|
2318
2678
|
// badge came to read higher than the folder total beside it.
|
|
2679
|
+
// The sidebar counts mail, the list header counts conversations. They answer different
|
|
2680
|
+
// questions — how much is in the folder, and how many rows that comes to — so they are
|
|
2681
|
+
// deliberately different numbers rather than one of them being wrong.
|
|
2319
2682
|
const unreadCount = serverCounts?.unread ?? 0
|
|
2320
2683
|
|
|
2321
2684
|
// Without a search the list total is the folder's own count, which the sidebar has
|
|
2322
2685
|
// already fetched; asking the list query to count the same rows again is wasted work.
|
|
2323
2686
|
useEffect(() => {
|
|
2324
2687
|
if (search.trim() || !serverCounts) return
|
|
2325
|
-
const
|
|
2688
|
+
const total = threadFolder === 'archive'
|
|
2326
2689
|
? serverCounts.archived
|
|
2327
|
-
:
|
|
2690
|
+
: threadFolder === 'trash'
|
|
2328
2691
|
? serverCounts.trashed
|
|
2329
|
-
:
|
|
2692
|
+
: threadFolder === 'starred'
|
|
2330
2693
|
? serverCounts.starred
|
|
2331
|
-
:
|
|
2332
|
-
|
|
2333
|
-
|
|
2694
|
+
: threadFolder === 'snoozed'
|
|
2695
|
+
? serverCounts.snoozed
|
|
2696
|
+
: serverCounts.inbox
|
|
2697
|
+
setInboxTotal(total)
|
|
2698
|
+
}, [serverCounts, search, threadFolder])
|
|
2334
2699
|
|
|
2335
2700
|
const folderCounts = useMemo(
|
|
2336
2701
|
() => ({
|
|
@@ -2338,10 +2703,28 @@ export default function DevMailPage() {
|
|
|
2338
2703
|
starred: serverCounts?.starred ?? 0,
|
|
2339
2704
|
archived: serverCounts?.archived ?? 0,
|
|
2340
2705
|
trashed: serverCounts?.trashed ?? 0,
|
|
2706
|
+
snoozed: serverCounts?.snoozed ?? 0,
|
|
2341
2707
|
}),
|
|
2342
2708
|
[serverCounts],
|
|
2343
2709
|
)
|
|
2344
2710
|
|
|
2711
|
+
/**
|
|
2712
|
+
* What the folder holds, not what has been fetched. The header counted loaded rows, so a
|
|
2713
|
+
* mailbox of 14,974 conversations opened on "500 conversations" — the thread page size
|
|
2714
|
+
* showing through — and climbed as you scrolled. A search has no such total: the rows
|
|
2715
|
+
* are the result.
|
|
2716
|
+
*/
|
|
2717
|
+
const folderConversationTotal = useMemo(() => {
|
|
2718
|
+
const tally = serverCounts?.conversations
|
|
2719
|
+
if (!tally || search.trim()) return null
|
|
2720
|
+
if (folder === 'archived') return tally.archived
|
|
2721
|
+
if (folder === 'trash') return tally.trashed
|
|
2722
|
+
if (folder === 'starred') return tally.starred
|
|
2723
|
+
if (folder === 'snoozed') return tally.snoozed
|
|
2724
|
+
if (folder === 'inbox') return tally.inbox
|
|
2725
|
+
return null
|
|
2726
|
+
}, [serverCounts, search, folder])
|
|
2727
|
+
|
|
2345
2728
|
const eventsByEmail = useMemo(() => {
|
|
2346
2729
|
const map: Record<string, MailEvent[]> = {}
|
|
2347
2730
|
for (const event of events) {
|
|
@@ -2352,23 +2735,6 @@ export default function DevMailPage() {
|
|
|
2352
2735
|
return map
|
|
2353
2736
|
}, [events])
|
|
2354
2737
|
|
|
2355
|
-
const statusFor = useCallback(
|
|
2356
|
-
(entry: SentEmail): string => {
|
|
2357
|
-
const tracked = eventsByEmail[entry.id] ?? []
|
|
2358
|
-
const has = (type: string) => tracked.filter(event => event.type === `email.${type}`)
|
|
2359
|
-
if (has('complained').length) return 'complained'
|
|
2360
|
-
if (has('bounced').length) return 'bounced'
|
|
2361
|
-
if (has('failed').length) return 'failed'
|
|
2362
|
-
const clicks = has('clicked').length
|
|
2363
|
-
if (clicks) return clicks > 1 ? `clicked ×${clicks}` : 'clicked'
|
|
2364
|
-
const opens = has('opened').length
|
|
2365
|
-
if (opens) return opens > 1 ? `opened ×${opens}` : 'opened'
|
|
2366
|
-
if (has('delivered').length) return 'delivered'
|
|
2367
|
-
if (has('delivery_delayed').length) return 'delayed'
|
|
2368
|
-
return entry.lastEvent || 'sent'
|
|
2369
|
-
},
|
|
2370
|
-
[eventsByEmail],
|
|
2371
|
-
)
|
|
2372
2738
|
|
|
2373
2739
|
const searchQuery = useMemo(() => parseQuery(search), [search])
|
|
2374
2740
|
|
|
@@ -2500,10 +2866,12 @@ export default function DevMailPage() {
|
|
|
2500
2866
|
|
|
2501
2867
|
const inboxFolderPredicate = useCallback(
|
|
2502
2868
|
(entry: InboundEmail) => {
|
|
2869
|
+
const asleep = Boolean(entry.snoozedUntil && Date.parse(entry.snoozedUntil) > Date.now())
|
|
2870
|
+
if (folder === 'snoozed') return asleep && !entry.trashed
|
|
2503
2871
|
if (folder === 'starred') return entry.starred && !entry.trashed
|
|
2504
2872
|
if (folder === 'archived') return entry.archived && !entry.trashed
|
|
2505
2873
|
if (folder === 'trash') return entry.trashed
|
|
2506
|
-
return !entry.archived && !entry.trashed
|
|
2874
|
+
return !entry.archived && !entry.trashed && !asleep
|
|
2507
2875
|
},
|
|
2508
2876
|
[folder],
|
|
2509
2877
|
)
|
|
@@ -2521,6 +2889,7 @@ export default function DevMailPage() {
|
|
|
2521
2889
|
|
|
2522
2890
|
// An action on a thread applies to every message in it, not just the representative.
|
|
2523
2891
|
const threadIds = useCallback((id: string): string[] => threadMembers(id).map(member => member.id), [threadMembers])
|
|
2892
|
+
useEffect(() => { threadIdsRef.current = threadIds }, [threadIds])
|
|
2524
2893
|
|
|
2525
2894
|
// Sent replies that belong to an inbound thread: matched by the recorded In-Reply-To
|
|
2526
2895
|
// Message-ID, falling back to normalized subject for historical sends with no linkage.
|
|
@@ -2593,7 +2962,7 @@ export default function DevMailPage() {
|
|
|
2593
2962
|
|
|
2594
2963
|
/** The folders backed by the paged inbound list, as opposed to ones loaded whole. */
|
|
2595
2964
|
const isInboundFolder =
|
|
2596
|
-
folder === 'inbox' || folder === 'starred' || folder === 'archived' || folder === 'trash'
|
|
2965
|
+
folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' || folder === 'trash'
|
|
2597
2966
|
|
|
2598
2967
|
const listItems = useMemo(() => {
|
|
2599
2968
|
const sentToItem = (entry: SentEmail) => ({
|
|
@@ -2609,12 +2978,12 @@ export default function DevMailPage() {
|
|
|
2609
2978
|
unread: false,
|
|
2610
2979
|
starred: entry.starred,
|
|
2611
2980
|
hasAttachment: false,
|
|
2612
|
-
chip: (folder === 'scheduled' ? 'scheduled' :
|
|
2981
|
+
chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
|
|
2613
2982
|
threadCount: 1,
|
|
2614
2983
|
latestAt: new Date(entry.createdAt).getTime(),
|
|
2615
2984
|
labels: [] as string[],
|
|
2616
2985
|
})
|
|
2617
|
-
if (
|
|
2986
|
+
if (isInboundFolder) {
|
|
2618
2987
|
const visible = inboxEmails
|
|
2619
2988
|
.filter(inboxFolderPredicate)
|
|
2620
2989
|
.filter(matchesInbound)
|
|
@@ -2626,11 +2995,14 @@ export default function DevMailPage() {
|
|
|
2626
2995
|
if (list) list.push(entry)
|
|
2627
2996
|
else groups.set(key, [entry])
|
|
2628
2997
|
}
|
|
2629
|
-
const inThreadFolder = (thread: ConversationRow) =>
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2998
|
+
const inThreadFolder = (thread: ConversationRow) => {
|
|
2999
|
+
const asleep = Boolean(thread.snoozedUntil && Date.parse(thread.snoozedUntil) > Date.now())
|
|
3000
|
+
if (folder === 'snoozed') return asleep
|
|
3001
|
+
if (folder === 'archived') return thread.archivedCount > 0
|
|
3002
|
+
if (folder === 'trash') return thread.trashedCount > 0
|
|
3003
|
+
if (folder === 'starred') return thread.starredCount > 0
|
|
3004
|
+
return thread.inboxCount > 0 && !asleep
|
|
3005
|
+
}
|
|
2634
3006
|
const threadItems = threads.length > 0 && !search.trim()
|
|
2635
3007
|
? threads
|
|
2636
3008
|
.filter(inThreadFolder)
|
|
@@ -2719,20 +3091,39 @@ export default function DevMailPage() {
|
|
|
2719
3091
|
labels: [] as string[],
|
|
2720
3092
|
}))
|
|
2721
3093
|
}
|
|
2722
|
-
const
|
|
3094
|
+
const searched = folder === 'sent' && sentSearchResults
|
|
3095
|
+
const source = folder === 'scheduled' ? scheduledEmails : searched ? sentSearchResults : deliveredEmails
|
|
2723
3096
|
return source
|
|
2724
|
-
.filter(entry => matches(`${entry.to.join(' ')} ${entry.subject}`))
|
|
2725
|
-
.filter(entry => !(folder === 'sent' && hideForwarded && isForwarded(entry.subject)))
|
|
3097
|
+
.filter(entry => searched || matches(`${entry.to.join(' ')} ${entry.subject}`))
|
|
2726
3098
|
.filter(entry => !(folder === 'sent' && (entry.archived || entry.trashed)))
|
|
2727
3099
|
.map(entry => sentToItem(entry))
|
|
2728
|
-
}, [folder, inboxEmails, drafts, scheduledEmails, deliveredEmails, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys,
|
|
3100
|
+
}, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search])
|
|
2729
3101
|
|
|
2730
|
-
const listSettling = searching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
|
|
3102
|
+
const listSettling = searching || sentSearching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
|
|
2731
3103
|
const listRefreshing = !listSettling && (mailboxStale || mailboxLoading || !threadsResolved)
|
|
2732
3104
|
|
|
2733
|
-
const inboxFolder =
|
|
3105
|
+
const inboxFolder = isInboundFolder
|
|
3106
|
+
|
|
3107
|
+
// Sent, drafts and scheduled really are lists of messages, so only the threaded folders
|
|
3108
|
+
// count in conversations. Where the folder total is known and the list has not reached
|
|
3109
|
+
// it, say both figures rather than let one grow silently towards the other.
|
|
3110
|
+
const loadedRows = listItems.length
|
|
3111
|
+
const listCountPaging = folderConversationTotal != null && loadedRows < folderConversationTotal
|
|
3112
|
+
const listCountFigure = listCountPaging
|
|
3113
|
+
? `${loadedRows.toLocaleString()} of ${folderConversationTotal!.toLocaleString()}`
|
|
3114
|
+
: (folderConversationTotal ?? loadedRows).toLocaleString()
|
|
3115
|
+
const listCountUnit = !isInboundFolder
|
|
3116
|
+
? loadedRows === 1 ? 'message' : 'messages'
|
|
3117
|
+
: listCountPaging
|
|
3118
|
+
? 'conversations loaded'
|
|
3119
|
+
: (folderConversationTotal ?? loadedRows) === 1 ? 'conversation' : 'conversations'
|
|
3120
|
+
const listCountTitle = `${listCountFigure} ${listCountUnit}`
|
|
3121
|
+
|
|
2734
3122
|
const selectedInbound = selectedId ? inboxEmails.find(entry => entry.id === selectedId) ?? null : null
|
|
2735
|
-
const selectedSent =
|
|
3123
|
+
const selectedSent =
|
|
3124
|
+
selectedId && !selectedInbound
|
|
3125
|
+
? sentEmails.find(entry => entry.id === selectedId) ?? sentSearchResults?.find(entry => entry.id === selectedId) ?? null
|
|
3126
|
+
: null
|
|
2736
3127
|
const selectedDetail = selectedSent ? detailCache[selectedSent.id] ?? null : null
|
|
2737
3128
|
const selectedIsThread = selectedInbound ? unifiedThread(selectedInbound.id).length > 1 : false
|
|
2738
3129
|
|
|
@@ -2854,12 +3245,49 @@ export default function DevMailPage() {
|
|
|
2854
3245
|
}
|
|
2855
3246
|
}, [selectedInbound, threadMembers, inboundAttachments, apiHeaders])
|
|
2856
3247
|
|
|
3248
|
+
/**
|
|
3249
|
+
* Every selection bumps this. A conversation fetch that finishes after a later selection
|
|
3250
|
+
* finds the number moved on and does nothing — otherwise clicking B while A was still
|
|
3251
|
+
* loading showed A's skeleton over B and then replaced B with A when A arrived.
|
|
3252
|
+
*/
|
|
3253
|
+
const openSeq = useRef(0)
|
|
3254
|
+
const openController = useRef<AbortController | null>(null)
|
|
3255
|
+
const cancelThreadOpen = () => {
|
|
3256
|
+
openSeq.current += 1
|
|
3257
|
+
openController.current?.abort()
|
|
3258
|
+
openController.current = null
|
|
3259
|
+
setThreadOpening(null)
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// Failing to open is worth a word. It used to go through loadError, which only renders
|
|
3263
|
+
// when the list itself is empty, so on a phone the pane slid in and out and said nothing.
|
|
3264
|
+
const [openError, setOpenError] = useState<string | null>(null)
|
|
3265
|
+
useEffect(() => {
|
|
3266
|
+
if (!openError) return
|
|
3267
|
+
const timer = window.setTimeout(() => setOpenError(null), 4500)
|
|
3268
|
+
return () => window.clearTimeout(timer)
|
|
3269
|
+
}, [openError])
|
|
3270
|
+
|
|
2857
3271
|
const openThread = async (threadId: string, latestId: string) => {
|
|
2858
3272
|
if (!inboxEmails.some(entry => entry.id === latestId)) {
|
|
3273
|
+
const seq = ++openSeq.current
|
|
3274
|
+
// Whatever the phone showed before the tap is what a failed open goes back to: the
|
|
3275
|
+
// list if they were on the list, the message if one was open. selectedId is the
|
|
3276
|
+
// wrong proxy for that, because Back on a phone hides the pane and leaves it set.
|
|
3277
|
+
// A pane still sliding in for an earlier tap does not count as open.
|
|
3278
|
+
const wasOpen = readerOpenMobile && !threadOpening
|
|
2859
3279
|
setThreadOpening(threadId)
|
|
3280
|
+
setReaderOpenMobile(true)
|
|
3281
|
+
const controller = new AbortController()
|
|
3282
|
+
openController.current = controller
|
|
3283
|
+
const giveUp = window.setTimeout(() => controller.abort(), 15000)
|
|
2860
3284
|
try {
|
|
2861
|
-
const response = await fetch(`/api/mail/inbox?thread=${encodeURIComponent(threadId)}&limit=200`, {
|
|
3285
|
+
const response = await fetch(`/api/mail/inbox?thread=${encodeURIComponent(threadId)}&limit=200`, {
|
|
3286
|
+
headers: apiHeaders(),
|
|
3287
|
+
signal: controller.signal,
|
|
3288
|
+
})
|
|
2862
3289
|
const data = await response.json().catch(() => null)
|
|
3290
|
+
if (seq !== openSeq.current) return
|
|
2863
3291
|
if (data?.ok && Array.isArray(data.emails)) {
|
|
2864
3292
|
const fresh = data.emails as InboundEmail[]
|
|
2865
3293
|
setInboxEmails(current => {
|
|
@@ -2868,14 +3296,20 @@ export default function DevMailPage() {
|
|
|
2868
3296
|
})
|
|
2869
3297
|
} else {
|
|
2870
3298
|
setThreadOpening(null)
|
|
2871
|
-
|
|
3299
|
+
setReaderOpenMobile(wasOpen)
|
|
3300
|
+
setOpenError('Could not open that conversation')
|
|
2872
3301
|
return
|
|
2873
3302
|
}
|
|
2874
3303
|
} catch (err) {
|
|
3304
|
+
if (seq !== openSeq.current) return
|
|
2875
3305
|
console.warn('[mail] thread load failed', err)
|
|
2876
3306
|
setThreadOpening(null)
|
|
2877
|
-
|
|
3307
|
+
setReaderOpenMobile(wasOpen)
|
|
3308
|
+
setOpenError('Could not open that conversation')
|
|
2878
3309
|
return
|
|
3310
|
+
} finally {
|
|
3311
|
+
window.clearTimeout(giveUp)
|
|
3312
|
+
if (openController.current === controller) openController.current = null
|
|
2879
3313
|
}
|
|
2880
3314
|
setThreadOpening(null)
|
|
2881
3315
|
}
|
|
@@ -2883,6 +3317,7 @@ export default function DevMailPage() {
|
|
|
2883
3317
|
}
|
|
2884
3318
|
|
|
2885
3319
|
const openInbound = (id: string) => {
|
|
3320
|
+
cancelThreadOpen()
|
|
2886
3321
|
// Reading and writing share the same panel, so the composer has to give it up.
|
|
2887
3322
|
// Closed through closeCompose rather than just hidden, so the draft is kept.
|
|
2888
3323
|
if (composeOpen) closeCompose()
|
|
@@ -2894,6 +3329,10 @@ export default function DevMailPage() {
|
|
|
2894
3329
|
}
|
|
2895
3330
|
}
|
|
2896
3331
|
|
|
3332
|
+
type InboundFlagPatch = Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>
|
|
3333
|
+
const setInboundFlagRef = useRef<(ids: string[], flags: InboundFlagPatch) => void>(() => {})
|
|
3334
|
+
const threadIdsRef = useRef<(id: string) => string[]>(id => [id])
|
|
3335
|
+
|
|
2897
3336
|
const setInboundFlag = useCallback(
|
|
2898
3337
|
(ids: string[], flags: Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>) => {
|
|
2899
3338
|
if (flags.read === true) ids.forEach(id => pendingRead.current.add(id))
|
|
@@ -2928,9 +3367,49 @@ export default function DevMailPage() {
|
|
|
2928
3367
|
[apiHeaders],
|
|
2929
3368
|
)
|
|
2930
3369
|
|
|
3370
|
+
/**
|
|
3371
|
+
* A row stands for a conversation, and the messages under it may not be loaded, so the
|
|
3372
|
+
* id the row carries is often not enough to act on. Naming the thread lets the server
|
|
3373
|
+
* find its messages; a row with no thread falls back to the ids we do hold.
|
|
3374
|
+
*/
|
|
3375
|
+
const commitRowFlags = useCallback(
|
|
3376
|
+
(id: string, threadId: string | null, flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>>) => {
|
|
3377
|
+
if (!threadId) {
|
|
3378
|
+
setInboundFlag(threadIds(id), flags)
|
|
3379
|
+
return
|
|
3380
|
+
}
|
|
3381
|
+
setInboxEmails(list =>
|
|
3382
|
+
list.map(entry => (threadKeys.get(entry.id) === threadKeys.get(id) ? { ...entry, ...flags } : entry)),
|
|
3383
|
+
)
|
|
3384
|
+
setThreads(list =>
|
|
3385
|
+
list.filter(thread => {
|
|
3386
|
+
if (thread.threadId !== threadId) return true
|
|
3387
|
+
// The row leaves the folder it was swiped out of; the next refresh restates it.
|
|
3388
|
+
return false
|
|
3389
|
+
}),
|
|
3390
|
+
)
|
|
3391
|
+
fetch('/api/mail/inbox', {
|
|
3392
|
+
method: 'PATCH',
|
|
3393
|
+
headers: apiHeaders(),
|
|
3394
|
+
body: JSON.stringify({ threadId, ...flags }),
|
|
3395
|
+
})
|
|
3396
|
+
.then(() => loadCounts(true))
|
|
3397
|
+
.catch(() => {})
|
|
3398
|
+
},
|
|
3399
|
+
[apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
|
|
3400
|
+
)
|
|
3401
|
+
useEffect(() => { swipeCommitRef.current = commitRowFlags }, [commitRowFlags])
|
|
3402
|
+
useEffect(() => { setInboundFlagRef.current = setInboundFlag }, [setInboundFlag])
|
|
3403
|
+
|
|
3404
|
+
const isSentId = useCallback(
|
|
3405
|
+
(id: string) => sentEmails.some(entry => entry.id === id) || Boolean(sentSearchResults?.some(entry => entry.id === id)),
|
|
3406
|
+
[sentEmails, sentSearchResults],
|
|
3407
|
+
)
|
|
3408
|
+
|
|
2931
3409
|
const setSentFlag = useCallback(
|
|
2932
3410
|
(ids: string[], flags: Partial<Pick<SentEmail, 'starred' | 'archived' | 'trashed'>>) => {
|
|
2933
3411
|
setSentEmails(list => list.map(entry => (ids.includes(entry.id) ? { ...entry, ...flags } : entry)))
|
|
3412
|
+
setSentSearchResults(results => (results ? results.map(entry => (ids.includes(entry.id) ? { ...entry, ...flags } : entry)) : results))
|
|
2934
3413
|
fetch('/api/mail/emails', {
|
|
2935
3414
|
method: 'PATCH',
|
|
2936
3415
|
headers: apiHeaders(),
|
|
@@ -2978,7 +3457,7 @@ export default function DevMailPage() {
|
|
|
2978
3457
|
const sentIds: string[] = []
|
|
2979
3458
|
for (const id of ids) {
|
|
2980
3459
|
if (inboxEmails.some(entry => entry.id === id)) inboundIds.push(...threadIds(id))
|
|
2981
|
-
else if (
|
|
3460
|
+
else if (isSentId(id)) sentIds.push(id)
|
|
2982
3461
|
}
|
|
2983
3462
|
if (inboundIds.length) setInboundFlag(Array.from(new Set(inboundIds)), flags)
|
|
2984
3463
|
if (sentIds.length) {
|
|
@@ -2990,7 +3469,7 @@ export default function DevMailPage() {
|
|
|
2990
3469
|
}
|
|
2991
3470
|
setSelectedBulk(new Set())
|
|
2992
3471
|
},
|
|
2993
|
-
[selectedBulk, inboxEmails,
|
|
3472
|
+
[selectedBulk, inboxEmails, isSentId, threadIds, setInboundFlag, setSentFlag],
|
|
2994
3473
|
)
|
|
2995
3474
|
|
|
2996
3475
|
const bulkDeleteDrafts = useCallback(() => {
|
|
@@ -3019,7 +3498,7 @@ export default function DevMailPage() {
|
|
|
3019
3498
|
const ids = Array.from(selectedBulk)
|
|
3020
3499
|
if (!ids.length || !ownerAddress) return
|
|
3021
3500
|
const inboundIds = ids.filter(id => inboxEmails.some(entry => entry.id === id))
|
|
3022
|
-
const sentIds = ids.filter(
|
|
3501
|
+
const sentIds = ids.filter(isSentId)
|
|
3023
3502
|
await Promise.all([
|
|
3024
3503
|
inboundIds.length
|
|
3025
3504
|
? fetch('/api/mail/inbox', { method: 'PATCH', headers: apiHeaders(), body: JSON.stringify({ ids: inboundIds, owner: ownerAddress }) })
|
|
@@ -3031,7 +3510,7 @@ export default function DevMailPage() {
|
|
|
3031
3510
|
setSelectedBulk(new Set())
|
|
3032
3511
|
refreshAll()
|
|
3033
3512
|
},
|
|
3034
|
-
[selectedBulk, inboxEmails,
|
|
3513
|
+
[selectedBulk, inboxEmails, isSentId, apiHeaders, refreshAll],
|
|
3035
3514
|
)
|
|
3036
3515
|
|
|
3037
3516
|
const suggestContacts = useCallback(
|
|
@@ -3104,7 +3583,7 @@ export default function DevMailPage() {
|
|
|
3104
3583
|
const signatureLogoHtml = signatureMarkSrc
|
|
3105
3584
|
? `<div style="margin-bottom:12px;"><img src="${absoluteLogo(signatureMarkSrc)}" alt="${escapeHtml(CLIENT_BRAND.name)}" width="200" style="${clientSignatureMarkStyle(200)}" /></div>`
|
|
3106
3585
|
: ''
|
|
3107
|
-
const signatureHtml = `<div style="margin-top:26px;padding-top:18px;border-top:1px solid #E8E2F4;">${signatureLogoHtml}${asRichHtml(dropUnreachableImages(signatureBody))}</div>`
|
|
3586
|
+
const signatureHtml = `<div style="margin-top:26px;padding-top:18px;border-top:1px solid #E8E2F4;line-height:1;">${signatureLogoHtml}${tightenSignature(asRichHtml(dropUnreachableImages(signatureBody)))}</div>`
|
|
3108
3587
|
const signatureText = `\n\n${htmlToPlainText(asRichHtml(signatureBody))}`
|
|
3109
3588
|
// Airy is not only more room: it is the setting for someone who never wants to see the
|
|
3110
3589
|
// source of a message or how it was authenticated.
|
|
@@ -3116,6 +3595,7 @@ export default function DevMailPage() {
|
|
|
3116
3595
|
}, [plainSpoken])
|
|
3117
3596
|
const fontCss = fontFaceCss(settings.fonts ?? [], CLIENT_BRAND.publicUrl)
|
|
3118
3597
|
const defaultFont = settings.defaultFont ?? EMPTY_FONT
|
|
3598
|
+
const readerSpacing = lineSpacingOf(defaultFont)
|
|
3119
3599
|
|
|
3120
3600
|
const [logoBusy, setLogoBusy] = useState(false)
|
|
3121
3601
|
const [logoMsg, setLogoMsg] = useState('')
|
|
@@ -3124,11 +3604,9 @@ export default function DevMailPage() {
|
|
|
3124
3604
|
// loading for the recipient long after any signed link would have expired.
|
|
3125
3605
|
const uploadInlineImage = useCallback(
|
|
3126
3606
|
async (file: File) => {
|
|
3127
|
-
const
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
body: file,
|
|
3131
|
-
})
|
|
3607
|
+
const headers = new Headers(apiHeaders())
|
|
3608
|
+
headers.set('Content-Type', file.type || 'application/octet-stream')
|
|
3609
|
+
const response = await fetch('/api/mail/signature-logo', { method: 'POST', headers, body: file })
|
|
3132
3610
|
const data = await response.json().catch(() => null)
|
|
3133
3611
|
if (!response.ok || !data?.ok) throw new Error(data?.error || 'That image could not be stored.')
|
|
3134
3612
|
return data.url as string
|
|
@@ -3219,13 +3697,14 @@ export default function DevMailPage() {
|
|
|
3219
3697
|
}
|
|
3220
3698
|
}, [compose, composeOpen, saveDraftNow])
|
|
3221
3699
|
|
|
3222
|
-
const openCompose = (data?: Partial<ComposeData>, existingDraftId?: string) => {
|
|
3700
|
+
const openCompose = (data?: Partial<ComposeData>, existingDraftId?: string, options?: { keepAttachments?: boolean }) => {
|
|
3223
3701
|
// Full width unless a message is open — replying wants the original in view.
|
|
3224
3702
|
setComposeExpanded(!selectedId)
|
|
3225
3703
|
// The sender name is whoever is signed in — their profile name, else the account
|
|
3226
3704
|
// name, else the local part of their address — not a fixed company name.
|
|
3227
3705
|
setCompose({ ...EMPTY_COMPOSE, fromName: identityName, ...data })
|
|
3228
|
-
|
|
3706
|
+
// A reply handed over from the dock brings its files with it; everything else starts clean.
|
|
3707
|
+
if (!options?.keepAttachments) setAttachments([])
|
|
3229
3708
|
setComposeError('')
|
|
3230
3709
|
setShowCcBcc(Boolean(data?.cc?.length || data?.bcc?.length))
|
|
3231
3710
|
setDraftId(existingDraftId ?? null)
|
|
@@ -3237,7 +3716,17 @@ export default function DevMailPage() {
|
|
|
3237
3716
|
setComposeOpen(false)
|
|
3238
3717
|
}
|
|
3239
3718
|
|
|
3240
|
-
const discardCompose = () => {
|
|
3719
|
+
const discardCompose = async () => {
|
|
3720
|
+
const hasWork = compose.bodyHtml.trim() || compose.subject.trim() || compose.to.length || attachments.length
|
|
3721
|
+
if (hasWork) {
|
|
3722
|
+
const agreed = await confirm({
|
|
3723
|
+
title: 'Discard this message?',
|
|
3724
|
+
body: 'What you have written and attached will be thrown away.',
|
|
3725
|
+
confirmLabel: 'Discard',
|
|
3726
|
+
danger: true,
|
|
3727
|
+
})
|
|
3728
|
+
if (!agreed) return
|
|
3729
|
+
}
|
|
3241
3730
|
if (draftId) {
|
|
3242
3731
|
deleteStashItem('draft', draftId, apiHeaders())
|
|
3243
3732
|
setDrafts(current => current.filter(entry => entry.id !== draftId))
|
|
@@ -3246,6 +3735,7 @@ export default function DevMailPage() {
|
|
|
3246
3735
|
}
|
|
3247
3736
|
|
|
3248
3737
|
const openDraft = (id: string) => {
|
|
3738
|
+
cancelThreadOpen()
|
|
3249
3739
|
const draft = drafts.find(entry => entry.id === id)
|
|
3250
3740
|
if (!draft) return
|
|
3251
3741
|
openCompose(draft.data, draft.id)
|
|
@@ -3785,6 +4275,20 @@ export default function DevMailPage() {
|
|
|
3785
4275
|
// Recipients come from the (collapsible) To/Cc/Bcc fields; an untouched To falls back to the sender.
|
|
3786
4276
|
const buildReplyDraft = (entry: InboundEmail): ComposeData => {
|
|
3787
4277
|
const target = quickReplyTarget(entry)
|
|
4278
|
+
if (replyBar === 'forward') {
|
|
4279
|
+
return {
|
|
4280
|
+
...EMPTY_COMPOSE,
|
|
4281
|
+
to: replyToList,
|
|
4282
|
+
cc: replyCc,
|
|
4283
|
+
bcc: replyBcc,
|
|
4284
|
+
subject: target.subject.startsWith('Fwd:') ? target.subject : `Fwd: ${target.subject}`,
|
|
4285
|
+
markdown: quickReply,
|
|
4286
|
+
htmlSource: replyHtml,
|
|
4287
|
+
htmlDirty: replyHtmlDirty,
|
|
4288
|
+
useSignature: replySig,
|
|
4289
|
+
quoteHtml: forwardQuote(target.subject, target.html, target.text),
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
3788
4292
|
const fallback = defaultReplyRecipient(entry)
|
|
3789
4293
|
// Replying to everyone is the default: a conversation that reached four people is
|
|
3790
4294
|
// answered to those four, and trimming the list is the deliberate act.
|
|
@@ -3846,7 +4350,7 @@ export default function DevMailPage() {
|
|
|
3846
4350
|
|
|
3847
4351
|
const sendQuickReply = async (entry: InboundEmail) => {
|
|
3848
4352
|
const body = quickReply.trim() || (replyHtmlDirty ? replyHtml.trim() : '')
|
|
3849
|
-
if (!body || quickSending) return
|
|
4353
|
+
if ((!body && replyBar !== 'forward') || quickSending) return
|
|
3850
4354
|
const draft = buildReplyDraft(entry)
|
|
3851
4355
|
if (!draft.to.length) {
|
|
3852
4356
|
openReplySettings(entry)
|
|
@@ -3896,6 +4400,7 @@ export default function DevMailPage() {
|
|
|
3896
4400
|
setReplyHtml('')
|
|
3897
4401
|
setReplyHtmlDirty(false)
|
|
3898
4402
|
setReplyMode('write')
|
|
4403
|
+
setReplyBar(null)
|
|
3899
4404
|
setReplyToList([])
|
|
3900
4405
|
setReplyCc([])
|
|
3901
4406
|
setReplyBcc([])
|
|
@@ -3911,8 +4416,61 @@ export default function DevMailPage() {
|
|
|
3911
4416
|
}
|
|
3912
4417
|
|
|
3913
4418
|
// The settings icon on the reply bar hands the typed text off to the full composer (Cc/Bcc/formatting/attachments).
|
|
4419
|
+
/**
|
|
4420
|
+
* Replying to something you sent writes to the same people again, quoting what went out.
|
|
4421
|
+
* The message's own id stands in for a Message-ID, as it already does for inbound mail.
|
|
4422
|
+
*/
|
|
4423
|
+
const replyToSent = (sent: SentEmail, detail: SentDetail | null, everyone: boolean) => {
|
|
4424
|
+
const to = sent.to.map(parseAddress)
|
|
4425
|
+
const cc = everyone ? (detail?.cc ?? sent.cc ?? []).map(parseAddress).filter(address => !to.includes(address)) : []
|
|
4426
|
+
openCompose({
|
|
4427
|
+
to,
|
|
4428
|
+
cc,
|
|
4429
|
+
subject: sent.subject.startsWith('Re:') ? sent.subject : `Re: ${sent.subject}`,
|
|
4430
|
+
quoteHtml: quoteBlock(sent.from, sent.createdAt, detail?.html ?? null, detail?.text ?? null),
|
|
4431
|
+
useSignature: true,
|
|
4432
|
+
inReplyTo: sent.id,
|
|
4433
|
+
})
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
/**
|
|
4437
|
+
* On a phone the docked reply becomes the composer as its own page. Everything typed or
|
|
4438
|
+
* attached in the dock travels with it — the older hand-off quietly dropped the files.
|
|
4439
|
+
*/
|
|
4440
|
+
const openReplyPage = (entry: InboundEmail) => {
|
|
4441
|
+
const draft = buildReplyDraft(entry)
|
|
4442
|
+
const target = quickReplyTarget(entry)
|
|
4443
|
+
const typed = quickReply.trim()
|
|
4444
|
+
const bodyHtml = draft.htmlDirty && draft.htmlSource.trim() ? draft.htmlSource : typed ? markdownToHtml(typed) : ''
|
|
4445
|
+
openCompose(
|
|
4446
|
+
{
|
|
4447
|
+
to: draft.to,
|
|
4448
|
+
cc: draft.cc,
|
|
4449
|
+
bcc: draft.bcc,
|
|
4450
|
+
subject: draft.subject,
|
|
4451
|
+
bodyHtml,
|
|
4452
|
+
quoteHtml: quoteBlock(target.from, target.receivedAt, target.html, target.text),
|
|
4453
|
+
useSignature: draft.useSignature,
|
|
4454
|
+
inReplyTo: draft.inReplyTo,
|
|
4455
|
+
},
|
|
4456
|
+
undefined,
|
|
4457
|
+
{ keepAttachments: true },
|
|
4458
|
+
)
|
|
4459
|
+
setComposeExpanded(true)
|
|
4460
|
+
setQuickReply('')
|
|
4461
|
+
setReplyHtml('')
|
|
4462
|
+
setReplyHtmlDirty(false)
|
|
4463
|
+
setReplyMode('write')
|
|
4464
|
+
setReplyToList([])
|
|
4465
|
+
setReplyCc([])
|
|
4466
|
+
setReplyBcc([])
|
|
4467
|
+
setReplyRecipsOpen(false)
|
|
4468
|
+
setReplyRecipsEdited(false)
|
|
4469
|
+
}
|
|
4470
|
+
|
|
3914
4471
|
const openReplySettings = (entry: InboundEmail) => {
|
|
3915
|
-
|
|
4472
|
+
if (replyBar === 'forward') forwardEmail(entry.subject, entry.html, entry.text, entry.id)
|
|
4473
|
+
else replyAllTo(entry)
|
|
3916
4474
|
const typed = quickReply.trim()
|
|
3917
4475
|
if (typed) setCompose(data => ({ ...data, bodyHtml: markdownToHtml(typed) + data.bodyHtml, useSignature: replySig }))
|
|
3918
4476
|
setQuickReply('')
|
|
@@ -3954,6 +4512,15 @@ export default function DevMailPage() {
|
|
|
3954
4512
|
}
|
|
3955
4513
|
}
|
|
3956
4514
|
|
|
4515
|
+
// Carries the whole original mail (full HTML), not a stripped snippet.
|
|
4516
|
+
const forwardQuote = (subject: string, html: string | null, text: string | null): string => {
|
|
4517
|
+
if (html && html.trim()) {
|
|
4518
|
+
const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${CLIENT_BRAND.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br>Subject: ${escapeHtml(subject)}</div>`
|
|
4519
|
+
return `${header}${html}`
|
|
4520
|
+
}
|
|
4521
|
+
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#030712;">${inlineEmailStyles(markdownToHtml(`---\n\nForwarded message:\n\n${text ?? ''}`))}</div>`
|
|
4522
|
+
}
|
|
4523
|
+
|
|
3957
4524
|
const forwardEmail = (
|
|
3958
4525
|
subject: string,
|
|
3959
4526
|
html: string | null,
|
|
@@ -3962,20 +4529,34 @@ export default function DevMailPage() {
|
|
|
3962
4529
|
kind: 'inbound' | 'sent' = 'inbound',
|
|
3963
4530
|
) => {
|
|
3964
4531
|
const fwSubject = subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`
|
|
3965
|
-
|
|
3966
|
-
// Carry the whole original mail (full HTML), not a stripped snippet.
|
|
3967
|
-
const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${CLIENT_BRAND.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br>Subject: ${escapeHtml(subject)}</div>`
|
|
3968
|
-
openCompose({ subject: fwSubject, quoteHtml: `${header}${html}` })
|
|
3969
|
-
} else {
|
|
3970
|
-
openCompose({
|
|
3971
|
-
subject: fwSubject,
|
|
3972
|
-
quoteHtml: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#030712;">${inlineEmailStyles(markdownToHtml(`---\n\nForwarded message:\n\n${text ?? ''}`))}</div>`,
|
|
3973
|
-
})
|
|
3974
|
-
}
|
|
4532
|
+
openCompose({ subject: fwSubject, quoteHtml: forwardQuote(subject, html, text) })
|
|
3975
4533
|
// After openCompose, which clears the list the files are about to go into.
|
|
3976
4534
|
if (messageId) void carryForwardAttachments(messageId, kind)
|
|
3977
4535
|
}
|
|
3978
4536
|
|
|
4537
|
+
const openReplyBar = (entry: InboundEmail, kind: 'reply' | 'all' | 'forward') => {
|
|
4538
|
+
setReplyBar(kind)
|
|
4539
|
+
setReplyRecipsOpen(kind === 'forward')
|
|
4540
|
+
if (kind === 'all') {
|
|
4541
|
+
const { to, cc } = replyAllRecipients(entry)
|
|
4542
|
+
setReplyToList(to)
|
|
4543
|
+
setReplyCc(cc)
|
|
4544
|
+
setReplyRecipsEdited(true)
|
|
4545
|
+
return
|
|
4546
|
+
}
|
|
4547
|
+
setReplyToList([])
|
|
4548
|
+
setReplyCc([])
|
|
4549
|
+
setReplyRecipsEdited(kind === 'forward')
|
|
4550
|
+
if (kind === 'forward') void carryForwardAttachments(entry.id)
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
const startReply = (entry: InboundEmail, kind: 'reply' | 'all' | 'forward') => {
|
|
4554
|
+
if (!isPhone && settings.replyStyle === 'mini') return openReplyBar(entry, kind)
|
|
4555
|
+
if (kind === 'reply') replyTo(entry)
|
|
4556
|
+
else if (kind === 'all') replyAllTo(entry)
|
|
4557
|
+
else forwardEmail(entry.subject, entry.html, entry.text, entry.id)
|
|
4558
|
+
}
|
|
4559
|
+
|
|
3979
4560
|
const printMessage = (subject: string, from: string, when: string, html: string | null, text: string | null, recipients: { to?: string[]; cc?: string[] } = {}) => {
|
|
3980
4561
|
const frame = document.createElement('iframe')
|
|
3981
4562
|
frame.style.position = 'fixed'
|
|
@@ -4009,7 +4590,8 @@ export default function DevMailPage() {
|
|
|
4009
4590
|
// print-color-adjust keeps the mark from being dropped by "background graphics off".
|
|
4010
4591
|
const printCss = `<style>
|
|
4011
4592
|
@page { margin: 14mm; }
|
|
4012
|
-
body { margin: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
4593
|
+
body { margin: 0; line-height: ${readerSpacing}; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
4594
|
+
p { margin: 0 0 ${paragraphGap(readerSpacing)}; }
|
|
4013
4595
|
img { max-width: 100%; }
|
|
4014
4596
|
</style>`
|
|
4015
4597
|
doc.open()
|
|
@@ -4062,6 +4644,7 @@ export default function DevMailPage() {
|
|
|
4062
4644
|
if (composeOpen) closeCompose()
|
|
4063
4645
|
else if (filesOpen) setFilesOpen(false)
|
|
4064
4646
|
else if (settingsOpen) closeSettings()
|
|
4647
|
+
else if (threadOpening) { cancelThreadOpen(); setReaderOpenMobile(false) }
|
|
4065
4648
|
else if (readerOpenMobile) setReaderOpenMobile(false)
|
|
4066
4649
|
else if (selectedId) setSelectedId(null)
|
|
4067
4650
|
return
|
|
@@ -4091,20 +4674,29 @@ export default function DevMailPage() {
|
|
|
4091
4674
|
moveSelection(-1)
|
|
4092
4675
|
return
|
|
4093
4676
|
}
|
|
4677
|
+
if (!selectedInbound && selectedSent) {
|
|
4678
|
+
if (event.key === 'r') { event.preventDefault(); replyToSent(selectedSent, selectedDetail, settings.replyAllDefault); return }
|
|
4679
|
+
if (event.key === 'a') { event.preventDefault(); replyToSent(selectedSent, selectedDetail, true); return }
|
|
4680
|
+
if (event.key === 'f') {
|
|
4681
|
+
event.preventDefault()
|
|
4682
|
+
forwardEmail(selectedSent.subject, selectedDetail?.html ?? null, selectedDetail?.text ?? null, selectedSent.id, 'sent')
|
|
4683
|
+
return
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4094
4686
|
const inbound = selectedInbound
|
|
4095
4687
|
if (!inbound) return
|
|
4096
4688
|
switch (event.key) {
|
|
4097
4689
|
case 'r':
|
|
4098
4690
|
event.preventDefault()
|
|
4099
|
-
|
|
4691
|
+
startReply(inbound, 'reply')
|
|
4100
4692
|
break
|
|
4101
4693
|
case 'a':
|
|
4102
4694
|
event.preventDefault()
|
|
4103
|
-
|
|
4695
|
+
startReply(inbound, 'all')
|
|
4104
4696
|
break
|
|
4105
4697
|
case 'f':
|
|
4106
4698
|
event.preventDefault()
|
|
4107
|
-
|
|
4699
|
+
startReply(inbound, 'forward')
|
|
4108
4700
|
break
|
|
4109
4701
|
case 's':
|
|
4110
4702
|
event.preventDefault()
|
|
@@ -4137,7 +4729,7 @@ export default function DevMailPage() {
|
|
|
4137
4729
|
useEffect(() => {
|
|
4138
4730
|
if (!isLoggedIn || !settings.desktopNotifications || notifyPermission !== 'granted') return
|
|
4139
4731
|
void subscribePush(apiHeaders())
|
|
4140
|
-
}, [isLoggedIn, settings.desktopNotifications, notifyPermission, apiHeaders])
|
|
4732
|
+
}, [isLoggedIn, settings.desktopNotifications, notifyPermission, apiHeaders, selectedSent, selectedDetail])
|
|
4141
4733
|
|
|
4142
4734
|
useEffect(() => {
|
|
4143
4735
|
announce(
|
|
@@ -4366,6 +4958,7 @@ export default function DevMailPage() {
|
|
|
4366
4958
|
const folderTitles: Record<Folder, string> = {
|
|
4367
4959
|
inbox: 'Inbox',
|
|
4368
4960
|
starred: 'Starred',
|
|
4961
|
+
snoozed: 'Snoozed',
|
|
4369
4962
|
sent: 'Sent',
|
|
4370
4963
|
scheduled: 'Scheduled',
|
|
4371
4964
|
drafts: 'Drafts',
|
|
@@ -4611,7 +5204,7 @@ export default function DevMailPage() {
|
|
|
4611
5204
|
)}
|
|
4612
5205
|
</button>
|
|
4613
5206
|
<div className={styles.attachInfo}>
|
|
4614
|
-
<
|
|
5207
|
+
<Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
|
|
4615
5208
|
<span className={styles.attachMeta}>
|
|
4616
5209
|
{attachment.size ? formatSize(attachment.size) : null}
|
|
4617
5210
|
{attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
|
|
@@ -4744,7 +5337,7 @@ export default function DevMailPage() {
|
|
|
4744
5337
|
<span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
|
|
4745
5338
|
</span>
|
|
4746
5339
|
)}
|
|
4747
|
-
<
|
|
5340
|
+
<Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
|
|
4748
5341
|
<span className={styles.attachChipSize}>{state}</span>
|
|
4749
5342
|
{attachment.error && attachment.file && (
|
|
4750
5343
|
<button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
|
|
@@ -4803,20 +5396,23 @@ export default function DevMailPage() {
|
|
|
4803
5396
|
}
|
|
4804
5397
|
|
|
4805
5398
|
const renderInboundAttachments = (message: InboundEmail) => {
|
|
4806
|
-
if (!message.
|
|
5399
|
+
if (!visibleAttachments(message).length) return null
|
|
4807
5400
|
// The stored-blob lookup answers with an empty array for anything it does not
|
|
4808
5401
|
// hold, which is every file sent as a share link. Falling back only on null
|
|
4809
5402
|
// left the header counting attachments the grid then refused to draw.
|
|
4810
5403
|
const fetched = inboundAttachments[message.id]
|
|
4811
|
-
const
|
|
5404
|
+
const inline = inlineAttachmentNames(message)
|
|
5405
|
+
const list: DownloadAttachment[] = (fetched?.length
|
|
4812
5406
|
? fetched
|
|
4813
5407
|
: message.attachments.map(entry => ({
|
|
4814
5408
|
filename: entry.filename,
|
|
4815
5409
|
size: entry.size ?? 0,
|
|
4816
5410
|
downloadUrl: entry.shareId ? `/share/${entry.shareId}` : '',
|
|
4817
5411
|
shareId: entry.shareId,
|
|
5412
|
+
contentId: entry.contentId,
|
|
4818
5413
|
}))
|
|
4819
|
-
|
|
5414
|
+
).filter(entry => !inline.has(entry.filename))
|
|
5415
|
+
return list.length ? renderAttachmentTiles(list) : null
|
|
4820
5416
|
}
|
|
4821
5417
|
|
|
4822
5418
|
const renderInboundBody = (message: InboundEmail, framed: boolean) => {
|
|
@@ -4834,7 +5430,7 @@ export default function DevMailPage() {
|
|
|
4834
5430
|
<iframe
|
|
4835
5431
|
className={styles.threadFrame}
|
|
4836
5432
|
sandbox="allow-same-origin"
|
|
4837
|
-
srcDoc={frameHtml(message.html, showRemote)}
|
|
5433
|
+
srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')}
|
|
4838
5434
|
title="Email content"
|
|
4839
5435
|
onLoad={event => {
|
|
4840
5436
|
try {
|
|
@@ -4845,7 +5441,7 @@ export default function DevMailPage() {
|
|
|
4845
5441
|
/>
|
|
4846
5442
|
)
|
|
4847
5443
|
}
|
|
4848
|
-
return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote)} title="Email content" />
|
|
5444
|
+
return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
4849
5445
|
}
|
|
4850
5446
|
|
|
4851
5447
|
// One message open at a time — opening another collapses the rest.
|
|
@@ -4861,7 +5457,7 @@ export default function DevMailPage() {
|
|
|
4861
5457
|
<iframe
|
|
4862
5458
|
className={styles.threadFrame}
|
|
4863
5459
|
sandbox="allow-same-origin"
|
|
4864
|
-
srcDoc={frameHtml(detail.html, true)}
|
|
5460
|
+
srcDoc={frameHtml(detail.html, true, readerSpacing, resolvedTheme === 'dark')}
|
|
4865
5461
|
title="Sent email"
|
|
4866
5462
|
onLoad={event => {
|
|
4867
5463
|
try {
|
|
@@ -4996,7 +5592,7 @@ export default function DevMailPage() {
|
|
|
4996
5592
|
mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
|
|
4997
5593
|
).slice(0, 100)
|
|
4998
5594
|
const unread = !mine && !item.inbound.read
|
|
4999
|
-
const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : item.inbound.
|
|
5595
|
+
const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
|
|
5000
5596
|
const shellClass =
|
|
5001
5597
|
layout === 'bubbles'
|
|
5002
5598
|
? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
|
|
@@ -5028,9 +5624,9 @@ export default function DevMailPage() {
|
|
|
5028
5624
|
<span className={styles.threadMsgMeta}>
|
|
5029
5625
|
<span className={styles.threadMsgFrom}>
|
|
5030
5626
|
{unread && <span className={styles.unreadDot} />}
|
|
5031
|
-
{from}
|
|
5627
|
+
<Ticker className={styles.threadMsgFromText}>{from}</Ticker>
|
|
5032
5628
|
</span>
|
|
5033
|
-
{!open && <
|
|
5629
|
+
{!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
|
|
5034
5630
|
</span>
|
|
5035
5631
|
{hasAttach && (
|
|
5036
5632
|
<span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
|
|
@@ -5102,6 +5698,7 @@ export default function DevMailPage() {
|
|
|
5102
5698
|
}
|
|
5103
5699
|
|
|
5104
5700
|
const reader = (() => {
|
|
5701
|
+
if (threadOpening) return <ReaderSkeleton />
|
|
5105
5702
|
if (selectedInbound) {
|
|
5106
5703
|
const inbound = selectedInbound
|
|
5107
5704
|
const inboundMembers = threadMembers(inbound.id)
|
|
@@ -5151,9 +5748,9 @@ export default function DevMailPage() {
|
|
|
5151
5748
|
<div className={styles.readerMeta}>
|
|
5152
5749
|
<div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
|
|
5153
5750
|
<div className={styles.readerMetaText}>
|
|
5154
|
-
<
|
|
5155
|
-
<
|
|
5156
|
-
{inbound.cc.length > 0 && <
|
|
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>}
|
|
5157
5754
|
</div>
|
|
5158
5755
|
<div className={styles.readerDate}>
|
|
5159
5756
|
{new Date(inbound.receivedAt).toLocaleString()}
|
|
@@ -5177,15 +5774,15 @@ export default function DevMailPage() {
|
|
|
5177
5774
|
)}
|
|
5178
5775
|
</div>
|
|
5179
5776
|
<div className={styles.actions}>
|
|
5180
|
-
<button className={styles.actionBtn} onClick={() =>
|
|
5777
|
+
<button className={styles.actionBtn} onClick={() => startReply(inbound, 'reply')}>
|
|
5181
5778
|
{ICONS.reply} Reply
|
|
5182
5779
|
</button>
|
|
5183
5780
|
{!settings.replyAllDefault && (
|
|
5184
|
-
<button className={styles.actionBtn} onClick={() =>
|
|
5781
|
+
<button className={styles.actionBtn} onClick={() => startReply(inbound, 'all')}>
|
|
5185
5782
|
{ICONS.replyAll} Reply all
|
|
5186
5783
|
</button>
|
|
5187
5784
|
)}
|
|
5188
|
-
<button className={styles.actionBtn} onClick={() =>
|
|
5785
|
+
<button className={styles.actionBtn} onClick={() => startReply(inbound, 'forward')}>
|
|
5189
5786
|
{ICONS.forward} Forward
|
|
5190
5787
|
</button>
|
|
5191
5788
|
<span className={styles.actionSpacer} />
|
|
@@ -5230,6 +5827,50 @@ export default function DevMailPage() {
|
|
|
5230
5827
|
{inbound.archived ? ICONS.restore : ICONS.archive}
|
|
5231
5828
|
</button>
|
|
5232
5829
|
)}
|
|
5830
|
+
{inbound.threadId && (() => {
|
|
5831
|
+
// A snooze that has already come round leaves its timestamp behind, since
|
|
5832
|
+
// nothing runs to clear it. Asleep means the time is still ahead, which is
|
|
5833
|
+
// what every folder query asks too.
|
|
5834
|
+
const wakesAt = inbound.snoozedUntil ? Date.parse(inbound.snoozedUntil) : 0
|
|
5835
|
+
const asleep = wakesAt > Date.now()
|
|
5836
|
+
return (
|
|
5837
|
+
<span className={styles.snoozeWrap}>
|
|
5838
|
+
<button
|
|
5839
|
+
className={`${styles.iconBtn} ${snoozeMenuOpen ? styles.iconBtnOn : ''}`}
|
|
5840
|
+
title={
|
|
5841
|
+
asleep
|
|
5842
|
+
? `Snoozed until ${new Date(wakesAt).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })} — wake it now`
|
|
5843
|
+
: 'Snooze'
|
|
5844
|
+
}
|
|
5845
|
+
aria-haspopup="menu"
|
|
5846
|
+
aria-expanded={snoozeMenuOpen}
|
|
5847
|
+
onClick={() => {
|
|
5848
|
+
if (asleep) applySnooze(inbound.threadId ?? null, null)
|
|
5849
|
+
else setSnoozeMenuOpen(open => !open)
|
|
5850
|
+
}}
|
|
5851
|
+
>
|
|
5852
|
+
{ICONS.snooze}
|
|
5853
|
+
</button>
|
|
5854
|
+
{snoozeMenuOpen && (
|
|
5855
|
+
<div className={styles.snoozeMenu} role="menu">
|
|
5856
|
+
{snoozeChoices.map(choice => (
|
|
5857
|
+
<button
|
|
5858
|
+
key={choice.key}
|
|
5859
|
+
role="menuitem"
|
|
5860
|
+
className={styles.snoozeItem}
|
|
5861
|
+
onClick={() => applySnooze(inbound.threadId ?? null, choice.at)}
|
|
5862
|
+
>
|
|
5863
|
+
<span>{choice.label}</span>
|
|
5864
|
+
<span className={styles.snoozeWhen}>
|
|
5865
|
+
{choice.at.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })}
|
|
5866
|
+
</span>
|
|
5867
|
+
</button>
|
|
5868
|
+
))}
|
|
5869
|
+
</div>
|
|
5870
|
+
)}
|
|
5871
|
+
</span>
|
|
5872
|
+
)
|
|
5873
|
+
})()}
|
|
5233
5874
|
<button
|
|
5234
5875
|
className={styles.iconBtn}
|
|
5235
5876
|
title={inbound.read ? 'Mark unread' : 'Mark read'}
|
|
@@ -5388,6 +6029,28 @@ export default function DevMailPage() {
|
|
|
5388
6029
|
</div>
|
|
5389
6030
|
)}
|
|
5390
6031
|
{folder !== 'trash' && (() => {
|
|
6032
|
+
if (isPhone) {
|
|
6033
|
+
return (
|
|
6034
|
+
<div className={styles.replyLaunch}>
|
|
6035
|
+
<button type="button" className={styles.replyLaunchBtn} onClick={() => openReplyPage(inbound)}>
|
|
6036
|
+
{ICONS.reply}
|
|
6037
|
+
<span>{quickReply.trim() ? quickReply.trim().slice(0, 60) : `Reply${settings.replyAllDefault ? ' to everyone' : ''}…`}</span>
|
|
6038
|
+
</button>
|
|
6039
|
+
{!settings.replyAllDefault && (
|
|
6040
|
+
<button
|
|
6041
|
+
type="button"
|
|
6042
|
+
className={styles.replyLaunchAll}
|
|
6043
|
+
title="Reply all"
|
|
6044
|
+
aria-label="Reply all"
|
|
6045
|
+
onClick={() => { replyAllTo(inbound); setComposeExpanded(true) }}
|
|
6046
|
+
>
|
|
6047
|
+
{ICONS.replyAll}
|
|
6048
|
+
</button>
|
|
6049
|
+
)}
|
|
6050
|
+
</div>
|
|
6051
|
+
)
|
|
6052
|
+
}
|
|
6053
|
+
if (!replyBar && !quickReply.trim() && !replyHtmlDirty) return null
|
|
5391
6054
|
const replyDraft = buildReplyDraft(inbound)
|
|
5392
6055
|
const replyEffHtml = buildEmailHtml(replyDraft, signatureHtml, fontCss, defaultFont)
|
|
5393
6056
|
const replyEffText = buildEmailText(replyDraft, signatureText)
|
|
@@ -5400,7 +6063,8 @@ export default function DevMailPage() {
|
|
|
5400
6063
|
'',
|
|
5401
6064
|
replyEffHtml,
|
|
5402
6065
|
].filter(line => line !== null).join('\n')
|
|
5403
|
-
const
|
|
6066
|
+
const hasBody = Boolean(quickReply.trim() || (replyHtmlDirty && replyHtml.trim()))
|
|
6067
|
+
const canSend = (hasBody || replyBar === 'forward') && (replyBar !== 'forward' || replyDraft.to.length > 0) && !quickSending
|
|
5404
6068
|
const recipSummary = [
|
|
5405
6069
|
`To ${replyDraft.to.join(', ') || '—'}`,
|
|
5406
6070
|
replyDraft.cc.length ? `Cc ${replyDraft.cc.length}` : null,
|
|
@@ -5450,7 +6114,7 @@ export default function DevMailPage() {
|
|
|
5450
6114
|
title="Recipients — Cc, Bcc"
|
|
5451
6115
|
>
|
|
5452
6116
|
<span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
|
|
5453
|
-
<
|
|
6117
|
+
<Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
|
|
5454
6118
|
</button>
|
|
5455
6119
|
</div>
|
|
5456
6120
|
{replyRecipsOpen && (
|
|
@@ -5556,7 +6220,7 @@ export default function DevMailPage() {
|
|
|
5556
6220
|
<textarea
|
|
5557
6221
|
ref={quickReplyRef}
|
|
5558
6222
|
className={styles.quickReplyInput}
|
|
5559
|
-
style={{ fontFamily: fontStack(defaultFont.family), fontSize: defaultFont.size || undefined }}
|
|
6223
|
+
style={{ fontFamily: fontStack(defaultFont.family), fontSize: defaultFont.size || undefined, lineHeight: lineSpacingOf(defaultFont) }}
|
|
5560
6224
|
value={quickReply}
|
|
5561
6225
|
onChange={event => { setQuickReply(event.target.value); setReplyHtmlDirty(false) }}
|
|
5562
6226
|
onPaste={event => {
|
|
@@ -5572,7 +6236,7 @@ export default function DevMailPage() {
|
|
|
5572
6236
|
sendQuickReply(inbound)
|
|
5573
6237
|
}
|
|
5574
6238
|
}}
|
|
5575
|
-
placeholder={`Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
|
|
6239
|
+
placeholder={replyBar === 'forward' ? 'Add a note to go with the forwarded message…' : `Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
|
|
5576
6240
|
rows={1}
|
|
5577
6241
|
/>
|
|
5578
6242
|
)}
|
|
@@ -5593,7 +6257,7 @@ export default function DevMailPage() {
|
|
|
5593
6257
|
<iframe
|
|
5594
6258
|
className={styles.replyPreview}
|
|
5595
6259
|
sandbox="allow-same-origin"
|
|
5596
|
-
srcDoc={frameHtml(replyEffHtml, true)}
|
|
6260
|
+
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
|
|
5597
6261
|
title="Reply preview"
|
|
5598
6262
|
onLoad={event => {
|
|
5599
6263
|
try {
|
|
@@ -5669,8 +6333,8 @@ export default function DevMailPage() {
|
|
|
5669
6333
|
<div className={styles.readerMeta}>
|
|
5670
6334
|
<div className={styles.avatar}>N</div>
|
|
5671
6335
|
<div className={styles.readerMetaText}>
|
|
5672
|
-
<
|
|
5673
|
-
<
|
|
6336
|
+
<Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
|
|
6337
|
+
<Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {selectedSent.to.join(', ')}</Ticker>
|
|
5674
6338
|
</div>
|
|
5675
6339
|
<div className={styles.readerDate}>
|
|
5676
6340
|
{isScheduled && selectedSent.scheduledAt
|
|
@@ -5703,14 +6367,13 @@ export default function DevMailPage() {
|
|
|
5703
6367
|
</>
|
|
5704
6368
|
) : (
|
|
5705
6369
|
<>
|
|
5706
|
-
<
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
</span>
|
|
6370
|
+
<button className={styles.actionBtn} onClick={() => replyToSent(selectedSent, selectedDetail, settings.replyAllDefault)}>
|
|
6371
|
+
{ICONS.reply} Reply
|
|
6372
|
+
</button>
|
|
6373
|
+
{!settings.replyAllDefault && (
|
|
6374
|
+
<button className={styles.actionBtn} onClick={() => replyToSent(selectedSent, selectedDetail, true)}>
|
|
6375
|
+
{ICONS.replyAll} Reply all
|
|
6376
|
+
</button>
|
|
5714
6377
|
)}
|
|
5715
6378
|
<button
|
|
5716
6379
|
className={styles.actionBtn}
|
|
@@ -5786,7 +6449,10 @@ export default function DevMailPage() {
|
|
|
5786
6449
|
</div>
|
|
5787
6450
|
)}
|
|
5788
6451
|
{(() => {
|
|
5789
|
-
const
|
|
6452
|
+
const opened: MailEvent[] = selectedSent.openedAt
|
|
6453
|
+
? [{ emailId: selectedSent.id, type: 'email.opened', at: selectedSent.openedAt, meta: selectedSent.openCount > 1 ? { count: String(selectedSent.openCount) } : undefined }]
|
|
6454
|
+
: []
|
|
6455
|
+
const ordered = [...(eventsByEmail[selectedSent.id] ?? []), ...opened].sort(
|
|
5790
6456
|
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
|
5791
6457
|
)
|
|
5792
6458
|
const seen = new Set<string>()
|
|
@@ -5820,7 +6486,10 @@ export default function DevMailPage() {
|
|
|
5820
6486
|
: ''
|
|
5821
6487
|
}`}
|
|
5822
6488
|
/>
|
|
5823
|
-
<span className={styles.eventType}>
|
|
6489
|
+
<span className={styles.eventType}>
|
|
6490
|
+
{event.type.replace('email.', '')}
|
|
6491
|
+
{event.meta?.count ? ` ×${event.meta.count}` : ''}
|
|
6492
|
+
</span>
|
|
5824
6493
|
<span className={styles.eventTime}>{new Date(event.at).toLocaleString()}</span>
|
|
5825
6494
|
</div>
|
|
5826
6495
|
{event.meta?.link && (
|
|
@@ -5839,7 +6508,7 @@ export default function DevMailPage() {
|
|
|
5839
6508
|
{!selectedDetail ? (
|
|
5840
6509
|
<BodySkeleton />
|
|
5841
6510
|
) : selectedDetail.html ? (
|
|
5842
|
-
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true)} title="Email content" />
|
|
6511
|
+
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5843
6512
|
) : (
|
|
5844
6513
|
<pre className={styles.readerText}>{selectedDetail.text ?? '(no content)'}</pre>
|
|
5845
6514
|
)}
|
|
@@ -5870,6 +6539,7 @@ export default function DevMailPage() {
|
|
|
5870
6539
|
),
|
|
5871
6540
|
['--acting-offset' as string]: `${actingBarHeight}px`,
|
|
5872
6541
|
['--brand-mark' as string]: `url('${CLIENT_BRAND.chromeMarkUrl}')`,
|
|
6542
|
+
['--mail-lead' as string]: String(readerSpacing),
|
|
5873
6543
|
['--list-w' as string]: `${listWidth}px`,
|
|
5874
6544
|
} as React.CSSProperties}
|
|
5875
6545
|
>
|
|
@@ -5886,39 +6556,85 @@ export default function DevMailPage() {
|
|
|
5886
6556
|
</button>
|
|
5887
6557
|
</div>
|
|
5888
6558
|
)}
|
|
5889
|
-
{
|
|
5890
|
-
<
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
6559
|
+
{(() => {
|
|
6560
|
+
const notices: Array<{ key: string; title: string; body: string; dismiss: string; action: string; onDismiss: () => void; onAction: () => void }> = []
|
|
6561
|
+
if (isLoggedIn && !settings.desktopNotifications && notifyPermission !== 'denied' && notifyPermission !== 'unsupported' && !notifyNoticeHidden) {
|
|
6562
|
+
notices.push({
|
|
6563
|
+
key: 'notifications',
|
|
6564
|
+
title: 'Turn on notifications',
|
|
6565
|
+
body: 'Hear about new mail the moment it arrives, even with this tab in the background or the app closed.',
|
|
6566
|
+
dismiss: 'Not now',
|
|
6567
|
+
action: 'Turn on',
|
|
6568
|
+
onDismiss: dismissNotifyNotice,
|
|
6569
|
+
onAction: () => {
|
|
6570
|
+
void (async () => {
|
|
6571
|
+
const result = await requestNotifyPermission()
|
|
6572
|
+
if (result === 'denied') dismissNotifyNotice()
|
|
6573
|
+
if (result !== 'granted') return
|
|
6574
|
+
const ok = await subscribePush(apiHeaders())
|
|
6575
|
+
setNotifyNoticeHidden(true)
|
|
6576
|
+
if (ok) setMailSettings(current => ({ ...current, desktopNotifications: true }))
|
|
6577
|
+
else {
|
|
6578
|
+
setSettingsOpen(true)
|
|
6579
|
+
setSettingsTab('notifications')
|
|
6580
|
+
}
|
|
6581
|
+
})()
|
|
6582
|
+
},
|
|
6583
|
+
})
|
|
6584
|
+
}
|
|
6585
|
+
if (account?.defaultPassword && !pwNoticeHidden) {
|
|
6586
|
+
notices.push({
|
|
6587
|
+
key: 'password',
|
|
6588
|
+
title: 'Set your own password',
|
|
6589
|
+
body: 'This mailbox still uses the password it was created with, which is your own address. Anyone who knows it could sign in as you.',
|
|
6590
|
+
dismiss: 'Not now',
|
|
6591
|
+
action: 'Change it',
|
|
6592
|
+
onDismiss: () => setPwNoticeHidden(true),
|
|
6593
|
+
onAction: () => {
|
|
6594
|
+
setSettingsOpen(true)
|
|
6595
|
+
setSettingsTab('profile')
|
|
6596
|
+
setPwNoticeHidden(true)
|
|
6597
|
+
setPwFocusRequest(request => request + 1)
|
|
6598
|
+
},
|
|
6599
|
+
})
|
|
6600
|
+
}
|
|
6601
|
+
if (!notices.length) return null
|
|
6602
|
+
return notices.slice(0, 3).map((notice, depth) => (
|
|
6603
|
+
<div
|
|
6604
|
+
key={notice.key}
|
|
6605
|
+
className={`${styles.pwNotice} ${depth > 0 ? styles.pwNoticeBehind : ''}`}
|
|
6606
|
+
style={{ ['--depth' as string]: depth } as React.CSSProperties}
|
|
6607
|
+
role={depth === 0 ? 'status' : undefined}
|
|
6608
|
+
aria-hidden={depth > 0}
|
|
6609
|
+
>
|
|
6610
|
+
{depth === 0 && notices.length > 1 && (
|
|
6611
|
+
<span className={styles.pwNoticeMore}>{notices.length - 1} more</span>
|
|
6612
|
+
)}
|
|
6613
|
+
<div className={styles.pwNoticeText}>
|
|
6614
|
+
<p className={styles.pwNoticeTitle}>{notice.title}</p>
|
|
6615
|
+
<p className={styles.pwNoticeSub}>{notice.body}</p>
|
|
6616
|
+
</div>
|
|
6617
|
+
<div className={styles.pwNoticeActions}>
|
|
6618
|
+
<button type="button" className={styles.pwNoticeDismiss} onClick={notice.onDismiss} tabIndex={depth === 0 ? 0 : -1}>
|
|
6619
|
+
{notice.dismiss}
|
|
6620
|
+
</button>
|
|
6621
|
+
<button type="button" className={styles.pwNoticeGo} onClick={notice.onAction} tabIndex={depth === 0 ? 0 : -1}>
|
|
6622
|
+
{notice.action}
|
|
6623
|
+
</button>
|
|
6624
|
+
</div>
|
|
5918
6625
|
</div>
|
|
5919
|
-
|
|
5920
|
-
)}
|
|
6626
|
+
))
|
|
6627
|
+
})()}
|
|
5921
6628
|
<div className={styles.topRight}>
|
|
6629
|
+
{isPhone && (
|
|
6630
|
+
<button
|
|
6631
|
+
className={`${styles.themeToggle} ${refreshing ? styles.spinning : ''}`}
|
|
6632
|
+
onClick={refreshAll}
|
|
6633
|
+
aria-label="Refresh"
|
|
6634
|
+
>
|
|
6635
|
+
{ICONS.refresh}
|
|
6636
|
+
</button>
|
|
6637
|
+
)}
|
|
5922
6638
|
{canInstall && !installed && (
|
|
5923
6639
|
<button
|
|
5924
6640
|
className={styles.themeToggle}
|
|
@@ -5928,17 +6644,19 @@ export default function DevMailPage() {
|
|
|
5928
6644
|
{ICONS.install}
|
|
5929
6645
|
</button>
|
|
5930
6646
|
)}
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
6647
|
+
{!isPhone && (
|
|
6648
|
+
<button
|
|
6649
|
+
className={styles.themeToggle}
|
|
6650
|
+
onClick={() => { setSettingsTab('notifications'); setSettingsOpen(true) }}
|
|
6651
|
+
aria-label={
|
|
6652
|
+
settings.desktopNotifications && notifyPermission === 'granted'
|
|
6653
|
+
? 'Notifications are on. Open notification settings.'
|
|
6654
|
+
: 'Notifications are off. Open notification settings.'
|
|
6655
|
+
}
|
|
6656
|
+
>
|
|
6657
|
+
{settings.desktopNotifications && notifyPermission === 'granted' ? ICONS.bell : ICONS.bellOff}
|
|
6658
|
+
</button>
|
|
6659
|
+
)}
|
|
5942
6660
|
<div
|
|
5943
6661
|
className={styles.themeSwitcher}
|
|
5944
6662
|
onMouseEnter={openThemeMenu}
|
|
@@ -6054,12 +6772,14 @@ export default function DevMailPage() {
|
|
|
6054
6772
|
<span className={styles.composePen}>{ICONS.pencil}</span>
|
|
6055
6773
|
<span className={styles.railLabel}>Compose</span>
|
|
6056
6774
|
</button>
|
|
6057
|
-
{(['inbox', 'starred', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
|
|
6775
|
+
{(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
|
|
6058
6776
|
const count =
|
|
6059
6777
|
key === 'inbox'
|
|
6060
6778
|
? folderCounts.inbox || ''
|
|
6061
6779
|
: key === 'starred'
|
|
6062
6780
|
? folderCounts.starred || ''
|
|
6781
|
+
: key === 'snoozed'
|
|
6782
|
+
? folderCounts.snoozed || ''
|
|
6063
6783
|
: key === 'sent'
|
|
6064
6784
|
? deliveredEmails.filter(entry => !entry.archived && !entry.trashed).length || ''
|
|
6065
6785
|
: key === 'scheduled'
|
|
@@ -6075,6 +6795,7 @@ export default function DevMailPage() {
|
|
|
6075
6795
|
className={`${styles.folder} ${folder === key ? styles.folderActive : ''}`}
|
|
6076
6796
|
title={folderTitles[key]}
|
|
6077
6797
|
onClick={() => {
|
|
6798
|
+
cancelThreadOpen()
|
|
6078
6799
|
setFolder(key)
|
|
6079
6800
|
setSelectedId(null)
|
|
6080
6801
|
setReaderOpenMobile(false)
|
|
@@ -6147,16 +6868,9 @@ export default function DevMailPage() {
|
|
|
6147
6868
|
{ICONS.menu}
|
|
6148
6869
|
</button>
|
|
6149
6870
|
<h1 className={styles.listTitle}>{folderTitles[folder]}</h1>
|
|
6150
|
-
<span
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
>
|
|
6154
|
-
{listItems.length.toLocaleString()}{' '}
|
|
6155
|
-
<span className={styles.listMetaUnit}>
|
|
6156
|
-
{isInboundFolder
|
|
6157
|
-
? listItems.length === 1 ? 'conversation' : 'conversations'
|
|
6158
|
-
: listItems.length === 1 ? 'message' : 'messages'}
|
|
6159
|
-
</span>
|
|
6871
|
+
<span className={styles.listMeta} title={listCountTitle}>
|
|
6872
|
+
{listCountFigure}{' '}
|
|
6873
|
+
<span className={styles.listMetaUnit}>{listCountUnit}</span>
|
|
6160
6874
|
</span>
|
|
6161
6875
|
<button
|
|
6162
6876
|
className={`${styles.refreshBtn} ${refreshing ? styles.spinning : ''}`}
|
|
@@ -6166,6 +6880,11 @@ export default function DevMailPage() {
|
|
|
6166
6880
|
{ICONS.refresh}
|
|
6167
6881
|
</button>
|
|
6168
6882
|
</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'} →
|
|
6886
|
+
</button>
|
|
6887
|
+
)}
|
|
6169
6888
|
<div className={styles.search}>
|
|
6170
6889
|
{ICONS.search}
|
|
6171
6890
|
<input
|
|
@@ -6318,6 +7037,26 @@ export default function DevMailPage() {
|
|
|
6318
7037
|
<span className={styles.srOnly}>Checking for new mail</span>
|
|
6319
7038
|
</div>
|
|
6320
7039
|
)}
|
|
7040
|
+
<div
|
|
7041
|
+
className={styles.pullHint}
|
|
7042
|
+
style={{ height: pullDistance, opacity: pullDistance > 6 ? 1 : 0 }}
|
|
7043
|
+
aria-hidden
|
|
7044
|
+
>
|
|
7045
|
+
<span className={`${styles.pullSpinner} ${pullDistance >= PULL_TRIGGER * 0.75 ? styles.pullSpinnerReady : ''}`}>
|
|
7046
|
+
{ICONS.refresh}
|
|
7047
|
+
</span>
|
|
7048
|
+
</div>
|
|
7049
|
+
<button
|
|
7050
|
+
type="button"
|
|
7051
|
+
className={`${styles.toTop} ${listScrolled ? styles.toTopOn : ''}`}
|
|
7052
|
+
onClick={scrollListToTop}
|
|
7053
|
+
aria-label="Back to the top of the list"
|
|
7054
|
+
title="Back to top"
|
|
7055
|
+
tabIndex={listScrolled ? 0 : -1}
|
|
7056
|
+
aria-hidden={!listScrolled}
|
|
7057
|
+
>
|
|
7058
|
+
{ICONS.chevron}
|
|
7059
|
+
</button>
|
|
6321
7060
|
<div className={styles.list} ref={listRef}>
|
|
6322
7061
|
{listSettling ? (
|
|
6323
7062
|
Array.from({ length: 7 }).map((_, index) => (
|
|
@@ -6352,6 +7091,7 @@ export default function DevMailPage() {
|
|
|
6352
7091
|
<p className={styles.emptyTitle}>
|
|
6353
7092
|
{folder === 'inbox' && 'Inbox zero'}
|
|
6354
7093
|
{folder === 'starred' && 'No starred mail'}
|
|
7094
|
+
{folder === 'snoozed' && 'Nothing snoozed'}
|
|
6355
7095
|
{folder === 'sent' && 'Nothing sent yet'}
|
|
6356
7096
|
{folder === 'scheduled' && 'Nothing scheduled'}
|
|
6357
7097
|
{folder === 'drafts' && 'No drafts'}
|
|
@@ -6362,6 +7102,8 @@ export default function DevMailPage() {
|
|
|
6362
7102
|
{folder === 'inbox' &&
|
|
6363
7103
|
'Nothing new right now. Messages sent to your address will appear here.'}
|
|
6364
7104
|
{folder === 'starred' && 'Star a message and it will be kept here so you can find it again.'}
|
|
7105
|
+
{folder === 'snoozed' &&
|
|
7106
|
+
'Put a conversation aside and it waits here, then returns to the inbox by itself at the time you chose.'}
|
|
6365
7107
|
{folder === 'sent' && 'Everything you send is kept here, newest first.'}
|
|
6366
7108
|
{folder === 'scheduled' && 'Messages waiting to go out. You can still edit or cancel them.'}
|
|
6367
7109
|
{folder === 'drafts' && 'Unfinished messages are saved here automatically, so nothing is lost.'}
|
|
@@ -6370,18 +7112,49 @@ export default function DevMailPage() {
|
|
|
6370
7112
|
</p>
|
|
6371
7113
|
</div>
|
|
6372
7114
|
) : (
|
|
6373
|
-
|
|
7115
|
+
<>
|
|
7116
|
+
{swipe && (() => {
|
|
7117
|
+
const action = swipe.dx > 0 ? swipeActions.right : swipeActions.left
|
|
7118
|
+
if (!action || swipe.dx === 0) return null
|
|
7119
|
+
return (
|
|
7120
|
+
<div
|
|
7121
|
+
className={`${styles.swipeBand} ${swipe.dx > 0 ? styles.swipeBandRight : styles.swipeBandLeft} ${Math.abs(swipe.dx) >= SWIPE_COMMIT ? styles.swipeBandArmed : ''}`}
|
|
7122
|
+
style={{ top: swipe.top, height: swipe.height }}
|
|
7123
|
+
aria-hidden
|
|
7124
|
+
>
|
|
7125
|
+
<span>{action.label}</span>
|
|
7126
|
+
</div>
|
|
7127
|
+
)
|
|
7128
|
+
})()}
|
|
7129
|
+
{listItems.map(item => (
|
|
6374
7130
|
<button
|
|
6375
7131
|
key={item.id}
|
|
6376
7132
|
className={`${styles.item} ${selectedId === item.id ? styles.itemActive : ''} ${item.unread ? styles.itemUnread : ''} ${selectedBulk.has(item.id) ? styles.itemChecked : ''} ${'threadId' in item && item.threadId && threadOpening === item.threadId ? styles.itemOpening : ''}`}
|
|
6377
7133
|
aria-busy={'threadId' in item && item.threadId ? threadOpening === item.threadId : undefined}
|
|
7134
|
+
style={
|
|
7135
|
+
swipe?.id === item.id
|
|
7136
|
+
// Two things get in the way of a row following a finger: the staggered
|
|
7137
|
+
// entry animation, which outranks an inline transform, and a 160ms
|
|
7138
|
+
// transform transition, which makes the row chase the finger instead of
|
|
7139
|
+
// tracking it. Both stand down for the duration of the drag.
|
|
7140
|
+
? { transform: `translateX(${swipe.dx}px)`, animation: 'none', transition: 'none' }
|
|
7141
|
+
: undefined
|
|
7142
|
+
}
|
|
7143
|
+
onTouchStart={item.kind === 'inbound' ? event => onRowTouchStart(item.id, 'threadId' in item ? item.threadId ?? null : null, event) : undefined}
|
|
7144
|
+
onTouchMove={item.kind === 'inbound' ? onRowTouchMove : undefined}
|
|
7145
|
+
onTouchEnd={item.kind === 'inbound' ? onRowTouchEnd : undefined}
|
|
7146
|
+
onTouchCancel={item.kind === 'inbound' ? onRowTouchEnd : undefined}
|
|
6378
7147
|
onClick={() => {
|
|
7148
|
+
// A swipe ends on the row it started on, so without this the release
|
|
7149
|
+
// opens the message the reader just archived.
|
|
7150
|
+
if (swipeRef.current?.moved) return
|
|
6379
7151
|
if (item.kind === 'draft') {
|
|
6380
7152
|
openDraft(item.id)
|
|
6381
7153
|
} else if (item.kind === 'inbound') {
|
|
6382
7154
|
if ('threadId' in item && item.threadId) void openThread(item.threadId, item.id)
|
|
6383
7155
|
else openInbound(item.id)
|
|
6384
7156
|
} else {
|
|
7157
|
+
cancelThreadOpen()
|
|
6385
7158
|
setSelectedId(item.id)
|
|
6386
7159
|
setReaderOpenMobile(true)
|
|
6387
7160
|
}
|
|
@@ -6408,15 +7181,15 @@ export default function DevMailPage() {
|
|
|
6408
7181
|
</span>
|
|
6409
7182
|
<span className={styles.itemBody}>
|
|
6410
7183
|
<span className={styles.itemTop}>
|
|
6411
|
-
<
|
|
7184
|
+
<Ticker className={styles.itemFrom}>{item.primary}</Ticker>
|
|
6412
7185
|
{item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
|
|
6413
7186
|
<span className={styles.itemTime}>{item.time}</span>
|
|
6414
7187
|
</span>
|
|
6415
7188
|
<p className={styles.itemSubject}>
|
|
6416
7189
|
{item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
|
|
6417
|
-
{item.subject}
|
|
7190
|
+
<Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
|
|
6418
7191
|
</p>
|
|
6419
|
-
{item.snippet && <
|
|
7192
|
+
{item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
|
|
6420
7193
|
{item.labels.length > 0 && (
|
|
6421
7194
|
<span className={styles.itemLabels}>
|
|
6422
7195
|
{item.labels.map(labelId => {
|
|
@@ -6507,7 +7280,8 @@ export default function DevMailPage() {
|
|
|
6507
7280
|
</span>
|
|
6508
7281
|
)}
|
|
6509
7282
|
</button>
|
|
6510
|
-
))
|
|
7283
|
+
))}
|
|
7284
|
+
</>
|
|
6511
7285
|
)}
|
|
6512
7286
|
{/* Nothing to load more of when nothing is listed: the skeleton under an empty
|
|
6513
7287
|
folder read as a mailbox that never finished loading. */}
|
|
@@ -6532,16 +7306,14 @@ export default function DevMailPage() {
|
|
|
6532
7306
|
{!moreCursor && listItems.length > 0 && (
|
|
6533
7307
|
<div className={styles.listEnd}>
|
|
6534
7308
|
{!isInboundFolder
|
|
6535
|
-
?
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
: `All ${inboxTotal.toLocaleString()} messages`
|
|
6544
|
-
: `${inboxEmails.length.toLocaleString()} of ${inboxTotal.toLocaleString()} messages`}
|
|
7309
|
+
? sentSearchTruncated
|
|
7310
|
+
? `Newest ${listItems.length.toLocaleString()} matches shown — narrow the search to reach older ones`
|
|
7311
|
+
: `${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'message' : 'messages'}`
|
|
7312
|
+
: search.trim() && inboxTotal
|
|
7313
|
+
// A search knows how many messages it matched but not how many
|
|
7314
|
+
// conversations they fall into, so say both rather than neither.
|
|
7315
|
+
? `${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'conversation' : 'conversations'} from ${inboxTotal.toLocaleString()} matching ${inboxTotal === 1 ? 'message' : 'messages'}`
|
|
7316
|
+
: `All ${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'conversation' : 'conversations'} loaded`}
|
|
6545
7317
|
</div>
|
|
6546
7318
|
)}
|
|
6547
7319
|
</div>
|
|
@@ -6554,7 +7326,7 @@ export default function DevMailPage() {
|
|
|
6554
7326
|
back to. On a wide one it is beside us, and the only thing to do is close. */}
|
|
6555
7327
|
<button
|
|
6556
7328
|
className={styles.actionBtn}
|
|
6557
|
-
onClick={() => (listHidden
|
|
7329
|
+
onClick={() => { cancelThreadOpen(); if (listHidden) setReaderOpenMobile(false); else setSelectedId(null) }}
|
|
6558
7330
|
>
|
|
6559
7331
|
{listHidden ? <>{ICONS.back} Back</> : <>{ICONS.close} Close</>}
|
|
6560
7332
|
</button>
|
|
@@ -6563,7 +7335,7 @@ export default function DevMailPage() {
|
|
|
6563
7335
|
{/* Keyed so React rebuilds it when the selection changes, which is what lets the
|
|
6564
7336
|
animation replay: switching threads swapped the panel with no sign that
|
|
6565
7337
|
anything had happened. */}
|
|
6566
|
-
<div className={styles.readerSwap} key={selectedId ?? 'empty'}>
|
|
7338
|
+
<div className={styles.readerSwap} key={threadOpening ? `opening:${threadOpening}` : (selectedId ?? 'empty')}>
|
|
6567
7339
|
{reader}
|
|
6568
7340
|
</div>
|
|
6569
7341
|
</section>
|
|
@@ -6597,7 +7369,9 @@ export default function DevMailPage() {
|
|
|
6597
7369
|
>
|
|
6598
7370
|
<div className={styles.composeHead}>
|
|
6599
7371
|
<h2 className={styles.composeTitle}>New message</h2>
|
|
6600
|
-
<span className={styles.composeHint}>
|
|
7372
|
+
<span className={styles.composeHint}>
|
|
7373
|
+
drafts autosave<span className={styles.composeHintKeys}> · Esc to close</span>
|
|
7374
|
+
</span>
|
|
6601
7375
|
<div className={styles.composeHeadActions}>
|
|
6602
7376
|
<button
|
|
6603
7377
|
className={styles.composeExpandBtn}
|
|
@@ -6812,9 +7586,9 @@ export default function DevMailPage() {
|
|
|
6812
7586
|
/>
|
|
6813
7587
|
)}
|
|
6814
7588
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
6815
|
-
<details className={styles.composeQuote}
|
|
7589
|
+
<details className={styles.composeQuote}>
|
|
6816
7590
|
<summary className={styles.composeQuoteLabel}>Quoted message</summary>
|
|
6817
|
-
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true)} title="Quoted message" />
|
|
7591
|
+
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
|
|
6818
7592
|
</details>
|
|
6819
7593
|
)}
|
|
6820
7594
|
{/* The signature is appended on send, so show it here rather than leaving the
|
|
@@ -7447,6 +8221,7 @@ export default function DevMailPage() {
|
|
|
7447
8221
|
<div className={styles.fontAdd}>
|
|
7448
8222
|
<MailSelect
|
|
7449
8223
|
ariaLabel="Default font"
|
|
8224
|
+
prefix={<span className={styles.selectGlyphSerif}>A</span>}
|
|
7450
8225
|
value={settings.defaultFont?.family ?? ''}
|
|
7451
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 }))]}
|
|
7452
8227
|
optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
|
|
@@ -7454,6 +8229,7 @@ export default function DevMailPage() {
|
|
|
7454
8229
|
/>
|
|
7455
8230
|
<MailSelect
|
|
7456
8231
|
ariaLabel="Default size"
|
|
8232
|
+
prefix="tT"
|
|
7457
8233
|
editable
|
|
7458
8234
|
placeholder="15"
|
|
7459
8235
|
value={settings.defaultFont?.size ?? ''}
|
|
@@ -7467,6 +8243,32 @@ export default function DevMailPage() {
|
|
|
7467
8243
|
</div>
|
|
7468
8244
|
<p className={styles.settingsNote}>Applied to everything you write; a font or size picked in the editor still wins for that text.</p>
|
|
7469
8245
|
</div>
|
|
8246
|
+
<div className={styles.settingsField}>
|
|
8247
|
+
<span>Line spacing</span>
|
|
8248
|
+
<div className={styles.fontAdd}>
|
|
8249
|
+
<MailSelect
|
|
8250
|
+
ariaLabel="Line spacing"
|
|
8251
|
+
prefix="↕"
|
|
8252
|
+
editable
|
|
8253
|
+
placeholder={String(DEFAULT_LINE_SPACING)}
|
|
8254
|
+
value={settings.defaultFont?.lineSpacing ? String(settings.defaultFont.lineSpacing) : ''}
|
|
8255
|
+
options={[
|
|
8256
|
+
{ value: '', label: `${DEFAULT_LINE_SPACING} (standard)` },
|
|
8257
|
+
...LINE_SPACINGS.filter(spacing => spacing !== DEFAULT_LINE_SPACING).map(spacing => ({ value: String(spacing), label: String(spacing) })),
|
|
8258
|
+
]}
|
|
8259
|
+
onChange={raw => {
|
|
8260
|
+
const spacing = parseFloat(raw)
|
|
8261
|
+
if (raw.trim() === '' || raw.startsWith(`${DEFAULT_LINE_SPACING} (`)) {
|
|
8262
|
+
setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), lineSpacing: undefined } }))
|
|
8263
|
+
return
|
|
8264
|
+
}
|
|
8265
|
+
if (!(Number.isFinite(spacing) && spacing >= 0.8 && spacing <= 3)) return
|
|
8266
|
+
setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), lineSpacing: spacing } }))
|
|
8267
|
+
}}
|
|
8268
|
+
/>
|
|
8269
|
+
</div>
|
|
8270
|
+
<p className={styles.settingsNote}>The space between lines and paragraphs in everything you write, in the editor and as recipients see it. 1 is single, 2 is double. The Spacing control in the editor overrides it for the paragraphs you pick.</p>
|
|
8271
|
+
</div>
|
|
7470
8272
|
<div className={styles.settingsField}>
|
|
7471
8273
|
<span>Fonts</span>
|
|
7472
8274
|
<p className={styles.settingsNote}>
|
|
@@ -7521,6 +8323,14 @@ export default function DevMailPage() {
|
|
|
7521
8323
|
/>
|
|
7522
8324
|
<span>Reply to everyone in the conversation by default. With this off, a reply goes to the sender and a separate Reply all sits beside it.</span>
|
|
7523
8325
|
</label>
|
|
8326
|
+
<label className={styles.settingsToggle}>
|
|
8327
|
+
<input
|
|
8328
|
+
type="checkbox"
|
|
8329
|
+
checked={settings.replyStyle === 'mini'}
|
|
8330
|
+
onChange={event => setMailSettings(current => ({ ...current, replyStyle: event.target.checked ? 'mini' : 'panel' }))}
|
|
8331
|
+
/>
|
|
8332
|
+
<span>Reply in the small bar under the conversation. With this off, Reply, Reply all and Forward open the full composer.</span>
|
|
8333
|
+
</label>
|
|
7524
8334
|
<label className={styles.settingsToggle}>
|
|
7525
8335
|
<input
|
|
7526
8336
|
type="checkbox"
|
|
@@ -7961,6 +8771,11 @@ export default function DevMailPage() {
|
|
|
7961
8771
|
</button>
|
|
7962
8772
|
</div>
|
|
7963
8773
|
)}
|
|
8774
|
+
{openError && !undo && (
|
|
8775
|
+
<div className={`${styles.toast} ${styles.toastError}`} role="alert">
|
|
8776
|
+
{openError}
|
|
8777
|
+
</div>
|
|
8778
|
+
)}
|
|
7964
8779
|
{sentFlash && !undo && (
|
|
7965
8780
|
<div className={styles.toast}>
|
|
7966
8781
|
<span className={sentFlash === 'Sent' ? styles.toastOk : ''}>{sentFlash}</span>
|