@novacraft-engineering/mailbox 0.4.25 → 0.4.26

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.
@@ -7262,3 +7262,278 @@
7262
7262
  .fieldBad,
7263
7263
  .msgBad { animation: none; }
7264
7264
  }
7265
+
7266
+ /* Writing tools */
7267
+ .rteBody {
7268
+ position: relative;
7269
+ display: flex;
7270
+ flex-direction: column;
7271
+ flex: 1;
7272
+ min-height: 0;
7273
+ }
7274
+ .rteCorner {
7275
+ position: absolute;
7276
+ top: 8px;
7277
+ right: 12px;
7278
+ z-index: 5;
7279
+ display: flex;
7280
+ gap: 4px;
7281
+ }
7282
+ .rteCornerBtn {
7283
+ display: inline-flex;
7284
+ align-items: center;
7285
+ gap: 2px;
7286
+ height: 24px;
7287
+ padding: 0 8px;
7288
+ border: 1px solid var(--nc-line-1);
7289
+ border-radius: 999px;
7290
+ background: var(--nc-ink-2);
7291
+ color: var(--nc-fg-3);
7292
+ font: 600 11px/1 var(--nc-font-sans);
7293
+ cursor: pointer;
7294
+ }
7295
+ .rteCornerBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
7296
+ .rteCornerOn { color: var(--nc-violet-500); border-color: var(--nc-violet-300); }
7297
+ .rteWordCount {
7298
+ align-self: flex-end;
7299
+ padding: 4px 18px 8px;
7300
+ color: var(--nc-fg-4);
7301
+ font: 500 11px/1 var(--nc-font-sans);
7302
+ font-variant-numeric: tabular-nums;
7303
+ }
7304
+ .rteContent :global(.rteMisspelt) {
7305
+ text-decoration: underline wavy var(--nc-danger);
7306
+ text-decoration-skip-ink: none;
7307
+ text-underline-offset: 3px;
7308
+ cursor: pointer;
7309
+ }
7310
+ .rteContent :global(.rteRepeat) {
7311
+ text-decoration: underline wavy var(--nc-warn);
7312
+ text-underline-offset: 3px;
7313
+ cursor: pointer;
7314
+ }
7315
+ .rteContent :global(.rteGrammar) {
7316
+ text-decoration: underline wavy #3B82F6;
7317
+ text-underline-offset: 3px;
7318
+ cursor: pointer;
7319
+ }
7320
+ .rteContent :global(.rteFind) { background: color-mix(in srgb, var(--nc-warn) 30%, transparent); border-radius: 2px; }
7321
+ .rteContent :global(.rteFindCurrent) { background: color-mix(in srgb, var(--nc-warn) 70%, transparent); border-radius: 2px; }
7322
+
7323
+ .rteSuggest {
7324
+ position: absolute;
7325
+ z-index: 45;
7326
+ min-width: 180px;
7327
+ max-width: 300px;
7328
+ display: flex;
7329
+ flex-direction: column;
7330
+ padding: 6px;
7331
+ border: 1px solid var(--nc-line-1);
7332
+ border-radius: 10px;
7333
+ background: var(--nc-ink-2);
7334
+ box-shadow: 0 14px 36px var(--nc-line-soft);
7335
+ }
7336
+ .rteSuggestPick {
7337
+ padding: 6px 10px;
7338
+ border: 0;
7339
+ border-radius: 6px;
7340
+ background: transparent;
7341
+ color: var(--nc-fg-1);
7342
+ font: 600 13px/1.3 var(--nc-font-sans);
7343
+ text-align: left;
7344
+ cursor: pointer;
7345
+ }
7346
+ .rteSuggestPick:hover { background: var(--nc-ink-3); }
7347
+ .rteSuggestNote { margin: 4px 10px 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
7348
+ .rteSuggestActions {
7349
+ display: flex;
7350
+ flex-wrap: wrap;
7351
+ gap: 4px;
7352
+ margin-top: 4px;
7353
+ padding-top: 6px;
7354
+ border-top: 1px solid var(--nc-line-1);
7355
+ }
7356
+ .rteSuggestActions button {
7357
+ padding: 4px 8px;
7358
+ border: 0;
7359
+ border-radius: 6px;
7360
+ background: transparent;
7361
+ color: var(--nc-fg-3);
7362
+ font: 500 12px/1.2 var(--nc-font-sans);
7363
+ cursor: pointer;
7364
+ }
7365
+ .rteSuggestActions button:hover { background: var(--nc-ink-3); color: var(--nc-fg-1); }
7366
+
7367
+ .rteFindBar {
7368
+ display: flex;
7369
+ flex-wrap: wrap;
7370
+ align-items: center;
7371
+ gap: 6px;
7372
+ padding: 6px 18px;
7373
+ border-bottom: 1px solid var(--nc-line-1);
7374
+ background: var(--nc-ink-1);
7375
+ }
7376
+ .rteFindInput {
7377
+ flex: 1 1 140px;
7378
+ min-width: 0;
7379
+ height: 28px;
7380
+ padding: 0 8px;
7381
+ border: 1px solid var(--nc-line-1);
7382
+ border-radius: 6px;
7383
+ background: var(--nc-ink-2);
7384
+ color: var(--nc-fg-1);
7385
+ font: 400 13px/1 var(--nc-font-sans);
7386
+ }
7387
+ .rteFindCount { min-width: 64px; color: var(--nc-fg-3); font: 500 12px/1 var(--nc-font-sans); font-variant-numeric: tabular-nums; }
7388
+ .rteFindBtn {
7389
+ height: 28px;
7390
+ padding: 0 10px;
7391
+ border: 1px solid var(--nc-line-1);
7392
+ border-radius: 6px;
7393
+ background: var(--nc-ink-2);
7394
+ color: var(--nc-fg-2);
7395
+ font: 500 12px/1 var(--nc-font-sans);
7396
+ cursor: pointer;
7397
+ }
7398
+ .rteFindBtn:disabled { opacity: 0.45; cursor: default; }
7399
+
7400
+ .wpPanel {
7401
+ position: absolute;
7402
+ top: 38px;
7403
+ right: 12px;
7404
+ z-index: 44;
7405
+ width: min(360px, calc(100% - 24px));
7406
+ max-height: min(520px, 70vh);
7407
+ display: flex;
7408
+ flex-direction: column;
7409
+ border: 1px solid var(--nc-line-1);
7410
+ border-radius: 12px;
7411
+ background: var(--nc-ink-2);
7412
+ box-shadow: 0 18px 44px var(--nc-line-soft);
7413
+ }
7414
+ .wpHead { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; }
7415
+ .wpTitle { color: var(--nc-fg-1); font: 700 13px/1 var(--nc-font-sans); }
7416
+ .wpClose { border: 0; background: transparent; color: var(--nc-fg-3); font-size: 18px; line-height: 1; cursor: pointer; }
7417
+ .wpTabs { display: flex; gap: 2px; padding: 4px 8px 8px; border-bottom: 1px solid var(--nc-line-1); overflow-x: auto; }
7418
+ .wpTab {
7419
+ flex: none;
7420
+ padding: 5px 8px;
7421
+ border: 0;
7422
+ border-radius: 6px;
7423
+ background: transparent;
7424
+ color: var(--nc-fg-3);
7425
+ font: 600 11.5px/1 var(--nc-font-sans);
7426
+ cursor: pointer;
7427
+ }
7428
+ .wpTabOn { background: var(--nc-ink-3); color: var(--nc-fg-1); }
7429
+ .wpBody { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px 12px; overflow-y: auto; }
7430
+ .wpRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; cursor: pointer; }
7431
+ .wpText { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
7432
+ .wpLabel { color: var(--nc-fg-1); font: 600 12.5px/1.3 var(--nc-font-sans); }
7433
+ .wpHint { color: var(--nc-fg-4); font: 400 11.5px/1.35 var(--nc-font-sans); }
7434
+ .wpIndent { margin-left: 12px; padding-left: 10px; border-left: 2px solid var(--nc-line-1); }
7435
+ .wpSwitch { flex: none; width: 16px; height: 16px; accent-color: var(--nc-violet-500); cursor: pointer; }
7436
+ .wpNumber, .wpInput {
7437
+ height: 28px;
7438
+ padding: 0 8px;
7439
+ border: 1px solid var(--nc-line-1);
7440
+ border-radius: 6px;
7441
+ background: var(--nc-ink-1);
7442
+ color: var(--nc-fg-1);
7443
+ font: 400 12.5px/1 var(--nc-font-sans);
7444
+ }
7445
+ .wpNumber { width: 56px; }
7446
+ .wpInput { width: 100%; }
7447
+ .wpNote { margin: 2px 0 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
7448
+ .wpTemplate { display: flex; align-items: center; gap: 6px; }
7449
+ .wpTemplateName {
7450
+ flex: 1;
7451
+ display: flex;
7452
+ align-items: center;
7453
+ justify-content: space-between;
7454
+ gap: 8px;
7455
+ padding: 7px 8px;
7456
+ border: 0;
7457
+ border-radius: 6px;
7458
+ background: transparent;
7459
+ color: var(--nc-fg-1);
7460
+ font: 600 12.5px/1.2 var(--nc-font-sans);
7461
+ text-align: left;
7462
+ cursor: pointer;
7463
+ }
7464
+ .wpTemplateName:hover { background: var(--nc-ink-3); }
7465
+ .wpShortcut { color: var(--nc-fg-4); font: 500 11px/1 var(--nc-font-mono); }
7466
+ .wpRemove { border: 0; background: transparent; color: var(--nc-fg-4); font-size: 16px; cursor: pointer; }
7467
+ .wpRemove:hover { color: var(--nc-danger); }
7468
+ .wpSave { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--nc-line-1); }
7469
+ .wpButton {
7470
+ align-self: flex-start;
7471
+ height: 28px;
7472
+ padding: 0 12px;
7473
+ border: 0;
7474
+ border-radius: 6px;
7475
+ background: var(--nc-violet-500);
7476
+ color: #fff;
7477
+ font: 600 12px/1 var(--nc-font-sans);
7478
+ cursor: pointer;
7479
+ }
7480
+ .wpButton:disabled { opacity: 0.45; cursor: default; }
7481
+ .wpChips { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 10px; }
7482
+ .wpChip {
7483
+ display: inline-flex;
7484
+ align-items: center;
7485
+ gap: 4px;
7486
+ padding: 3px 4px 3px 8px;
7487
+ border-radius: 999px;
7488
+ background: var(--nc-ink-3);
7489
+ color: var(--nc-fg-2);
7490
+ font: 500 12px/1 var(--nc-font-sans);
7491
+ }
7492
+ .wpChip button { border: 0; background: transparent; color: var(--nc-fg-4); cursor: pointer; font-size: 13px; line-height: 1; }
7493
+ .wpKeys { display: flex; flex-direction: column; gap: 6px; margin: 0; }
7494
+ .wpKeyRow { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 10px; align-items: baseline; }
7495
+ .wpKeyRow dt { margin: 0; }
7496
+ .wpKeyRow dd { margin: 0; color: var(--nc-fg-3); font: 400 12px/1.35 var(--nc-font-sans); }
7497
+ .wpKeyRow kbd {
7498
+ padding: 2px 6px;
7499
+ border: 1px solid var(--nc-line-1);
7500
+ border-radius: 4px;
7501
+ background: var(--nc-ink-1);
7502
+ color: var(--nc-fg-1);
7503
+ font: 500 11px/1.4 var(--nc-font-mono);
7504
+ white-space: nowrap;
7505
+ }
7506
+ .sendIssues { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
7507
+
7508
+ .rteGear { font-size: 17px; line-height: 1; }
7509
+ .wpSelect {
7510
+ display: inline-flex;
7511
+ align-items: center;
7512
+ gap: 6px;
7513
+ height: 28px;
7514
+ padding: 0 8px;
7515
+ border: 1px solid var(--nc-line-1);
7516
+ border-radius: 6px;
7517
+ background: var(--nc-ink-1);
7518
+ color: var(--nc-fg-1);
7519
+ font: 500 12.5px/1 var(--nc-font-sans);
7520
+ cursor: pointer;
7521
+ }
7522
+ /* The app's own scrollbar, as on the message list: thin, violet, inset. */
7523
+ .wpTabs, .wpBody, .rteContent, .rteSuggest {
7524
+ scrollbar-width: thin;
7525
+ scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
7526
+ }
7527
+ .wpTabs::-webkit-scrollbar, .wpBody::-webkit-scrollbar, .rteContent::-webkit-scrollbar, .rteSuggest::-webkit-scrollbar { width: 10px; height: 8px; }
7528
+ .wpTabs::-webkit-scrollbar-track, .wpBody::-webkit-scrollbar-track, .rteContent::-webkit-scrollbar-track, .rteSuggest::-webkit-scrollbar-track { background: transparent; }
7529
+ .wpTabs::-webkit-scrollbar-thumb, .wpBody::-webkit-scrollbar-thumb, .rteContent::-webkit-scrollbar-thumb, .rteSuggest::-webkit-scrollbar-thumb {
7530
+ background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent);
7531
+ background-clip: padding-box;
7532
+ border: 3px solid transparent;
7533
+ border-radius: 999px;
7534
+ }
7535
+ .wpTabs::-webkit-scrollbar-thumb:hover, .wpBody::-webkit-scrollbar-thumb:hover, .rteContent::-webkit-scrollbar-thumb:hover, .rteSuggest::-webkit-scrollbar-thumb:hover {
7536
+ background: color-mix(in srgb, var(--nc-violet-500) 78%, transparent);
7537
+ background-clip: padding-box;
7538
+ }
7539
+ .wpTabs { padding-bottom: 6px; }
package/app/mail/page.tsx CHANGED
@@ -13,6 +13,9 @@ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeI
13
13
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
14
14
  import MailSelect, { GLYPH } from './MailSelect'
15
15
  import { handleComposeTab } from './tabKey'
16
+ import { writingSettingsFrom, type WritingSettings } from './writing/settings'
17
+ import { sendIssues, type SendIssue } from './writing/checks'
18
+ import { firstNameFromAddress, type MailTemplate } from './writing/templates'
16
19
 
17
20
  /** Which product an address belongs to, so the picker can wear its mark. */
18
21
  function productOf(address: string): 'vela' | 'hosting' | 'person' {
@@ -354,6 +357,7 @@ type MailSettings = {
354
357
  /** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
355
358
  threadMessages?: 'collapsible' | 'open'
356
359
  ticker?: TickerSettings
360
+ writing?: WritingSettings
357
361
  notifyEmail: string
358
362
  fonts: CustomFont[]
359
363
  defaultFont: BaseFont
@@ -1131,7 +1135,7 @@ function formatBytes(bytes: number): string {
1131
1135
 
1132
1136
  type StashHeaders = Record<string, string>
1133
1137
 
1134
- async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
1138
+ async function fetchStash(kind: 'draft' | 'template', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
1135
1139
  try {
1136
1140
  const response = await fetch(`/api/mail/stash?kind=${kind}`, { headers })
1137
1141
  const data = await response.json()
@@ -1141,11 +1145,11 @@ async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{
1141
1145
  }
1142
1146
  }
1143
1147
 
1144
- function putStash(kind: 'draft', id: string, data: unknown, headers: StashHeaders): void {
1148
+ function putStash(kind: 'draft' | 'template', id: string, data: unknown, headers: StashHeaders): void {
1145
1149
  fetch('/api/mail/stash', { method: 'PUT', headers, body: JSON.stringify({ kind, id, data }) }).catch(() => {})
1146
1150
  }
1147
1151
 
1148
- function deleteStashItem(kind: 'draft', id: string, headers: StashHeaders): void {
1152
+ function deleteStashItem(kind: 'draft' | 'template', id: string, headers: StashHeaders): void {
1149
1153
  fetch(`/api/mail/stash?kind=${kind}&id=${encodeURIComponent(id)}`, { method: 'DELETE', headers }).catch(() => {})
1150
1154
  }
1151
1155
 
@@ -1827,6 +1831,8 @@ export default function DevMailPage() {
1827
1831
  const [companyLogo, setCompanyLogo] = useState('')
1828
1832
  const [signatureTab, setSignatureTab] = useState<'personal' | 'company'>('personal')
1829
1833
  const [prefsLoaded, setPrefsLoaded] = useState(false)
1834
+ const [templates, setTemplates] = useState<MailTemplate[]>([])
1835
+ const [companyWords, setCompanyWords] = useState<string[]>([])
1830
1836
  useEffect(() => {
1831
1837
  if (!isLoggedIn || !prefsLoaded) return
1832
1838
  const timer = window.setTimeout(() => {
@@ -2907,7 +2913,7 @@ export default function DevMailPage() {
2907
2913
  // "comfortable" — is the default. Treating every unrecognised value as roomy put
2908
2914
  // six of the seven mailboxes into a reduced reader none of them had asked for.
2909
2915
  const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2910
- setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
2916
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible', writing: writingSettingsFrom(stored.writing) }))
2911
2917
  const prefs = (data.settings as MailSettings).prefs
2912
2918
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2913
2919
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2917,6 +2923,15 @@ export default function DevMailPage() {
2917
2923
  setPrefsLoaded(true)
2918
2924
  })
2919
2925
  .catch(() => {})
2926
+ fetchStash('template', headers).then(items =>
2927
+ setTemplates(items.map(item => ({ ...(item.data as MailTemplate), id: item.id })).filter(template => typeof template.html === 'string')),
2928
+ )
2929
+ fetch('/api/mail/company-dictionary', { headers })
2930
+ .then(response => response.json())
2931
+ .then(data => {
2932
+ if (data.ok && Array.isArray(data.words)) setCompanyWords(data.words)
2933
+ })
2934
+ .catch(() => {})
2920
2935
  fetch('/api/mail/company-signature', { headers })
2921
2936
  .then(response => response.json())
2922
2937
  .then(data => {
@@ -3940,6 +3955,46 @@ export default function DevMailPage() {
3940
3955
  [apiHeaders],
3941
3956
  )
3942
3957
 
3958
+ const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
3959
+ const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
3960
+ const saveTemplate = useCallback(
3961
+ (template: MailTemplate) => {
3962
+ setTemplates(list => [...list.filter(entry => entry.id !== template.id), template])
3963
+ putStash('template', template.id, template, apiHeaders())
3964
+ },
3965
+ [apiHeaders],
3966
+ )
3967
+ const deleteTemplate = useCallback(
3968
+ (id: string) => {
3969
+ setTemplates(list => list.filter(entry => entry.id !== id))
3970
+ deleteStashItem('template', id, apiHeaders())
3971
+ },
3972
+ [apiHeaders],
3973
+ )
3974
+ const saveCompanyWords = useCallback(
3975
+ (words: string[]) => {
3976
+ setCompanyWords(words)
3977
+ fetch('/api/mail/company-dictionary', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ words }) }).catch(() => {})
3978
+ },
3979
+ [apiHeaders],
3980
+ )
3981
+ const confirmIssues = useCallback(
3982
+ async (issues: SendIssue[]) =>
3983
+ issues.length === 0 ||
3984
+ confirm({
3985
+ title: 'Before you send',
3986
+ body: (
3987
+ <ul className={styles.sendIssues}>
3988
+ {issues.map(issue => (
3989
+ <li key={issue.kind}>{issue.message}</li>
3990
+ ))}
3991
+ </ul>
3992
+ ),
3993
+ confirmLabel: 'Send anyway',
3994
+ }),
3995
+ [confirm],
3996
+ )
3997
+
3943
3998
  const resetSignature = useCallback(async () => {
3944
3999
  const agreed = await confirm({
3945
4000
  title: 'Use the company signature?',
@@ -4486,6 +4541,17 @@ export default function DevMailPage() {
4486
4541
  // immediately — the delay/undo window only applies to attachment-free emails.
4487
4542
  const scheduledAt = attachable.length ? null : resolveScheduledAt()
4488
4543
 
4544
+ const issues = sendIssues(
4545
+ {
4546
+ body: htmlToPlainText(compose.htmlDirty && compose.htmlSource.trim() ? compose.htmlSource : compose.bodyHtml || compose.markdown),
4547
+ recipients: [...compose.to, ...compose.cc, ...compose.bcc].map(address => ({ email: address })),
4548
+ attachmentCount: attachments.length,
4549
+ ownDomains: LOGIN_DOMAINS,
4550
+ },
4551
+ writingSettings,
4552
+ )
4553
+ if (!(await confirmIssues(issues))) return
4554
+
4489
4555
  if (settings.confirmSend && !scheduledAt) {
4490
4556
  const recipientList = [...compose.to, ...compose.cc, ...compose.bcc].join(', ')
4491
4557
  const agreed = await confirm({
@@ -4791,6 +4857,16 @@ export default function DevMailPage() {
4791
4857
  openReplySettings(entry)
4792
4858
  return
4793
4859
  }
4860
+ const issues = sendIssues(
4861
+ {
4862
+ body: quickReply.trim() ? quickReply : htmlToPlainText(replyHtml),
4863
+ recipients: [...draft.to, ...draft.cc, ...draft.bcc].map(address => ({ email: address })),
4864
+ attachmentCount: attachments.length,
4865
+ ownDomains: LOGIN_DOMAINS,
4866
+ },
4867
+ { ...writingSettings, checkEmptyBody: writingSettings.checkEmptyBody && replyBar !== 'forward' },
4868
+ )
4869
+ if (!(await confirmIssues(issues))) return
4794
4870
  setQuickSending(true)
4795
4871
  setReplyError('')
4796
4872
  try {
@@ -8284,6 +8360,21 @@ export default function DevMailPage() {
8284
8360
  fonts={(settings.fonts ?? []).map(font => font.name)}
8285
8361
  fontFaceCss={fontCss}
8286
8362
  baseFont={defaultFont}
8363
+ writing={writingSettings}
8364
+ onWritingChange={saveWriting}
8365
+ templates={templates}
8366
+ onSaveTemplate={saveTemplate}
8367
+ onDeleteTemplate={deleteTemplate}
8368
+ templateContext={{
8369
+ firstName: firstNameFromAddress(compose.to[0]),
8370
+ email: compose.to[0],
8371
+ senderName: compose.fromName || settings.senderName || account?.name || undefined,
8372
+ }}
8373
+ companyWords={companyWords}
8374
+ onCompanyWords={isAdmin ? saveCompanyWords : undefined}
8375
+ isAdmin={isAdmin}
8376
+ onSubmit={() => void sendEmail()}
8377
+ requestHeaders={apiHeaders}
8287
8378
  />
8288
8379
  )}
8289
8380
  {compose.quoteHtml && !compose.campaign.kind && (
@@ -10,9 +10,13 @@ export const TAB_TEXT = '    '
10
10
  * Whether this Tab is the second of a quick pair. The first press is remembered with a way
11
11
  * to take its indent back, which the second press uses before letting focus move on.
12
12
  */
13
- export function tabPress<Mark>(memory: { at: number; mark: Mark | null }, mark: () => Mark): { leaving: boolean; previous: Mark | null } {
13
+ export function tabPress<Mark>(
14
+ memory: { at: number; mark: Mark | null },
15
+ mark: () => Mark,
16
+ windowMs: number = DOUBLE_TAB_MS,
17
+ ): { leaving: boolean; previous: Mark | null } {
14
18
  const now = Date.now()
15
- if (now - memory.at < DOUBLE_TAB_MS) {
19
+ if (now - memory.at < windowMs) {
16
20
  const previous = memory.mark
17
21
  memory.at = 0
18
22
  memory.mark = null