@novacraft-engineering/mailbox 0.4.25 → 0.4.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/send/route.ts +3 -0
- package/app/mail/RichEditor.tsx +405 -6
- package/app/mail/page.module.css +304 -9
- package/app/mail/page.tsx +121 -8
- package/app/mail/scale.test.ts +17 -0
- package/app/mail/scale.ts +15 -0
- package/app/mail/tabKey.ts +6 -2
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/mail-provider.ts +1 -1
- package/lib/scheduled.ts +4 -3
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
package/app/mail/page.module.css
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
--thread-pad: 14px 16px;
|
|
93
93
|
--thread-inset: 12px;
|
|
94
94
|
grid-template-columns: var(--rail-w) var(--list-w) 1fr;
|
|
95
|
-
grid-template-rows: 100dvh;
|
|
95
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
96
96
|
/* This element carries the theme's tokens, so it has to carry the theme's text and
|
|
97
97
|
surface too. Without them anything that does not name a colour of its own inherits
|
|
98
98
|
the document default — black, which only looks deliberate on the light theme. */
|
|
@@ -117,7 +117,11 @@
|
|
|
117
117
|
min-height: 0;
|
|
118
118
|
height: 100%;
|
|
119
119
|
overflow-y: auto;
|
|
120
|
+
scrollbar-width: thin;
|
|
121
|
+
scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
|
|
120
122
|
}
|
|
123
|
+
.rail::-webkit-scrollbar { width: 6px; }
|
|
124
|
+
.rail::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent); border-radius: 999px; }
|
|
121
125
|
.brand {
|
|
122
126
|
display: flex;
|
|
123
127
|
align-items: center;
|
|
@@ -335,6 +339,11 @@
|
|
|
335
339
|
opacity: 0;
|
|
336
340
|
cursor: pointer;
|
|
337
341
|
}
|
|
342
|
+
.scaleNote {
|
|
343
|
+
margin: 8px 0 0;
|
|
344
|
+
color: var(--nc-fg-3);
|
|
345
|
+
font: 400 12px/1.45 var(--nc-font-sans);
|
|
346
|
+
}
|
|
338
347
|
.scaleValue {
|
|
339
348
|
min-width: 48px;
|
|
340
349
|
font-family: var(--nc-font-mono);
|
|
@@ -351,7 +360,8 @@
|
|
|
351
360
|
position: relative;
|
|
352
361
|
width: 64px;
|
|
353
362
|
padding: 20px 10px;
|
|
354
|
-
overflow: hidden;
|
|
363
|
+
overflow-x: hidden;
|
|
364
|
+
overflow-y: auto;
|
|
355
365
|
z-index: 420;
|
|
356
366
|
transition: width var(--dur-base) var(--nc-ease-out), box-shadow var(--dur-base) var(--nc-ease-out);
|
|
357
367
|
}
|
|
@@ -1777,6 +1787,7 @@
|
|
|
1777
1787
|
}
|
|
1778
1788
|
.rteGroup {
|
|
1779
1789
|
display: flex;
|
|
1790
|
+
flex-wrap: wrap;
|
|
1780
1791
|
align-items: center;
|
|
1781
1792
|
gap: 2px;
|
|
1782
1793
|
padding-right: 6px;
|
|
@@ -2084,8 +2095,8 @@
|
|
|
2084
2095
|
position: fixed;
|
|
2085
2096
|
right: 24px;
|
|
2086
2097
|
bottom: 24px;
|
|
2087
|
-
width: min(700px, calc(100vw - 48px));
|
|
2088
|
-
max-height: calc(100dvh - 48px);
|
|
2098
|
+
width: min(700px, calc(100vw / var(--ui-zoom, 1) - 48px));
|
|
2099
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
2089
2100
|
display: flex;
|
|
2090
2101
|
flex-direction: column;
|
|
2091
2102
|
background: var(--nc-ink-1);
|
|
@@ -2131,7 +2142,16 @@
|
|
|
2131
2142
|
font-size: 11px;
|
|
2132
2143
|
color: var(--nc-fg-3);
|
|
2133
2144
|
}
|
|
2145
|
+
.composeTitle { flex: none; white-space: nowrap; }
|
|
2146
|
+
.composeHead .composeHint {
|
|
2147
|
+
flex: 0 1 auto;
|
|
2148
|
+
min-width: 0;
|
|
2149
|
+
overflow: hidden;
|
|
2150
|
+
text-overflow: ellipsis;
|
|
2151
|
+
white-space: nowrap;
|
|
2152
|
+
}
|
|
2134
2153
|
.composeHeadActions {
|
|
2154
|
+
flex: none;
|
|
2135
2155
|
margin-left: auto;
|
|
2136
2156
|
display: flex;
|
|
2137
2157
|
align-items: center;
|
|
@@ -3068,7 +3088,7 @@
|
|
|
3068
3088
|
.lbScrim { padding: 0; }
|
|
3069
3089
|
.lbShell {
|
|
3070
3090
|
width: 100%;
|
|
3071
|
-
height: 100dvh;
|
|
3091
|
+
height: calc(100dvh / var(--ui-zoom, 1));
|
|
3072
3092
|
border: none;
|
|
3073
3093
|
border-radius: 0;
|
|
3074
3094
|
}
|
|
@@ -4091,7 +4111,7 @@
|
|
|
4091
4111
|
|
|
4092
4112
|
.settingsCard {
|
|
4093
4113
|
width: min(760px, 100%);
|
|
4094
|
-
max-height: calc(100dvh - 48px);
|
|
4114
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
4095
4115
|
overflow-y: auto;
|
|
4096
4116
|
background: var(--nc-ink-1);
|
|
4097
4117
|
border: 1px solid var(--nc-line-2);
|
|
@@ -4265,7 +4285,7 @@
|
|
|
4265
4285
|
--rail-w: 0px;
|
|
4266
4286
|
--list-w: 0px;
|
|
4267
4287
|
grid-template-columns: 1fr;
|
|
4268
|
-
grid-template-rows: 100dvh;
|
|
4288
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
4269
4289
|
}
|
|
4270
4290
|
.rail {
|
|
4271
4291
|
position: fixed;
|
|
@@ -4297,7 +4317,7 @@
|
|
|
4297
4317
|
.menuBtn { display: inline-flex; }
|
|
4298
4318
|
.fab { display: inline-flex; animation: ncPop .5s var(--nc-ease-spring) .1s backwards; }
|
|
4299
4319
|
.list { padding-bottom: 88px; }
|
|
4300
|
-
.compose { right: 0; bottom: 0; width: 100vw; max-height: 100dvh; border-radius: 0; }
|
|
4320
|
+
.compose { right: 0; bottom: 0; width: calc(100vw / var(--ui-zoom, 1)); max-height: calc(100dvh / var(--ui-zoom, 1)); border-radius: 0; }
|
|
4301
4321
|
}
|
|
4302
4322
|
|
|
4303
4323
|
/* ── Motion ──────────────────────────────────────────────── */
|
|
@@ -5726,7 +5746,7 @@
|
|
|
5726
5746
|
top: 60px;
|
|
5727
5747
|
right: 14px;
|
|
5728
5748
|
z-index: 75;
|
|
5729
|
-
width: min(360px, calc(100vw - 28px));
|
|
5749
|
+
width: min(360px, calc(100vw / var(--ui-zoom, 1) - 28px));
|
|
5730
5750
|
padding: 16px 18px 14px;
|
|
5731
5751
|
border: 1px solid var(--nc-line-1);
|
|
5732
5752
|
border-left: 3px solid var(--nc-violet-500);
|
|
@@ -7262,3 +7282,278 @@
|
|
|
7262
7282
|
.fieldBad,
|
|
7263
7283
|
.msgBad { animation: none; }
|
|
7264
7284
|
}
|
|
7285
|
+
|
|
7286
|
+
/* Writing tools */
|
|
7287
|
+
.rteBody {
|
|
7288
|
+
position: relative;
|
|
7289
|
+
display: flex;
|
|
7290
|
+
flex-direction: column;
|
|
7291
|
+
flex: 1;
|
|
7292
|
+
min-height: 0;
|
|
7293
|
+
}
|
|
7294
|
+
.rteCorner {
|
|
7295
|
+
position: absolute;
|
|
7296
|
+
top: 8px;
|
|
7297
|
+
right: 12px;
|
|
7298
|
+
z-index: 5;
|
|
7299
|
+
display: flex;
|
|
7300
|
+
gap: 4px;
|
|
7301
|
+
}
|
|
7302
|
+
.rteCornerBtn {
|
|
7303
|
+
display: inline-flex;
|
|
7304
|
+
align-items: center;
|
|
7305
|
+
gap: 2px;
|
|
7306
|
+
height: 24px;
|
|
7307
|
+
padding: 0 8px;
|
|
7308
|
+
border: 1px solid var(--nc-line-1);
|
|
7309
|
+
border-radius: 999px;
|
|
7310
|
+
background: var(--nc-ink-2);
|
|
7311
|
+
color: var(--nc-fg-3);
|
|
7312
|
+
font: 600 11px/1 var(--nc-font-sans);
|
|
7313
|
+
cursor: pointer;
|
|
7314
|
+
}
|
|
7315
|
+
.rteCornerBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
|
|
7316
|
+
.rteCornerOn { color: var(--nc-violet-500); border-color: var(--nc-violet-300); }
|
|
7317
|
+
.rteWordCount {
|
|
7318
|
+
align-self: flex-end;
|
|
7319
|
+
padding: 4px 18px 8px;
|
|
7320
|
+
color: var(--nc-fg-4);
|
|
7321
|
+
font: 500 11px/1 var(--nc-font-sans);
|
|
7322
|
+
font-variant-numeric: tabular-nums;
|
|
7323
|
+
}
|
|
7324
|
+
.rteContent :global(.rteMisspelt) {
|
|
7325
|
+
text-decoration: underline wavy var(--nc-danger);
|
|
7326
|
+
text-decoration-skip-ink: none;
|
|
7327
|
+
text-underline-offset: 3px;
|
|
7328
|
+
cursor: pointer;
|
|
7329
|
+
}
|
|
7330
|
+
.rteContent :global(.rteRepeat) {
|
|
7331
|
+
text-decoration: underline wavy var(--nc-warn);
|
|
7332
|
+
text-underline-offset: 3px;
|
|
7333
|
+
cursor: pointer;
|
|
7334
|
+
}
|
|
7335
|
+
.rteContent :global(.rteGrammar) {
|
|
7336
|
+
text-decoration: underline wavy #3B82F6;
|
|
7337
|
+
text-underline-offset: 3px;
|
|
7338
|
+
cursor: pointer;
|
|
7339
|
+
}
|
|
7340
|
+
.rteContent :global(.rteFind) { background: color-mix(in srgb, var(--nc-warn) 30%, transparent); border-radius: 2px; }
|
|
7341
|
+
.rteContent :global(.rteFindCurrent) { background: color-mix(in srgb, var(--nc-warn) 70%, transparent); border-radius: 2px; }
|
|
7342
|
+
|
|
7343
|
+
.rteSuggest {
|
|
7344
|
+
position: absolute;
|
|
7345
|
+
z-index: 45;
|
|
7346
|
+
min-width: 180px;
|
|
7347
|
+
max-width: 300px;
|
|
7348
|
+
display: flex;
|
|
7349
|
+
flex-direction: column;
|
|
7350
|
+
padding: 6px;
|
|
7351
|
+
border: 1px solid var(--nc-line-1);
|
|
7352
|
+
border-radius: 10px;
|
|
7353
|
+
background: var(--nc-ink-2);
|
|
7354
|
+
box-shadow: 0 14px 36px var(--nc-line-soft);
|
|
7355
|
+
}
|
|
7356
|
+
.rteSuggestPick {
|
|
7357
|
+
padding: 6px 10px;
|
|
7358
|
+
border: 0;
|
|
7359
|
+
border-radius: 6px;
|
|
7360
|
+
background: transparent;
|
|
7361
|
+
color: var(--nc-fg-1);
|
|
7362
|
+
font: 600 13px/1.3 var(--nc-font-sans);
|
|
7363
|
+
text-align: left;
|
|
7364
|
+
cursor: pointer;
|
|
7365
|
+
}
|
|
7366
|
+
.rteSuggestPick:hover { background: var(--nc-ink-3); }
|
|
7367
|
+
.rteSuggestNote { margin: 4px 10px 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
|
|
7368
|
+
.rteSuggestActions {
|
|
7369
|
+
display: flex;
|
|
7370
|
+
flex-wrap: wrap;
|
|
7371
|
+
gap: 4px;
|
|
7372
|
+
margin-top: 4px;
|
|
7373
|
+
padding-top: 6px;
|
|
7374
|
+
border-top: 1px solid var(--nc-line-1);
|
|
7375
|
+
}
|
|
7376
|
+
.rteSuggestActions button {
|
|
7377
|
+
padding: 4px 8px;
|
|
7378
|
+
border: 0;
|
|
7379
|
+
border-radius: 6px;
|
|
7380
|
+
background: transparent;
|
|
7381
|
+
color: var(--nc-fg-3);
|
|
7382
|
+
font: 500 12px/1.2 var(--nc-font-sans);
|
|
7383
|
+
cursor: pointer;
|
|
7384
|
+
}
|
|
7385
|
+
.rteSuggestActions button:hover { background: var(--nc-ink-3); color: var(--nc-fg-1); }
|
|
7386
|
+
|
|
7387
|
+
.rteFindBar {
|
|
7388
|
+
display: flex;
|
|
7389
|
+
flex-wrap: wrap;
|
|
7390
|
+
align-items: center;
|
|
7391
|
+
gap: 6px;
|
|
7392
|
+
padding: 6px 18px;
|
|
7393
|
+
border-bottom: 1px solid var(--nc-line-1);
|
|
7394
|
+
background: var(--nc-ink-1);
|
|
7395
|
+
}
|
|
7396
|
+
.rteFindInput {
|
|
7397
|
+
flex: 1 1 140px;
|
|
7398
|
+
min-width: 0;
|
|
7399
|
+
height: 28px;
|
|
7400
|
+
padding: 0 8px;
|
|
7401
|
+
border: 1px solid var(--nc-line-1);
|
|
7402
|
+
border-radius: 6px;
|
|
7403
|
+
background: var(--nc-ink-2);
|
|
7404
|
+
color: var(--nc-fg-1);
|
|
7405
|
+
font: 400 13px/1 var(--nc-font-sans);
|
|
7406
|
+
}
|
|
7407
|
+
.rteFindCount { min-width: 64px; color: var(--nc-fg-3); font: 500 12px/1 var(--nc-font-sans); font-variant-numeric: tabular-nums; }
|
|
7408
|
+
.rteFindBtn {
|
|
7409
|
+
height: 28px;
|
|
7410
|
+
padding: 0 10px;
|
|
7411
|
+
border: 1px solid var(--nc-line-1);
|
|
7412
|
+
border-radius: 6px;
|
|
7413
|
+
background: var(--nc-ink-2);
|
|
7414
|
+
color: var(--nc-fg-2);
|
|
7415
|
+
font: 500 12px/1 var(--nc-font-sans);
|
|
7416
|
+
cursor: pointer;
|
|
7417
|
+
}
|
|
7418
|
+
.rteFindBtn:disabled { opacity: 0.45; cursor: default; }
|
|
7419
|
+
|
|
7420
|
+
.wpPanel {
|
|
7421
|
+
position: absolute;
|
|
7422
|
+
top: 38px;
|
|
7423
|
+
right: 12px;
|
|
7424
|
+
z-index: 44;
|
|
7425
|
+
width: min(360px, calc(100% - 24px));
|
|
7426
|
+
max-height: min(520px, 70vh);
|
|
7427
|
+
display: flex;
|
|
7428
|
+
flex-direction: column;
|
|
7429
|
+
border: 1px solid var(--nc-line-1);
|
|
7430
|
+
border-radius: 12px;
|
|
7431
|
+
background: var(--nc-ink-2);
|
|
7432
|
+
box-shadow: 0 18px 44px var(--nc-line-soft);
|
|
7433
|
+
}
|
|
7434
|
+
.wpHead { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; }
|
|
7435
|
+
.wpTitle { color: var(--nc-fg-1); font: 700 13px/1 var(--nc-font-sans); }
|
|
7436
|
+
.wpClose { border: 0; background: transparent; color: var(--nc-fg-3); font-size: 18px; line-height: 1; cursor: pointer; }
|
|
7437
|
+
.wpTabs { display: flex; gap: 2px; padding: 4px 8px 8px; border-bottom: 1px solid var(--nc-line-1); overflow-x: auto; }
|
|
7438
|
+
.wpTab {
|
|
7439
|
+
flex: none;
|
|
7440
|
+
padding: 5px 8px;
|
|
7441
|
+
border: 0;
|
|
7442
|
+
border-radius: 6px;
|
|
7443
|
+
background: transparent;
|
|
7444
|
+
color: var(--nc-fg-3);
|
|
7445
|
+
font: 600 11.5px/1 var(--nc-font-sans);
|
|
7446
|
+
cursor: pointer;
|
|
7447
|
+
}
|
|
7448
|
+
.wpTabOn { background: var(--nc-ink-3); color: var(--nc-fg-1); }
|
|
7449
|
+
.wpBody { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px 12px; overflow-y: auto; }
|
|
7450
|
+
.wpRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; cursor: pointer; }
|
|
7451
|
+
.wpText { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
7452
|
+
.wpLabel { color: var(--nc-fg-1); font: 600 12.5px/1.3 var(--nc-font-sans); }
|
|
7453
|
+
.wpHint { color: var(--nc-fg-4); font: 400 11.5px/1.35 var(--nc-font-sans); }
|
|
7454
|
+
.wpIndent { margin-left: 12px; padding-left: 10px; border-left: 2px solid var(--nc-line-1); }
|
|
7455
|
+
.wpSwitch { flex: none; width: 16px; height: 16px; accent-color: var(--nc-violet-500); cursor: pointer; }
|
|
7456
|
+
.wpNumber, .wpInput {
|
|
7457
|
+
height: 28px;
|
|
7458
|
+
padding: 0 8px;
|
|
7459
|
+
border: 1px solid var(--nc-line-1);
|
|
7460
|
+
border-radius: 6px;
|
|
7461
|
+
background: var(--nc-ink-1);
|
|
7462
|
+
color: var(--nc-fg-1);
|
|
7463
|
+
font: 400 12.5px/1 var(--nc-font-sans);
|
|
7464
|
+
}
|
|
7465
|
+
.wpNumber { width: 56px; }
|
|
7466
|
+
.wpInput { width: 100%; }
|
|
7467
|
+
.wpNote { margin: 2px 0 6px; color: var(--nc-fg-3); font: 400 12px/1.4 var(--nc-font-sans); }
|
|
7468
|
+
.wpTemplate { display: flex; align-items: center; gap: 6px; }
|
|
7469
|
+
.wpTemplateName {
|
|
7470
|
+
flex: 1;
|
|
7471
|
+
display: flex;
|
|
7472
|
+
align-items: center;
|
|
7473
|
+
justify-content: space-between;
|
|
7474
|
+
gap: 8px;
|
|
7475
|
+
padding: 7px 8px;
|
|
7476
|
+
border: 0;
|
|
7477
|
+
border-radius: 6px;
|
|
7478
|
+
background: transparent;
|
|
7479
|
+
color: var(--nc-fg-1);
|
|
7480
|
+
font: 600 12.5px/1.2 var(--nc-font-sans);
|
|
7481
|
+
text-align: left;
|
|
7482
|
+
cursor: pointer;
|
|
7483
|
+
}
|
|
7484
|
+
.wpTemplateName:hover { background: var(--nc-ink-3); }
|
|
7485
|
+
.wpShortcut { color: var(--nc-fg-4); font: 500 11px/1 var(--nc-font-mono); }
|
|
7486
|
+
.wpRemove { border: 0; background: transparent; color: var(--nc-fg-4); font-size: 16px; cursor: pointer; }
|
|
7487
|
+
.wpRemove:hover { color: var(--nc-danger); }
|
|
7488
|
+
.wpSave { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--nc-line-1); }
|
|
7489
|
+
.wpButton {
|
|
7490
|
+
align-self: flex-start;
|
|
7491
|
+
height: 28px;
|
|
7492
|
+
padding: 0 12px;
|
|
7493
|
+
border: 0;
|
|
7494
|
+
border-radius: 6px;
|
|
7495
|
+
background: var(--nc-violet-500);
|
|
7496
|
+
color: #fff;
|
|
7497
|
+
font: 600 12px/1 var(--nc-font-sans);
|
|
7498
|
+
cursor: pointer;
|
|
7499
|
+
}
|
|
7500
|
+
.wpButton:disabled { opacity: 0.45; cursor: default; }
|
|
7501
|
+
.wpChips { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 10px; }
|
|
7502
|
+
.wpChip {
|
|
7503
|
+
display: inline-flex;
|
|
7504
|
+
align-items: center;
|
|
7505
|
+
gap: 4px;
|
|
7506
|
+
padding: 3px 4px 3px 8px;
|
|
7507
|
+
border-radius: 999px;
|
|
7508
|
+
background: var(--nc-ink-3);
|
|
7509
|
+
color: var(--nc-fg-2);
|
|
7510
|
+
font: 500 12px/1 var(--nc-font-sans);
|
|
7511
|
+
}
|
|
7512
|
+
.wpChip button { border: 0; background: transparent; color: var(--nc-fg-4); cursor: pointer; font-size: 13px; line-height: 1; }
|
|
7513
|
+
.wpKeys { display: flex; flex-direction: column; gap: 6px; margin: 0; }
|
|
7514
|
+
.wpKeyRow { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 10px; align-items: baseline; }
|
|
7515
|
+
.wpKeyRow dt { margin: 0; }
|
|
7516
|
+
.wpKeyRow dd { margin: 0; color: var(--nc-fg-3); font: 400 12px/1.35 var(--nc-font-sans); }
|
|
7517
|
+
.wpKeyRow kbd {
|
|
7518
|
+
padding: 2px 6px;
|
|
7519
|
+
border: 1px solid var(--nc-line-1);
|
|
7520
|
+
border-radius: 4px;
|
|
7521
|
+
background: var(--nc-ink-1);
|
|
7522
|
+
color: var(--nc-fg-1);
|
|
7523
|
+
font: 500 11px/1.4 var(--nc-font-mono);
|
|
7524
|
+
white-space: nowrap;
|
|
7525
|
+
}
|
|
7526
|
+
.sendIssues { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
|
|
7527
|
+
|
|
7528
|
+
.rteGear { font-size: 17px; line-height: 1; }
|
|
7529
|
+
.wpSelect {
|
|
7530
|
+
display: inline-flex;
|
|
7531
|
+
align-items: center;
|
|
7532
|
+
gap: 6px;
|
|
7533
|
+
height: 28px;
|
|
7534
|
+
padding: 0 8px;
|
|
7535
|
+
border: 1px solid var(--nc-line-1);
|
|
7536
|
+
border-radius: 6px;
|
|
7537
|
+
background: var(--nc-ink-1);
|
|
7538
|
+
color: var(--nc-fg-1);
|
|
7539
|
+
font: 500 12.5px/1 var(--nc-font-sans);
|
|
7540
|
+
cursor: pointer;
|
|
7541
|
+
}
|
|
7542
|
+
/* The app's own scrollbar, as on the message list: thin, violet, inset. */
|
|
7543
|
+
.wpTabs, .wpBody, .rteContent, .rteSuggest {
|
|
7544
|
+
scrollbar-width: thin;
|
|
7545
|
+
scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
|
|
7546
|
+
}
|
|
7547
|
+
.wpTabs::-webkit-scrollbar, .wpBody::-webkit-scrollbar, .rteContent::-webkit-scrollbar, .rteSuggest::-webkit-scrollbar { width: 10px; height: 8px; }
|
|
7548
|
+
.wpTabs::-webkit-scrollbar-track, .wpBody::-webkit-scrollbar-track, .rteContent::-webkit-scrollbar-track, .rteSuggest::-webkit-scrollbar-track { background: transparent; }
|
|
7549
|
+
.wpTabs::-webkit-scrollbar-thumb, .wpBody::-webkit-scrollbar-thumb, .rteContent::-webkit-scrollbar-thumb, .rteSuggest::-webkit-scrollbar-thumb {
|
|
7550
|
+
background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent);
|
|
7551
|
+
background-clip: padding-box;
|
|
7552
|
+
border: 3px solid transparent;
|
|
7553
|
+
border-radius: 999px;
|
|
7554
|
+
}
|
|
7555
|
+
.wpTabs::-webkit-scrollbar-thumb:hover, .wpBody::-webkit-scrollbar-thumb:hover, .rteContent::-webkit-scrollbar-thumb:hover, .rteSuggest::-webkit-scrollbar-thumb:hover {
|
|
7556
|
+
background: color-mix(in srgb, var(--nc-violet-500) 78%, transparent);
|
|
7557
|
+
background-clip: padding-box;
|
|
7558
|
+
}
|
|
7559
|
+
.wpTabs { padding-bottom: 6px; }
|
package/app/mail/page.tsx
CHANGED
|
@@ -13,6 +13,10 @@ 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 { fittedScale } from './scale'
|
|
17
|
+
import { writingSettingsFrom, type WritingSettings } from './writing/settings'
|
|
18
|
+
import { sendIssues, type SendIssue } from './writing/checks'
|
|
19
|
+
import { firstNameFromAddress, type MailTemplate } from './writing/templates'
|
|
16
20
|
|
|
17
21
|
/** Which product an address belongs to, so the picker can wear its mark. */
|
|
18
22
|
function productOf(address: string): 'vela' | 'hosting' | 'person' {
|
|
@@ -354,6 +358,7 @@ type MailSettings = {
|
|
|
354
358
|
/** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
|
|
355
359
|
threadMessages?: 'collapsible' | 'open'
|
|
356
360
|
ticker?: TickerSettings
|
|
361
|
+
writing?: WritingSettings
|
|
357
362
|
notifyEmail: string
|
|
358
363
|
fonts: CustomFont[]
|
|
359
364
|
defaultFont: BaseFont
|
|
@@ -1131,7 +1136,7 @@ function formatBytes(bytes: number): string {
|
|
|
1131
1136
|
|
|
1132
1137
|
type StashHeaders = Record<string, string>
|
|
1133
1138
|
|
|
1134
|
-
async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
|
|
1139
|
+
async function fetchStash(kind: 'draft' | 'template', headers: StashHeaders): Promise<Array<{ id: string; data: unknown; updatedAt: string }>> {
|
|
1135
1140
|
try {
|
|
1136
1141
|
const response = await fetch(`/api/mail/stash?kind=${kind}`, { headers })
|
|
1137
1142
|
const data = await response.json()
|
|
@@ -1141,11 +1146,11 @@ async function fetchStash(kind: 'draft', headers: StashHeaders): Promise<Array<{
|
|
|
1141
1146
|
}
|
|
1142
1147
|
}
|
|
1143
1148
|
|
|
1144
|
-
function putStash(kind: 'draft', id: string, data: unknown, headers: StashHeaders): void {
|
|
1149
|
+
function putStash(kind: 'draft' | 'template', id: string, data: unknown, headers: StashHeaders): void {
|
|
1145
1150
|
fetch('/api/mail/stash', { method: 'PUT', headers, body: JSON.stringify({ kind, id, data }) }).catch(() => {})
|
|
1146
1151
|
}
|
|
1147
1152
|
|
|
1148
|
-
function deleteStashItem(kind: 'draft', id: string, headers: StashHeaders): void {
|
|
1153
|
+
function deleteStashItem(kind: 'draft' | 'template', id: string, headers: StashHeaders): void {
|
|
1149
1154
|
fetch(`/api/mail/stash?kind=${kind}&id=${encodeURIComponent(id)}`, { method: 'DELETE', headers }).catch(() => {})
|
|
1150
1155
|
}
|
|
1151
1156
|
|
|
@@ -1827,6 +1832,8 @@ export default function DevMailPage() {
|
|
|
1827
1832
|
const [companyLogo, setCompanyLogo] = useState('')
|
|
1828
1833
|
const [signatureTab, setSignatureTab] = useState<'personal' | 'company'>('personal')
|
|
1829
1834
|
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
|
1835
|
+
const [templates, setTemplates] = useState<MailTemplate[]>([])
|
|
1836
|
+
const [companyWords, setCompanyWords] = useState<string[]>([])
|
|
1830
1837
|
useEffect(() => {
|
|
1831
1838
|
if (!isLoggedIn || !prefsLoaded) return
|
|
1832
1839
|
const timer = window.setTimeout(() => {
|
|
@@ -1841,15 +1848,30 @@ export default function DevMailPage() {
|
|
|
1841
1848
|
return () => window.clearTimeout(timer)
|
|
1842
1849
|
}, [isLoggedIn, prefsLoaded, themePref, accent, themeCustom, readerLayout, apiHeaders])
|
|
1843
1850
|
|
|
1851
|
+
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
|
|
1852
|
+
useEffect(() => {
|
|
1853
|
+
const measure = () => setWindowSize({ width: window.innerWidth, height: window.innerHeight })
|
|
1854
|
+
measure()
|
|
1855
|
+
window.addEventListener('resize', measure)
|
|
1856
|
+
return () => window.removeEventListener('resize', measure)
|
|
1857
|
+
}, [])
|
|
1858
|
+
const chosenScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1859
|
+
const appliedScale = fittedScale(chosenScale, windowSize.width, windowSize.height)
|
|
1860
|
+
|
|
1844
1861
|
useEffect(() => {
|
|
1845
|
-
const scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1846
1862
|
// zoom rather than a font size: every measurement in this interface is in pixels, and
|
|
1847
1863
|
// scaling only the type would leave the type in boxes that no longer fit it.
|
|
1848
|
-
document.documentElement.style.zoom =
|
|
1864
|
+
document.documentElement.style.zoom = appliedScale === 100 ? '' : `${appliedScale}%`
|
|
1865
|
+
// A zoomed page still measures the window in unzoomed units, so every full-window
|
|
1866
|
+
// height or width is divided back down by this, or it overshoots the screen by the scale.
|
|
1867
|
+
document.documentElement.style.setProperty('--ui-zoom', String(appliedScale / 100))
|
|
1868
|
+
}, [appliedScale])
|
|
1869
|
+
|
|
1870
|
+
useEffect(() => {
|
|
1849
1871
|
try {
|
|
1850
|
-
localStorage.setItem(LS_SCALE_KEY, String(
|
|
1872
|
+
localStorage.setItem(LS_SCALE_KEY, String(chosenScale))
|
|
1851
1873
|
} catch {}
|
|
1852
|
-
}, [
|
|
1874
|
+
}, [chosenScale])
|
|
1853
1875
|
|
|
1854
1876
|
const chooseLayout = useCallback((layout: 'list' | 'bubbles') => {
|
|
1855
1877
|
setReaderLayout(layout)
|
|
@@ -2907,7 +2929,7 @@ export default function DevMailPage() {
|
|
|
2907
2929
|
// "comfortable" — is the default. Treating every unrecognised value as roomy put
|
|
2908
2930
|
// six of the seven mailboxes into a reduced reader none of them had asked for.
|
|
2909
2931
|
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' }))
|
|
2932
|
+
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
2933
|
const prefs = (data.settings as MailSettings).prefs
|
|
2912
2934
|
if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
|
|
2913
2935
|
if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
|
|
@@ -2917,6 +2939,15 @@ export default function DevMailPage() {
|
|
|
2917
2939
|
setPrefsLoaded(true)
|
|
2918
2940
|
})
|
|
2919
2941
|
.catch(() => {})
|
|
2942
|
+
fetchStash('template', headers).then(items =>
|
|
2943
|
+
setTemplates(items.map(item => ({ ...(item.data as MailTemplate), id: item.id })).filter(template => typeof template.html === 'string')),
|
|
2944
|
+
)
|
|
2945
|
+
fetch('/api/mail/company-dictionary', { headers })
|
|
2946
|
+
.then(response => response.json())
|
|
2947
|
+
.then(data => {
|
|
2948
|
+
if (data.ok && Array.isArray(data.words)) setCompanyWords(data.words)
|
|
2949
|
+
})
|
|
2950
|
+
.catch(() => {})
|
|
2920
2951
|
fetch('/api/mail/company-signature', { headers })
|
|
2921
2952
|
.then(response => response.json())
|
|
2922
2953
|
.then(data => {
|
|
@@ -3940,6 +3971,46 @@ export default function DevMailPage() {
|
|
|
3940
3971
|
[apiHeaders],
|
|
3941
3972
|
)
|
|
3942
3973
|
|
|
3974
|
+
const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
|
|
3975
|
+
const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
|
|
3976
|
+
const saveTemplate = useCallback(
|
|
3977
|
+
(template: MailTemplate) => {
|
|
3978
|
+
setTemplates(list => [...list.filter(entry => entry.id !== template.id), template])
|
|
3979
|
+
putStash('template', template.id, template, apiHeaders())
|
|
3980
|
+
},
|
|
3981
|
+
[apiHeaders],
|
|
3982
|
+
)
|
|
3983
|
+
const deleteTemplate = useCallback(
|
|
3984
|
+
(id: string) => {
|
|
3985
|
+
setTemplates(list => list.filter(entry => entry.id !== id))
|
|
3986
|
+
deleteStashItem('template', id, apiHeaders())
|
|
3987
|
+
},
|
|
3988
|
+
[apiHeaders],
|
|
3989
|
+
)
|
|
3990
|
+
const saveCompanyWords = useCallback(
|
|
3991
|
+
(words: string[]) => {
|
|
3992
|
+
setCompanyWords(words)
|
|
3993
|
+
fetch('/api/mail/company-dictionary', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ words }) }).catch(() => {})
|
|
3994
|
+
},
|
|
3995
|
+
[apiHeaders],
|
|
3996
|
+
)
|
|
3997
|
+
const confirmIssues = useCallback(
|
|
3998
|
+
async (issues: SendIssue[]) =>
|
|
3999
|
+
issues.length === 0 ||
|
|
4000
|
+
confirm({
|
|
4001
|
+
title: 'Before you send',
|
|
4002
|
+
body: (
|
|
4003
|
+
<ul className={styles.sendIssues}>
|
|
4004
|
+
{issues.map(issue => (
|
|
4005
|
+
<li key={issue.kind}>{issue.message}</li>
|
|
4006
|
+
))}
|
|
4007
|
+
</ul>
|
|
4008
|
+
),
|
|
4009
|
+
confirmLabel: 'Send anyway',
|
|
4010
|
+
}),
|
|
4011
|
+
[confirm],
|
|
4012
|
+
)
|
|
4013
|
+
|
|
3943
4014
|
const resetSignature = useCallback(async () => {
|
|
3944
4015
|
const agreed = await confirm({
|
|
3945
4016
|
title: 'Use the company signature?',
|
|
@@ -4486,6 +4557,17 @@ export default function DevMailPage() {
|
|
|
4486
4557
|
// immediately — the delay/undo window only applies to attachment-free emails.
|
|
4487
4558
|
const scheduledAt = attachable.length ? null : resolveScheduledAt()
|
|
4488
4559
|
|
|
4560
|
+
const issues = sendIssues(
|
|
4561
|
+
{
|
|
4562
|
+
body: htmlToPlainText(compose.htmlDirty && compose.htmlSource.trim() ? compose.htmlSource : compose.bodyHtml || compose.markdown),
|
|
4563
|
+
recipients: [...compose.to, ...compose.cc, ...compose.bcc].map(address => ({ email: address })),
|
|
4564
|
+
attachmentCount: attachments.length,
|
|
4565
|
+
ownDomains: LOGIN_DOMAINS,
|
|
4566
|
+
},
|
|
4567
|
+
writingSettings,
|
|
4568
|
+
)
|
|
4569
|
+
if (!(await confirmIssues(issues))) return
|
|
4570
|
+
|
|
4489
4571
|
if (settings.confirmSend && !scheduledAt) {
|
|
4490
4572
|
const recipientList = [...compose.to, ...compose.cc, ...compose.bcc].join(', ')
|
|
4491
4573
|
const agreed = await confirm({
|
|
@@ -4791,6 +4873,16 @@ export default function DevMailPage() {
|
|
|
4791
4873
|
openReplySettings(entry)
|
|
4792
4874
|
return
|
|
4793
4875
|
}
|
|
4876
|
+
const issues = sendIssues(
|
|
4877
|
+
{
|
|
4878
|
+
body: quickReply.trim() ? quickReply : htmlToPlainText(replyHtml),
|
|
4879
|
+
recipients: [...draft.to, ...draft.cc, ...draft.bcc].map(address => ({ email: address })),
|
|
4880
|
+
attachmentCount: attachments.length,
|
|
4881
|
+
ownDomains: LOGIN_DOMAINS,
|
|
4882
|
+
},
|
|
4883
|
+
{ ...writingSettings, checkEmptyBody: writingSettings.checkEmptyBody && replyBar !== 'forward' },
|
|
4884
|
+
)
|
|
4885
|
+
if (!(await confirmIssues(issues))) return
|
|
4794
4886
|
setQuickSending(true)
|
|
4795
4887
|
setReplyError('')
|
|
4796
4888
|
try {
|
|
@@ -8284,6 +8376,21 @@ export default function DevMailPage() {
|
|
|
8284
8376
|
fonts={(settings.fonts ?? []).map(font => font.name)}
|
|
8285
8377
|
fontFaceCss={fontCss}
|
|
8286
8378
|
baseFont={defaultFont}
|
|
8379
|
+
writing={writingSettings}
|
|
8380
|
+
onWritingChange={saveWriting}
|
|
8381
|
+
templates={templates}
|
|
8382
|
+
onSaveTemplate={saveTemplate}
|
|
8383
|
+
onDeleteTemplate={deleteTemplate}
|
|
8384
|
+
templateContext={{
|
|
8385
|
+
firstName: firstNameFromAddress(compose.to[0]),
|
|
8386
|
+
email: compose.to[0],
|
|
8387
|
+
senderName: compose.fromName || settings.senderName || account?.name || undefined,
|
|
8388
|
+
}}
|
|
8389
|
+
companyWords={companyWords}
|
|
8390
|
+
onCompanyWords={isAdmin ? saveCompanyWords : undefined}
|
|
8391
|
+
isAdmin={isAdmin}
|
|
8392
|
+
onSubmit={() => void sendEmail()}
|
|
8393
|
+
requestHeaders={apiHeaders}
|
|
8287
8394
|
/>
|
|
8288
8395
|
)}
|
|
8289
8396
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
@@ -8622,6 +8729,12 @@ export default function DevMailPage() {
|
|
|
8622
8729
|
/>
|
|
8623
8730
|
<span className={styles.scaleValue}>{settings.uiScale}%</span>
|
|
8624
8731
|
</div>
|
|
8732
|
+
{appliedScale < chosenScale && (
|
|
8733
|
+
<p className={styles.scaleNote} role="status">
|
|
8734
|
+
This window fits up to {appliedScale}%, so that is what it shows. Make the window larger, or pick a
|
|
8735
|
+
smaller size, to use {chosenScale}%.
|
|
8736
|
+
</p>
|
|
8737
|
+
)}
|
|
8625
8738
|
</div>
|
|
8626
8739
|
<div className={styles.settingsField}>
|
|
8627
8740
|
<span>Density</span>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { fittedScale } from './scale.ts'
|
|
4
|
+
|
|
5
|
+
test('a larger size is held to what the window can fit', () => {
|
|
6
|
+
assert.equal(fittedScale(110, 1440, 790), 110, 'a MacBook Air browser window takes Large')
|
|
7
|
+
assert.equal(fittedScale(125, 1440, 790), 125)
|
|
8
|
+
assert.equal(fittedScale(140, 1440, 790), 131, 'but not 140%: it would leave under 600px of height')
|
|
9
|
+
assert.equal(fittedScale(110, 1366, 650), 108, 'a 1366x768 laptop is held just short of Large')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('smaller sizes and 100% are always allowed', () => {
|
|
13
|
+
assert.equal(fittedScale(90, 800, 500), 90)
|
|
14
|
+
assert.equal(fittedScale(100, 1024, 600), 100)
|
|
15
|
+
assert.equal(fittedScale(125, 390, 844), 100, 'a phone keeps its own layout at 100%')
|
|
16
|
+
assert.equal(fittedScale(110, 0, 0), 110, 'before the window is known')
|
|
17
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The smallest room, in the interface's own pixels, the desktop layout still works in. */
|
|
2
|
+
export const SCALE_FIT = { width: 1024, height: 600 }
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The interface size a window can actually take. The page is zoomed rather than reflowed,
|
|
6
|
+
* so the layout switches between desktop and phone on the real window width: zoomed past
|
|
7
|
+
* what the window holds, the desktop layout stays up and its edges fall off the screen.
|
|
8
|
+
* A larger choice is held back to what fits; a smaller one, or 100%, is always allowed,
|
|
9
|
+
* since below that the phone layout takes over.
|
|
10
|
+
*/
|
|
11
|
+
export function fittedScale(chosen: number, width: number, height: number): number {
|
|
12
|
+
if (!width || !height) return chosen
|
|
13
|
+
const fit = Math.floor(100 * Math.min(width / SCALE_FIT.width, height / SCALE_FIT.height))
|
|
14
|
+
return Math.max(Math.min(chosen, fit), Math.min(chosen, 100))
|
|
15
|
+
}
|
package/app/mail/tabKey.ts
CHANGED
|
@@ -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>(
|
|
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 <
|
|
19
|
+
if (now - memory.at < windowMs) {
|
|
16
20
|
const previous = memory.mark
|
|
17
21
|
memory.at = 0
|
|
18
22
|
memory.mark = null
|