mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(email): restyle inbox, composer & reply to the Rowboat design system
- Inbox (gmail-shell): map the bespoke --gm-* palette onto the app design tokens (background/foreground/border/accent/primary), drop the unloaded "Inter" font override so it inherits the app font, and remove the redundant ".light .gmail-shell" block so theming follows :root/.dark. - Composer: rebuilt on the shadcn Button/Input/Dialog primitives with app tokens; bring the AI describe-and-write bar and tone presets into reply mode. - Auto-generated reply block (editor.css): retokenize the Gmail-blue pill buttons and compose surface onto app tokens; inherit the editor font. - Add a Gmail-style "n" shortcut to open the composer from the inbox.
This commit is contained in:
parent
40d684684c
commit
824610f84f
3 changed files with 343 additions and 880 deletions
|
|
@ -82,64 +82,40 @@
|
|||
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Inbox surface — mapped onto the app's design tokens so it follows the same
|
||||
palette, accent, and theme switching as the rest of Rowboat. Light/dark is
|
||||
handled by the app tokens (:root / .dark), so no separate .light override. */
|
||||
.gmail-shell {
|
||||
--gm-bg: #0f0f12;
|
||||
--gm-bg-card: #131317;
|
||||
--gm-bg-input: #1c1c20;
|
||||
--gm-bg-row-hover: #16161a;
|
||||
--gm-bg-row-selected: #1a1620;
|
||||
--gm-bg-row-selected-hover: #1d1825;
|
||||
--gm-bg-iframe: #fafafa;
|
||||
--gm-bg-pill: #1c1c20;
|
||||
--gm-bg-pill-hover: #232328;
|
||||
--gm-text: #e4e4e7;
|
||||
--gm-text-strong: #fafafa;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #52525b;
|
||||
--gm-text-body: #d4d4d8;
|
||||
--gm-border: #1f1f24;
|
||||
--gm-border-strong: #2e2e35;
|
||||
--gm-accent: #a78bfa;
|
||||
--gm-accent-hover: #b9a6ff;
|
||||
--gm-accent-glow: rgba(167, 139, 250, 0.45);
|
||||
--gm-accent-fg: #18181b;
|
||||
--gm-icon-hover-bg: #1f1f24;
|
||||
--gm-placeholder: #52525b;
|
||||
--gm-bg: var(--background);
|
||||
--gm-bg-card: var(--card-surface);
|
||||
--gm-bg-input: var(--muted);
|
||||
--gm-bg-row-hover: var(--accent);
|
||||
--gm-bg-row-selected: var(--rowboat-wash);
|
||||
--gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background));
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: var(--background);
|
||||
--gm-bg-pill-hover: var(--accent);
|
||||
--gm-text: var(--foreground);
|
||||
--gm-text-strong: var(--foreground);
|
||||
--gm-text-muted: var(--muted-foreground);
|
||||
--gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background));
|
||||
--gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background));
|
||||
--gm-border: var(--border);
|
||||
--gm-border-strong: var(--rowboat-hairline);
|
||||
--gm-accent: var(--primary);
|
||||
--gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
--gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
--gm-accent-fg: var(--primary-foreground);
|
||||
--gm-icon-hover-bg: var(--accent);
|
||||
--gm-placeholder: var(--muted-foreground);
|
||||
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg);
|
||||
color: var(--gm-text);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.light .gmail-shell {
|
||||
--gm-bg: #ffffff;
|
||||
--gm-bg-card: #ffffff;
|
||||
--gm-bg-input: #f4f4f7;
|
||||
--gm-bg-row-hover: #f7f7f9;
|
||||
--gm-bg-row-selected: #f5f0ff;
|
||||
--gm-bg-row-selected-hover: #ece4ff;
|
||||
--gm-bg-iframe: #ffffff;
|
||||
--gm-bg-pill: #ffffff;
|
||||
--gm-bg-pill-hover: #f4f4f7;
|
||||
--gm-text: #27272a;
|
||||
--gm-text-strong: #09090b;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-faint: #a1a1aa;
|
||||
--gm-text-body: #3f3f46;
|
||||
--gm-border: #e4e4e7;
|
||||
--gm-border-strong: #d4d4d8;
|
||||
--gm-accent: #7c3aed;
|
||||
--gm-accent-hover: #6d28d9;
|
||||
--gm-accent-glow: rgba(124, 58, 237, 0.3);
|
||||
--gm-accent-fg: #ffffff;
|
||||
--gm-icon-hover-bg: #f4f4f7;
|
||||
--gm-placeholder: #a1a1aa;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.gmail-main {
|
||||
|
|
@ -326,7 +302,7 @@
|
|||
}
|
||||
|
||||
.gmail-row-action-danger:hover {
|
||||
color: #e8453c;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.gmail-row:hover {
|
||||
|
|
@ -714,714 +690,78 @@
|
|||
border-color: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
/* Standalone "new email" composer — centered modal popup */
|
||||
.gmail-compose-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
/* Dark defaults (mirrors .gmail-shell); .light overrides below. */
|
||||
.gmail-compose-modal,
|
||||
.gmail-compose-card {
|
||||
--gm-bg-card: #131317;
|
||||
--gm-bg-input: #1c1c20;
|
||||
--gm-bg-elevated: #1c1c20;
|
||||
--gm-bg-pill: #1c1c20;
|
||||
--gm-bg-pill-hover: #232328;
|
||||
--gm-text: #e4e4e7;
|
||||
--gm-text-strong: #fafafa;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-body: #d4d4d8;
|
||||
--gm-border: #1f1f24;
|
||||
--gm-border-strong: #2e2e35;
|
||||
--gm-accent: #a78bfa;
|
||||
--gm-accent-hover: #b9a6ff;
|
||||
--gm-accent-fg: #18181b;
|
||||
--gm-icon-hover-bg: #1f1f24;
|
||||
--gm-placeholder: #52525b;
|
||||
}
|
||||
|
||||
.light .gmail-compose-modal,
|
||||
.light .gmail-compose-card {
|
||||
--gm-bg-card: #ffffff;
|
||||
--gm-bg-input: #f4f4f7;
|
||||
--gm-bg-elevated: #ffffff;
|
||||
--gm-bg-pill: #ffffff;
|
||||
--gm-bg-pill-hover: #f4f4f7;
|
||||
--gm-text: #27272a;
|
||||
--gm-text-strong: #09090b;
|
||||
--gm-text-muted: #71717a;
|
||||
--gm-text-body: #3f3f46;
|
||||
--gm-border: #e4e4e7;
|
||||
--gm-border-strong: #d4d4d8;
|
||||
--gm-accent: #7c3aed;
|
||||
--gm-accent-hover: #6d28d9;
|
||||
--gm-accent-fg: #ffffff;
|
||||
--gm-icon-hover-bg: #f4f4f7;
|
||||
--gm-placeholder: #a1a1aa;
|
||||
}
|
||||
|
||||
.gmail-compose-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(840px, 100%);
|
||||
height: min(720px, calc(100vh - 64px));
|
||||
max-height: calc(100vh - 64px);
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg-card);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.gmail-compose-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 0 8px 0 14px;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-compose-modal-header > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gmail-compose-modal .gmail-compose-editor {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: none;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 12px 10px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button:hover:not(:disabled) {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
border-color: var(--gm-accent);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button:disabled,
|
||||
.gmail-compose-ai-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gmail-compose-card {
|
||||
max-width: 720px;
|
||||
margin-left: 40px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg-card);
|
||||
}
|
||||
|
||||
.gmail-compose-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text-body);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-compose-header button,
|
||||
.gmail-compose-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gmail-compose-line input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.gmail-compose-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-compose-subject-input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Recipient (To / Cc / Bcc) rows with editable chips */
|
||||
.gmail-recipient-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-label {
|
||||
flex: none;
|
||||
min-width: 28px;
|
||||
padding-top: 5px;
|
||||
color: var(--gm-text-muted);
|
||||
}
|
||||
|
||||
.gmail-recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: min(440px, 100%);
|
||||
background: var(--gm-bg-elevated, #ffffff);
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(24, 24, 27, 0.08),
|
||||
0 12px 32px rgba(24, 24, 27, 0.18);
|
||||
max-height: 296px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
transform-origin: top left;
|
||||
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes gmail-recipient-suggestions-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gm-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms linear;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion.is-active {
|
||||
background: rgba(99, 142, 255, 0.18);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-avatar {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--gm-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-match {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip-remove:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-recipient-input {
|
||||
flex: 1 1 80px;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmail-recipient-trailing {
|
||||
flex: none;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-recipient-toggles button:hover {
|
||||
color: var(--gm-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 10px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
background: var(--gm-bg-input);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-card);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover input:focus {
|
||||
border-color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply {
|
||||
background: var(--gm-accent) !important;
|
||||
border-color: var(--gm-accent) !important;
|
||||
color: var(--gm-accent-fg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover-apply:hover {
|
||||
background: var(--gm-accent-hover) !important;
|
||||
border-color: var(--gm-accent-hover) !important;
|
||||
}
|
||||
|
||||
.gmail-compose-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-tool:hover:not(:disabled) {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-tool:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gmail-compose-tool.is-active {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-tool-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 6px;
|
||||
background: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gmail-compose-content {
|
||||
/* Email composer body — Tiptap content, styled to the app's design system. */
|
||||
.compose-content {
|
||||
outline: none;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: var(--gm-text);
|
||||
font: 13px/1.55 inherit;
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gmail-compose-content p {
|
||||
.compose-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content p + p,
|
||||
.gmail-compose-content p + ul,
|
||||
.gmail-compose-content p + ol,
|
||||
.gmail-compose-content p + blockquote {
|
||||
.compose-content p + p,
|
||||
.compose-content p + ul,
|
||||
.compose-content p + ol,
|
||||
.compose-content p + blockquote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul,
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ul,
|
||||
.compose-content ol {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.gmail-compose-content ul {
|
||||
.compose-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.gmail-compose-content ol {
|
||||
.compose-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.gmail-compose-content li {
|
||||
.compose-content li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content li > p {
|
||||
.compose-content li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-content blockquote {
|
||||
.compose-content blockquote {
|
||||
margin: 4px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--gm-border-strong);
|
||||
color: var(--gm-text-muted);
|
||||
border-left: 2px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gmail-compose-content a {
|
||||
color: var(--gm-accent);
|
||||
.compose-content a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gmail-compose-content code {
|
||||
.compose-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--gm-bg-pill-hover);
|
||||
background: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gmail-compose-content p.is-editor-empty:first-child::before {
|
||||
.compose-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--gm-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gmail-compose-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 240px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 6px;
|
||||
background: var(--gm-bg-pill);
|
||||
font-size: 12px;
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-size {
|
||||
color: var(--gm-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0 0 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-remove:hover {
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-actions-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-refine-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-refine-button:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
border-color: var(--gm-accent);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-send-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--gm-accent);
|
||||
color: var(--gm-accent-fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gmail-send-button:hover {
|
||||
background: var(--gm-accent-hover);
|
||||
}
|
||||
|
||||
.gmail-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2, X } from 'lucide-react'
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -9,6 +9,9 @@ import { cn } from '@/lib/utils'
|
|||
import { toast } from '@/lib/toast'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
type GmailThread = blocks.GmailThread
|
||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||
|
|
@ -539,9 +542,11 @@ function ComposeToolbarButton({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={cn('gmail-compose-tool', isActive && 'is-active')}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('size-7 text-muted-foreground', isActive && 'bg-accent text-foreground')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
command()
|
||||
|
|
@ -552,43 +557,47 @@ function ComposeToolbarButton({
|
|||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
|
||||
return (
|
||||
<div className="gmail-compose-toolbar">
|
||||
<button
|
||||
<div className="flex min-w-0 flex-1 items-center gap-0.5 border-l border-border pl-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
className="gmail-compose-tool"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
aria-label="Undo"
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
<Undo2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="gmail-compose-tool"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
aria-label="Redo"
|
||||
title="Redo"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
<span className="gmail-compose-tool-sep" />
|
||||
<Redo2 className="size-3.5" />
|
||||
</Button>
|
||||
<span className="mx-1.5 h-4 w-px bg-border" />
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
command={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
label="Bold"
|
||||
>
|
||||
<Bold size={14} />
|
||||
<Bold className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
|
|
@ -596,7 +605,7 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
isActive={editor.isActive('italic')}
|
||||
label="Italic"
|
||||
>
|
||||
<Italic size={14} />
|
||||
<Italic className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
|
|
@ -604,16 +613,16 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
isActive={editor.isActive('strike')}
|
||||
label="Strikethrough"
|
||||
>
|
||||
<Strikethrough size={14} />
|
||||
<Strikethrough className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<span className="gmail-compose-tool-sep" />
|
||||
<span className="mx-1.5 h-4 w-px bg-border" />
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
command={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
label="Bulleted list"
|
||||
>
|
||||
<List size={14} />
|
||||
<List className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
|
|
@ -621,7 +630,7 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
isActive={editor.isActive('orderedList')}
|
||||
label="Numbered list"
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
<ListOrdered className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
|
|
@ -629,20 +638,22 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
isActive={editor.isActive('blockquote')}
|
||||
label="Quote"
|
||||
>
|
||||
<Quote size={14} />
|
||||
<Quote className="size-3.5" />
|
||||
</ComposeToolbarButton>
|
||||
<span className="gmail-compose-tool-sep" />
|
||||
<button
|
||||
<span className="mx-1.5 h-4 w-px bg-border" />
|
||||
<Button
|
||||
type="button"
|
||||
className={cn('gmail-compose-tool', editor.isActive('link') && 'is-active')}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('size-7 text-muted-foreground', editor.isActive('link') && 'bg-accent text-foreground')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onOpenLink}
|
||||
aria-label="Link"
|
||||
aria-pressed={editor.isActive('link')}
|
||||
title="Link"
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -678,7 +689,7 @@ function HighlightedText({ text, query }: { text: string; query: string }) {
|
|||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
|
||||
<mark className="bg-transparent p-0 font-bold text-inherit">{text.slice(idx, idx + q.length)}</mark>
|
||||
{text.slice(idx + q.length)}
|
||||
</>
|
||||
)
|
||||
|
|
@ -812,26 +823,30 @@ function RecipientField({
|
|||
const showSuggestions = isFocused && suggestions.length > 0
|
||||
|
||||
return (
|
||||
<div className="gmail-recipient-row">
|
||||
<span className="gmail-recipient-label">{label}</span>
|
||||
<div className="gmail-recipient-field" ref={fieldRef}>
|
||||
<div className="flex items-start gap-2 border-b border-border px-3 py-1.5 text-sm">
|
||||
<span className="min-w-7 pt-1.5 text-muted-foreground">{label}</span>
|
||||
<div className="relative flex min-w-0 flex-1 flex-wrap items-center gap-1" ref={fieldRef}>
|
||||
{value.map((token, index) => (
|
||||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||
<span
|
||||
key={`${token}-${index}`}
|
||||
className="inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-muted pl-2 pr-1 text-xs text-foreground"
|
||||
title={extractAddress(token)}
|
||||
>
|
||||
<span className="max-w-[240px] truncate">{recipientLabel(token)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-recipient-chip-remove"
|
||||
className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Remove ${extractAddress(token)}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== index))}
|
||||
>
|
||||
×
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="gmail-recipient-input"
|
||||
className="h-6 min-w-[80px] flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
|
@ -854,7 +869,11 @@ function RecipientField({
|
|||
}}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
|
||||
<ul
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 m-0 max-h-[296px] w-max min-w-[280px] max-w-[min(440px,100%)] list-none overflow-y-auto overscroll-contain rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
role="listbox"
|
||||
ref={listRef}
|
||||
>
|
||||
{suggestions.map((c, idx) => {
|
||||
const hue = contactHue(c.email)
|
||||
return (
|
||||
|
|
@ -862,7 +881,10 @@ function RecipientField({
|
|||
key={c.email}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-sm transition-colors',
|
||||
idx === activeIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
// Prevent input blur before click fires.
|
||||
event.preventDefault()
|
||||
|
|
@ -871,18 +893,18 @@ function RecipientField({
|
|||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<span
|
||||
className="gmail-recipient-suggestion-avatar"
|
||||
className="inline-flex size-6 flex-none items-center justify-center rounded-full text-xs font-semibold uppercase text-white"
|
||||
style={{ background: `hsl(${hue}, 60%, 42%)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{contactInitial(c)}
|
||||
</span>
|
||||
<span className="gmail-recipient-suggestion-text">
|
||||
<span className="gmail-recipient-suggestion-name">
|
||||
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
||||
<span className="truncate font-medium">
|
||||
<HighlightedText text={c.name || c.email} query={queryShown} />
|
||||
</span>
|
||||
{c.name && (
|
||||
<span className="gmail-recipient-suggestion-email">
|
||||
<span className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
<HighlightedText text={c.email} query={queryShown} />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -893,7 +915,7 @@ function RecipientField({
|
|||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||
{trailing && <div className="flex-none pt-1.5">{trailing}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1005,7 +1027,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: { class: 'gmail-compose-content' },
|
||||
attributes: { class: 'compose-content' },
|
||||
},
|
||||
content: initialContent,
|
||||
})
|
||||
|
|
@ -1054,16 +1076,17 @@ const ComposeBox = memo(function ComposeBox({
|
|||
}
|
||||
|
||||
// The signed-in account's display name, used to sign off AI-generated emails.
|
||||
// Loaded in every mode (new, reply, forward) since the AI writer is available
|
||||
// throughout and needs a name to sign off with.
|
||||
const [selfName, setSelfName] = useState<string>('')
|
||||
const selfFirstName = useMemo(() => firstNameFromDisplayName(selfName), [selfName])
|
||||
useEffect(() => {
|
||||
if (!isNew) return
|
||||
let cancelled = false
|
||||
window.ipc.invoke('gmail:getAccountName', {})
|
||||
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [isNew])
|
||||
}, [])
|
||||
|
||||
const [aiPrompt, setAiPrompt] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
|
@ -1101,6 +1124,18 @@ const ComposeBox = memo(function ComposeBox({
|
|||
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
|
||||
system = AI_GENERATE_SYSTEM
|
||||
const ctx: string[] = []
|
||||
// When replying or forwarding, give the model the thread it's responding
|
||||
// to (oldest first) so the draft is on-topic and references the right
|
||||
// points. New emails have no thread and skip this.
|
||||
if (thread) {
|
||||
const threadText = thread.messages
|
||||
.map((message, index) => {
|
||||
const header = message.from ? `From: ${message.from}\n` : ''
|
||||
return `--- Message ${index + 1} ---\n${header}${(message.body || '').trim()}`
|
||||
})
|
||||
.join('\n\n')
|
||||
ctx.push(`This is a ${modeLabel.toLowerCase()} to the following email thread (oldest first):\n${threadText}`)
|
||||
}
|
||||
// Use the recipients' names (from the contacts picker) so the AI can
|
||||
// address them naturally; fall back to the address when there's no name.
|
||||
const recipientNames = toList
|
||||
|
|
@ -1111,7 +1146,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
.filter(Boolean)
|
||||
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
|
||||
if (selfFirstName) ctx.push(`Sender's first name (sign off as this): ${selfFirstName}`)
|
||||
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
|
||||
if (isNew && subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
|
||||
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
|
||||
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
|
||||
} else {
|
||||
|
|
@ -1135,15 +1170,18 @@ const ComposeBox = memo(function ComposeBox({
|
|||
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
|
||||
if (aiMode === 'generate') {
|
||||
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
|
||||
if (generatedSubject) setSubject(generatedSubject)
|
||||
// Only new emails take the AI's subject; replies/forwards keep their
|
||||
// derived "Re:"/"Fwd:" subject (and don't expose a subject field).
|
||||
if (generatedSubject && isNew) setSubject(generatedSubject)
|
||||
// Always sign off with the account first name, even if the model omitted it.
|
||||
const signed = ensureSignature(body, selfFirstName)
|
||||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
|
||||
setHasGenerated(true)
|
||||
} else {
|
||||
// Rewrites also regenerate the subject so it stays in sync with the body.
|
||||
// Rewrites also regenerate the subject so it stays in sync with the body —
|
||||
// but only for new emails, to preserve a reply/forward's threaded subject.
|
||||
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
|
||||
if (rewrittenSubject) setSubject(rewrittenSubject)
|
||||
if (rewrittenSubject && isNew) setSubject(rewrittenSubject)
|
||||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -1301,84 +1339,100 @@ const ComposeBox = memo(function ComposeBox({
|
|||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
|
||||
onClick={isNew ? (event) => event.stopPropagation() : undefined}
|
||||
>
|
||||
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
|
||||
<span>{modeLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={isNew ? 'gmail-icon-button' : undefined}
|
||||
onClick={onClose}
|
||||
aria-label="Close compose"
|
||||
>×</button>
|
||||
</div>
|
||||
const inner = (
|
||||
<>
|
||||
<RecipientField
|
||||
label="To"
|
||||
value={toList}
|
||||
onChange={setToList}
|
||||
autoFocus={isNew || mode === 'forward'}
|
||||
trailing={
|
||||
<div className="gmail-recipient-toggles">
|
||||
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||||
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
|
||||
<div className="flex gap-2.5">
|
||||
{!showCc && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
onClick={() => setShowCc(true)}
|
||||
>Cc</button>
|
||||
)}
|
||||
{!showBcc && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
onClick={() => setShowBcc(true)}
|
||||
>Bcc</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
|
||||
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
|
||||
{isNew && (
|
||||
<>
|
||||
<div className="gmail-compose-ai-bar">
|
||||
<input
|
||||
className="gmail-compose-ai-input"
|
||||
value={aiPrompt}
|
||||
onChange={(event) => setAiPrompt(event.target.value)}
|
||||
placeholder={hasGenerated
|
||||
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
|
||||
: 'Describe the email and let AI write it…'}
|
||||
disabled={generating}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void runAiBar()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
onClick={() => { void runAiBar() }}
|
||||
disabled={generating}
|
||||
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
|
||||
>
|
||||
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
|
||||
{generating
|
||||
? (hasGenerated ? 'Editing…' : 'Writing…')
|
||||
: (hasGenerated ? 'Edit' : 'Write')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="gmail-compose-ai-presets">
|
||||
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
|
||||
{TONE_PRESETS.map((preset) => (
|
||||
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
value={aiPrompt}
|
||||
onChange={(event) => setAiPrompt(event.target.value)}
|
||||
placeholder={hasGenerated
|
||||
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
|
||||
: isNew
|
||||
? 'Describe the email and let AI write it…'
|
||||
: 'Describe your reply and let AI write it…'}
|
||||
disabled={generating}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void runAiBar()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => { void runAiBar() }}
|
||||
disabled={generating}
|
||||
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
|
||||
>
|
||||
{generating ? <LoaderIcon className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{generating
|
||||
? (hasGenerated ? 'Editing…' : 'Writing…')
|
||||
: (hasGenerated ? 'Edit' : 'Write')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border px-3 pb-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="rounded-full"
|
||||
onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }}
|
||||
disabled={generating}
|
||||
>Improve</Button>
|
||||
{TONE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="rounded-full"
|
||||
onClick={() => { void runAi(preset.instruction, 'rewrite') }}
|
||||
disabled={generating}
|
||||
>{preset.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
{(isNew || mode === 'forward') && (
|
||||
<div className="gmail-compose-line">
|
||||
<span className="gmail-compose-label">Subject</span>
|
||||
<div className="flex min-h-8 items-center gap-2 border-b border-border px-3 text-sm">
|
||||
<span className="text-muted-foreground">Subject</span>
|
||||
<input
|
||||
className="gmail-compose-subject-input"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cn('w-full overflow-y-auto', isNew ? 'min-h-0 flex-1' : 'max-h-[360px]')}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
@ -1390,26 +1444,34 @@ const ComposeBox = memo(function ComposeBox({
|
|||
}}
|
||||
/>
|
||||
{attachments.length > 0 && (
|
||||
<div className="gmail-compose-attachments">
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
|
||||
<Paperclip size={13} />
|
||||
<span className="gmail-compose-attachment-name">{att.filename}</span>
|
||||
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
|
||||
<div
|
||||
key={att.id}
|
||||
className="inline-flex max-w-60 items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs text-foreground"
|
||||
title={att.filename}
|
||||
>
|
||||
<Paperclip className="size-3 shrink-0" />
|
||||
<span className="truncate">{att.filename}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{formatAttachmentSize(att.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-compose-attachment-remove"
|
||||
className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
aria-label={`Remove ${att.filename}`}
|
||||
>×</button>
|
||||
><X className="size-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{linkOpen && (
|
||||
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
|
||||
<input
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-t border-border bg-muted/30 px-3 py-2"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Input
|
||||
ref={linkInputRef}
|
||||
className="h-7 flex-1 text-xs"
|
||||
value={linkUrl}
|
||||
onChange={(event) => setLinkUrl(event.target.value)}
|
||||
placeholder="https://example.com"
|
||||
|
|
@ -1423,58 +1485,99 @@ const ComposeBox = memo(function ComposeBox({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="gmail-compose-link-popover-apply" onClick={applyLink}>Apply</button>
|
||||
<button type="button" className="gmail-compose-link-popover-cancel" onClick={cancelLink}>Cancel</button>
|
||||
<Button type="button" size="xs" onClick={applyLink}>Apply</Button>
|
||||
<Button type="button" variant="outline" size="xs" onClick={cancelLink}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="gmail-compose-actions">
|
||||
<div className="gmail-compose-actions-primary">
|
||||
<button
|
||||
<div className="flex items-center gap-3 border-t border-border px-3 py-2.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="gmail-send-button"
|
||||
size="sm"
|
||||
onClick={() => { void sendInGmail() }}
|
||||
disabled={sending}
|
||||
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
|
||||
>
|
||||
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
{sending ? <LoaderIcon className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sending}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip size={15} />
|
||||
<Paperclip className="size-4" />
|
||||
Attach
|
||||
</button>
|
||||
</Button>
|
||||
{thread && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refineWithCopilot}
|
||||
title="Refine this draft with Copilot"
|
||||
>
|
||||
<Sparkles size={15} />
|
||||
<Sparkles className="size-4" />
|
||||
Refine
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
|
||||
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={onClose}>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isNew) {
|
||||
return (
|
||||
<div className="gmail-compose-overlay" onClick={onClose}>
|
||||
{card}
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
className="flex h-[min(720px,calc(100vh-4rem))] flex-col gap-0 overflow-hidden p-0 font-sans sm:max-w-[840px]"
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<DialogTitle className="flex-1 text-sm font-medium text-foreground">{modeLabel}</DialogTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close compose"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{inner}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
return card
|
||||
|
||||
return (
|
||||
<div className="ml-10 max-w-[720px] overflow-hidden rounded-lg border border-border bg-background font-sans">
|
||||
<div className="flex h-8 items-center justify-between border-b border-border px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">{modeLabel}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close compose"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{inner}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function ThreadDetail({
|
||||
|
|
@ -1691,6 +1794,30 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Gmail-style "n" to start a new message. EmailView only mounts while the
|
||||
// inbox is open, so this is naturally scoped to that view. Ignored while
|
||||
// typing in any field or when a dialog (compose/settings) is already up.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return
|
||||
if (composeOpen || settingsOpen) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
setComposeOpen(true)
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [composeOpen, settingsOpen])
|
||||
|
||||
useEffect(() => { persistedImportant = important }, [important])
|
||||
useEffect(() => { persistedOther = other }, [other])
|
||||
|
||||
|
|
|
|||
|
|
@ -1551,9 +1551,9 @@
|
|||
/* Compose / draft box */
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose {
|
||||
margin-top: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1562,13 +1562,13 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
|
||||
|
|
@ -1592,7 +1592,7 @@
|
|||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
|
||||
|
|
@ -1600,7 +1600,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
|
|
@ -1615,35 +1615,33 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
width: fit-content;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
color: var(--foreground);
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
|
||||
color: #fff;
|
||||
background: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
|
||||
background: #1765cc;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
|
||||
color: #fff;
|
||||
background: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
border-color: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-error,
|
||||
|
|
@ -1690,9 +1688,8 @@
|
|||
|
||||
/* ---- Emails inbox block (language-emails) ---- */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-card {
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
/* No font-family override — inherit the editor's app font (ui-sans-serif /
|
||||
system-ui) so the inbox block matches the rest of the Rowboat UI. */
|
||||
|
||||
.tiptap-editor .ProseMirror .email-inbox-title {
|
||||
font-size: 14px;
|
||||
|
|
@ -1768,7 +1765,6 @@
|
|||
.tiptap-editor .ProseMirror .email-inbox-sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue