From a16c08681b7181d7d3eb04246caa9132348bc035 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 6 May 2024 09:13:50 +0200 Subject: [PATCH] webmail: change many inline styles to using css classes, and add dark mode this started with looking into the dark mode of PR #163 by mattfbacon. it's a very good solution, especially for the amount of code. while looking into dark mode, some common problems with inverting colors are: - box-shadow start "glowing" which isn't great. likewise, semitransparent layers would become brighter, not darker. - while popups/overlays in light mode just stay the same white, in dark mode they should become lighter than the regular content because box shadows don't give enough contrast in dark mode. while looking at adding explicit styles for dark mode, it turns out that's easier when we work more with css rules/classes instead of inline styles (so we can use the @media rule). so we now also create css rules instead of working with inline styles a lot. benefits: - creating css rules is useful for items that repeat. they'll have a single css class. changing a style on a css class is now reflected in all elements of that kind (with that class) - css class names are helpful when inspecting the DOM while developing: they typically describe the function of the element. most css classes are defined near where they are used, often while making the element using the class (the css rule is created on first use). this changes moves colors used for styling to a single place in webmail/lib.ts. each property can get two values: one for regular/light mode, one for dark mode. that should prevent forgetting one of them and makes it easy to configure both. this change sets colors for the dark mode. i think the popups look better than in PR #163, but in other ways it may be worse. this is a start, we can tweak the styling. if we can reduce the number of needed colors some more, we could make them configurable in the webmail settings in the future. so this is also a step towards making the ui looks configurable as discussed in issue #107. --- webmail/lib.ts | 217 +++++++++++++++++--- webmail/msg.html | 8 - webmail/msg.js | 174 ++++++++++++++-- webmail/msg.ts | 8 +- webmail/text.html | 12 -- webmail/text.js | 172 ++++++++++++++-- webmail/text.ts | 6 +- webmail/webmail.html | 66 +----- webmail/webmail.js | 477 ++++++++++++++++++++++++++++++------------- webmail/webmail.ts | 477 ++++++++++++++++++++++++++----------------- 10 files changed, 1130 insertions(+), 487 deletions(-) diff --git a/webmail/lib.ts b/webmail/lib.ts index 751f754..44da000 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -1,11 +1,158 @@ // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -// For authentication/security results. -const underlineGreen = '#50c40f' -const underlineRed = '#e15d1c' -const underlineBlue = '#09f' -const underlineGrey = '#aaa' -const underlineYellow = 'yellow' +// We build CSS rules in JS. For several reasons: +// - To keep the style definitions closer to their use. +// - To make it easier to provide both light/regular and dark mode colors. +// - To use class names for styling, instead of the the many inline styles. +// Makes it easier to look through a DOM, and easier to change the style of all +// instances of a class. + +// We keep the default/regular styles and dark-mode styles in separate stylesheets. +const cssStyle = dom.style(attr.type('text/css')) +document.head.appendChild(cssStyle) +const styleSheet = cssStyle.sheet! + +const cssStyleDark = dom.style(attr.type('text/css')) +document.head.appendChild(cssStyleDark) +const styleSheetDark = cssStyleDark.sheet! +styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}') +const darkModeRule = styleSheetDark.cssRules[0] as CSSMediaRule + +let cssRules: { [selector: string]: string} = {} // For ensuring a selector has a single definition. +// Ensure a selector has the given style properties. If a style value is an array, +// it must have 2 elements. The first is the default value, the second used for a +// rule for dark mode. +const ensureCSS = (selector: string, styles: { [prop: string]: string | number | string[] }, important?: boolean) => { + // Check that a selector isn't added again with different styling. Only during development. + const checkConsistency = location.hostname === 'localhost' + if (cssRules[selector]) { + if (checkConsistency) { + const exp = JSON.stringify(styles) + if (cssRules[selector] !== exp) { + throw new Error('duplicate css rule for selector '+selector+', had '+cssRules[selector] + ', next '+exp) + } + } + return + } + cssRules[selector] = checkConsistency ? JSON.stringify(styles) : 'x' + + const index = styleSheet.cssRules.length + styleSheet.insertRule(selector + ' {}', index) + const st = (styleSheet.cssRules[index] as CSSStyleRule).style + let darkst: CSSStyleDeclaration | undefined + for (let [k, v] of Object.entries(styles)) { + // We've kept the camel-case in our code which we had from when we did "st[prop] = + // value". It is more convenient as object keys. So convert to kebab-case. + k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) + if (Array.isArray(v)) { + if (v.length !== 2) { + throw new Error('2 elements required for light/dark mode style, got '+v.length) + } + if (!darkst) { + const darkIndex = darkModeRule.cssRules.length + darkModeRule.insertRule(selector + ' {}', darkIndex) + darkst = (darkModeRule.cssRules[darkIndex] as CSSStyleRule).style + } + st.setProperty(k, ''+v[0], important ? 'important' : '') + darkst.setProperty(k, ''+v[1], important ? 'important' : '') + } else { + st.setProperty(k, ''+v, important ? 'important' : '') + } + } +} + +// Ensure CSS styling exists for a class, returning the same kind of object +// returned by dom._class, for use with dom.*-building functions. +const css = (className: string, styles: { [prop: string]: string | number | string[] }, important?: boolean): { _class: string[] } => { + ensureCSS('.'+className, styles, important) + return dom._class(className) +} + +// todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. +// todo: add the standard padding and border-radius, perhaps more. +// todo: could make some of these {prop: value} objects and pass them directly to css() +const styles = { + color: ['black', '#ddd'], + colorMild: ['#555', '#bbb'], + colorMilder: ['#666', '#aaa'], + backgroundColor: ['white', '#222'], + backgroundColorMild: ['#f8f8f8', '#080808'], + backgroundColorMilder: ['#999', '#777'], + borderColor: ['#ccc', '#333'], + mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], + msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], + boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + + buttonBackground: ['#eee', '#222'], + buttonBorderColor: ['#888', '#666'], + buttonHoverBackground: ['#ddd', '#333'], + + overlayOpaqueBackgroundColor: ['#eee', '#011'], + overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + + popupColor: ['black', 'white'], + popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], + popupBorderColor: ['#ccc', '#555'], + + highlightBackground: ['gold', '#a70167'], + highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], + highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + + mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], + mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + + msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], + msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], + msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + + buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], + buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + + warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + + successBackground: ['#d2f791', '#1fa204'], + emphasisBackground: ['#666', '#aaa'], + + // For authentication/security results. + underlineGreen: '#50c40f', + underlineRed: '#e15d1c', + underlineBlue: '#09f', + underlineGrey: '#888', +} +const styleClasses = { + // For quoted text, with multiple levels of indentations. + quoted: [ + css('quoted1', {color: ['#03828f', '#71f2ff']}), // red + css('quoted2', {color: ['#c7445c', 'rgb(236, 76, 76)']}), // green + css('quoted3', {color: ['#417c10', 'rgb(115, 230, 20)']}), // blue + ], + // When text switches between unicode scripts. + scriptswitch: css('scriptswitch', {textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)']}), + textMild: css('textMild', {color: styles.colorMild}), + // For keywords (also known as flags/labels/tags) on messages. + keyword: css('keyword', {padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor}), + msgHeaders: css('msgHeaders', {marginBottom: '1ex', width: '100%'}), +} + +ensureCSS('.msgHeaders td', {wordBreak: 'break-word'}) // Prevent horizontal scroll bar for long header values. +ensureCSS('.keyword.keywordCollapsed', {opacity: .75}), + +// Generic styling. +ensureCSS('*', {fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box'}) +ensureCSS('.mono, .mono *', {fontFamily: "'ubuntu mono', monospace" }) +ensureCSS('table td, table th', {padding: '.15em .25em'}) +ensureCSS('.pad', {padding: '.5em'}) +ensureCSS('iframe', {border: 0}) +ensureCSS('img, embed, video, iframe', {backgroundColor: 'white', color: 'black'}) +ensureCSS(':root', {backgroundColor: styles.backgroundColor, color: styles.color }) +ensureCSS('a', {color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)']}) +ensureCSS('a:visited', {color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)']}) + +// For message view with multiple inline elements (often a single text and multiple messages). +ensureCSS('.textmulti > *:nth-child(even)', {backgroundColor: ['#f4f4f4', '#141414']}) +ensureCSS('.textmulti > *', {padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }) +ensureCSS('.textmulti > *:first-child', {padding: '.5em'}) + // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the @@ -83,8 +230,7 @@ const renderText = (text: string): HTMLElement => { if (q == 0) { return [addLinks(line), '\n'] } - q = (q-1)%3 + 1 - return dom.div(dom._class('quoted'+q), addLinks(line)) + return dom.div(styleClasses.quoted[q%styleClasses.quoted.length], addLinks(line)) })) } @@ -133,36 +279,43 @@ const formatAddressValidated = (a: api.MessageAddress, m: api.Message, use: bool // historic messages were from a mailing list. We could add a heuristic based on // List-Id headers, but it would be unreliable... // todo: add field to Message with the exact results. + let name = '' let color = '' let title = '' switch (m.MsgFromValidation) { case api.Validation.ValidationStrict: - color = underlineGreen + name = 'Strict' + color = styles.underlineGreen title = 'Message would have matched a strict DMARC policy.' break case api.Validation.ValidationDMARC: - color = underlineGreen + name = 'DMARC' + color = styles.underlineGreen title = 'Message matched DMARC policy of domain.' break case api.Validation.ValidationRelaxed: - color = underlineGreen + name = 'Relaxed' + color = styles.underlineGreen title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.' break; case api.Validation.ValidationNone: if (m.IsForward || m.IsMailingList) { - color = underlineBlue + name = 'Forwardlist' + color = styles.underlineBlue title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.' } else { - color = underlineRed + name = 'Bad' + color = styles.underlineRed title = 'Either domain did not have a DMARC policy, or message did not adhere to it.' } break; default: // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + name = 'Unknown' title = 'Unknown DMARC verification result.' return dom.span(attr.title(title+extra), domstr) } - return dom.span(attr.title(title+extra), style({borderBottom: '1.5px solid '+color, textDecoration: 'none'}), domstr) + return dom.span(attr.title(title+extra), css('addressValidation'+name, {borderBottom: '1.5px solid', borderBottomColor: color, textDecoration: 'none'}), domstr) } let l: (string | HTMLElement)[] = [] @@ -223,13 +376,15 @@ const loadMsgheaderView = (msgheaderelem: HTMLElement, mi: api.MessageItem, more const msgenv = mi.Envelope const received = mi.Message.Received const receivedlocal = new Date(received.getTime()) + const msgHeaderFieldStyle = css('msgHeaderField', {textAlign: 'right', color: styles.colorMild, whiteSpace: 'nowrap'}) + const msgAttrStyle = css('msgAttr', {padding: '0px 0.15em', fontSize: '.9em'}) dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) dom.tr( - dom.td('From:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('From:', msgHeaderFieldStyle), dom.td( style({width: '100%'}), - dom.div(style({display: 'flex', justifyContent: 'space-between'}), + dom.div(css('msgFromReceivedSpread', {display: 'flex', justifyContent: 'space-between'}), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div( attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), @@ -239,36 +394,36 @@ const loadMsgheaderView = (msgheaderelem: HTMLElement, mi: api.MessageItem, more ), ), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr( - dom.td('Reply-To:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', ')), ), dom.tr( - dom.td('To:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || [])), ), (msgenv.CC || []).length === 0 ? [] : dom.tr( - dom.td('Cc:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || [])), ), (msgenv.BCC || []).length === 0 ? [] : dom.tr( - dom.td('Bcc:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || [])), ), dom.tr( - dom.td('Subject:', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td('Subject:', msgHeaderFieldStyle), dom.td( - dom.div(style({display: 'flex', justifyContent: 'space-between'}), + dom.div(css('msgSubjectAttrsSpread', {display: 'flex', justifyContent: 'space-between'}), dom.div(msgenv.Subject || ''), dom.div( - mi.Message.IsForward ? dom.span(style({padding: '0px 0.15em', fontSize: '.9em'}), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], - mi.Message.IsMailingList ? dom.span(style({padding: '0px 0.15em', fontSize: '.9em'}), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], - mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c'}), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], - mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f'}), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], - mi.Message.ReceivedRequireTLS ? dom.span(style({padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px'}), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], - mi.IsSigned ? dom.span(style({backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em'}), 'Message has a signature') : [], - mi.IsEncrypted ? dom.span(style({backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em'}), 'Message is encrypted') : [], + mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], + mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], + mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', {borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed}), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], + mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', {borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen}), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], + mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', {padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px'}), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], + mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', {backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em'}), 'Message has a signature') : [], + mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', {backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em'}), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => - dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw) }), ) : [], @@ -278,7 +433,7 @@ const loadMsgheaderView = (msgheaderelem: HTMLElement, mi: api.MessageItem, more ), moreHeaders.map(k => dom.tr( - dom.td(k+':', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td(k+':', msgHeaderFieldStyle), dom.td(), ) ), diff --git a/webmail/msg.html b/webmail/msg.html index 8bc7055..4f55ca0 100644 --- a/webmail/msg.html +++ b/webmail/msg.html @@ -5,14 +5,6 @@ -
Loading...
diff --git a/webmail/msg.js b/webmail/msg.js index e3f542f..b0412db 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1047,12 +1047,140 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -// For authentication/security results. -const underlineGreen = '#50c40f'; -const underlineRed = '#e15d1c'; -const underlineBlue = '#09f'; -const underlineGrey = '#aaa'; -const underlineYellow = 'yellow'; +// We build CSS rules in JS. For several reasons: +// - To keep the style definitions closer to their use. +// - To make it easier to provide both light/regular and dark mode colors. +// - To use class names for styling, instead of the the many inline styles. +// Makes it easier to look through a DOM, and easier to change the style of all +// instances of a class. +// We keep the default/regular styles and dark-mode styles in separate stylesheets. +const cssStyle = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyle); +const styleSheet = cssStyle.sheet; +const cssStyleDark = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyleDark); +const styleSheetDark = cssStyleDark.sheet; +styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); +const darkModeRule = styleSheetDark.cssRules[0]; +let cssRules = {}; // For ensuring a selector has a single definition. +// Ensure a selector has the given style properties. If a style value is an array, +// it must have 2 elements. The first is the default value, the second used for a +// rule for dark mode. +const ensureCSS = (selector, styles, important) => { + // Check that a selector isn't added again with different styling. Only during development. + const checkConsistency = location.hostname === 'localhost'; + if (cssRules[selector]) { + if (checkConsistency) { + const exp = JSON.stringify(styles); + if (cssRules[selector] !== exp) { + throw new Error('duplicate css rule for selector ' + selector + ', had ' + cssRules[selector] + ', next ' + exp); + } + } + return; + } + cssRules[selector] = checkConsistency ? JSON.stringify(styles) : 'x'; + const index = styleSheet.cssRules.length; + styleSheet.insertRule(selector + ' {}', index); + const st = styleSheet.cssRules[index].style; + let darkst; + for (let [k, v] of Object.entries(styles)) { + // We've kept the camel-case in our code which we had from when we did "st[prop] = + // value". It is more convenient as object keys. So convert to kebab-case. + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + if (Array.isArray(v)) { + if (v.length !== 2) { + throw new Error('2 elements required for light/dark mode style, got ' + v.length); + } + if (!darkst) { + const darkIndex = darkModeRule.cssRules.length; + darkModeRule.insertRule(selector + ' {}', darkIndex); + darkst = darkModeRule.cssRules[darkIndex].style; + } + st.setProperty(k, '' + v[0], important ? 'important' : ''); + darkst.setProperty(k, '' + v[1], important ? 'important' : ''); + } + else { + st.setProperty(k, '' + v, important ? 'important' : ''); + } + } +}; +// Ensure CSS styling exists for a class, returning the same kind of object +// returned by dom._class, for use with dom.*-building functions. +const css = (className, styles, important) => { + ensureCSS('.' + className, styles, important); + return dom._class(className); +}; +// todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. +// todo: add the standard padding and border-radius, perhaps more. +// todo: could make some of these {prop: value} objects and pass them directly to css() +const styles = { + color: ['black', '#ddd'], + colorMild: ['#555', '#bbb'], + colorMilder: ['#666', '#aaa'], + backgroundColor: ['white', '#222'], + backgroundColorMild: ['#f8f8f8', '#080808'], + backgroundColorMilder: ['#999', '#777'], + borderColor: ['#ccc', '#333'], + mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], + msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], + boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + buttonBackground: ['#eee', '#222'], + buttonBorderColor: ['#888', '#666'], + buttonHoverBackground: ['#ddd', '#333'], + overlayOpaqueBackgroundColor: ['#eee', '#011'], + overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + popupColor: ['black', 'white'], + popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], + popupBorderColor: ['#ccc', '#555'], + highlightBackground: ['gold', '#a70167'], + highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], + highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], + mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], + msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], + msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], + buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + successBackground: ['#d2f791', '#1fa204'], + emphasisBackground: ['#666', '#aaa'], + // For authentication/security results. + underlineGreen: '#50c40f', + underlineRed: '#e15d1c', + underlineBlue: '#09f', + underlineGrey: '#888', +}; +const styleClasses = { + // For quoted text, with multiple levels of indentations. + quoted: [ + css('quoted1', { color: ['#03828f', '#71f2ff'] }), + css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), + css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + ], + // When text switches between unicode scripts. + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + textMild: css('textMild', { color: styles.colorMild }), + // For keywords (also known as flags/labels/tags) on messages. + keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), + msgHeaders: css('msgHeaders', { marginBottom: '1ex', width: '100%' }), +}; +ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. +ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), + // Generic styling. + ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); +ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); +ensureCSS('table td, table th', { padding: '.15em .25em' }); +ensureCSS('.pad', { padding: '.5em' }); +ensureCSS('iframe', { border: 0 }); +ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); +ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); +ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); +ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +// For message view with multiple inline elements (often a single text and multiple messages). +ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); +ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); +ensureCSS('.textmulti > *:first-child', { padding: '.5em' }); // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -1127,8 +1255,7 @@ const renderText = (text) => { if (q == 0) { return [addLinks(line), '\n']; } - q = (q - 1) % 3 + 1; - return dom.div(dom._class('quoted' + q), addLinks(line)); + return dom.div(styleClasses.quoted[q % styleClasses.quoted.length], addLinks(line)); })); }; const displayName = (s) => { @@ -1172,37 +1299,44 @@ const formatAddressValidated = (a, m, use) => { // historic messages were from a mailing list. We could add a heuristic based on // List-Id headers, but it would be unreliable... // todo: add field to Message with the exact results. + let name = ''; let color = ''; let title = ''; switch (m.MsgFromValidation) { case api.Validation.ValidationStrict: - color = underlineGreen; + name = 'Strict'; + color = styles.underlineGreen; title = 'Message would have matched a strict DMARC policy.'; break; case api.Validation.ValidationDMARC: - color = underlineGreen; + name = 'DMARC'; + color = styles.underlineGreen; title = 'Message matched DMARC policy of domain.'; break; case api.Validation.ValidationRelaxed: - color = underlineGreen; + name = 'Relaxed'; + color = styles.underlineGreen; title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; break; case api.Validation.ValidationNone: if (m.IsForward || m.IsMailingList) { - color = underlineBlue; + name = 'Forwardlist'; + color = styles.underlineBlue; title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; } else { - color = underlineRed; + name = 'Bad'; + color = styles.underlineRed; title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; } break; default: // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + name = 'Unknown'; title = 'Unknown DMARC verification result.'; return dom.span(attr.title(title + extra), domstr); } - return dom.span(attr.title(title + extra), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), domstr); + return dom.span(attr.title(title + extra), css('addressValidation' + name, { borderBottom: '1.5px solid', borderBottomColor: color, textDecoration: 'none' }), domstr); }; let l = []; if (a.Name) { @@ -1246,20 +1380,22 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgenv = mi.Envelope; const received = mi.Message.Received; const receivedlocal = new Date(received.getTime()); + const msgHeaderFieldStyle = css('msgHeaderField', { textAlign: 'right', color: styles.colorMild, whiteSpace: 'nowrap' }); + const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); - })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); + })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. const init = () => { const mi = api.parser.MessageItem(messageItem); let msgattachmentview = dom.div(); if (mi.Attachments && mi.Attachments.length > 0) { - dom._kids(msgattachmentview, dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', join(mi.Attachments.map(a => a.Filename || '(unnamed)'), () => ', ')))); + dom._kids(msgattachmentview, dom.div(css('msgAttachments', { borderTop: '1px solid', borderTopColor: styles.borderColor }), dom.div(dom._class('pad'), 'Attachments: ', join(mi.Attachments.map(a => a.Filename || '(unnamed)'), () => ', ')))); } - const msgheaderview = dom.table(dom._class('msgheaders'), style({ marginBottom: '1ex', width: '100%' })); + const msgheaderview = dom.table(styleClasses.msgHeaders); loadMsgheaderView(msgheaderview, mi, [], null, true); const l = window.location.pathname.split('/'); const w = l[l.length - 1]; @@ -1280,7 +1416,7 @@ const init = () => { iframepath += '?sameorigin=true'; let iframe; const page = document.getElementById('page'); - dom._kids(page, dom.div(style({ backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc' }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), style({ border: '0', width: '100%', height: '100%' }), function load() { + dom._kids(page, dom.div(css('msgMeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), css('msgIframe', { width: '100%', height: '100%' }), function load() { // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; if (window.location.hash === '#print') { diff --git a/webmail/msg.ts b/webmail/msg.ts index e18314d..9204007 100644 --- a/webmail/msg.ts +++ b/webmail/msg.ts @@ -10,7 +10,7 @@ const init = () => { if (mi.Attachments && mi.Attachments.length > 0) { dom._kids(msgattachmentview, dom.div( - style({borderTop: '1px solid #ccc'}), + css('msgAttachments', {borderTop: '1px solid', borderTopColor: styles.borderColor}), dom.div(dom._class('pad'), 'Attachments: ', join(mi.Attachments.map(a => a.Filename || '(unnamed)'), () => ', '), @@ -19,7 +19,7 @@ const init = () => { ) } - const msgheaderview = dom.table(dom._class('msgheaders'), style({marginBottom: '1ex', width: '100%'})) + const msgheaderview = dom.table(styleClasses.msgHeaders) loadMsgheaderView(msgheaderview, mi, [], null, true) const l = window.location.pathname.split('/') @@ -41,14 +41,14 @@ const init = () => { const page = document.getElementById('page')! dom._kids(page, dom.div( - style({backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc'}), + css('msgMeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor}), msgheaderview, msgattachmentview, ), iframe=dom.iframe( attr.title('Message body.'), attr.src(iframepath), - style({border: '0', width: '100%', height: '100%'}), + css('msgIframe', {width: '100%', height: '100%'}), function load() { // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. iframe.style.height = iframe.contentDocument!.documentElement.scrollHeight+'px' diff --git a/webmail/text.html b/webmail/text.html index d5fa00f..a7103ff 100644 --- a/webmail/text.html +++ b/webmail/text.html @@ -4,18 +4,6 @@ -
Loading...
diff --git a/webmail/text.js b/webmail/text.js index a2219a0..dd67b01 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1047,12 +1047,140 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -// For authentication/security results. -const underlineGreen = '#50c40f'; -const underlineRed = '#e15d1c'; -const underlineBlue = '#09f'; -const underlineGrey = '#aaa'; -const underlineYellow = 'yellow'; +// We build CSS rules in JS. For several reasons: +// - To keep the style definitions closer to their use. +// - To make it easier to provide both light/regular and dark mode colors. +// - To use class names for styling, instead of the the many inline styles. +// Makes it easier to look through a DOM, and easier to change the style of all +// instances of a class. +// We keep the default/regular styles and dark-mode styles in separate stylesheets. +const cssStyle = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyle); +const styleSheet = cssStyle.sheet; +const cssStyleDark = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyleDark); +const styleSheetDark = cssStyleDark.sheet; +styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); +const darkModeRule = styleSheetDark.cssRules[0]; +let cssRules = {}; // For ensuring a selector has a single definition. +// Ensure a selector has the given style properties. If a style value is an array, +// it must have 2 elements. The first is the default value, the second used for a +// rule for dark mode. +const ensureCSS = (selector, styles, important) => { + // Check that a selector isn't added again with different styling. Only during development. + const checkConsistency = location.hostname === 'localhost'; + if (cssRules[selector]) { + if (checkConsistency) { + const exp = JSON.stringify(styles); + if (cssRules[selector] !== exp) { + throw new Error('duplicate css rule for selector ' + selector + ', had ' + cssRules[selector] + ', next ' + exp); + } + } + return; + } + cssRules[selector] = checkConsistency ? JSON.stringify(styles) : 'x'; + const index = styleSheet.cssRules.length; + styleSheet.insertRule(selector + ' {}', index); + const st = styleSheet.cssRules[index].style; + let darkst; + for (let [k, v] of Object.entries(styles)) { + // We've kept the camel-case in our code which we had from when we did "st[prop] = + // value". It is more convenient as object keys. So convert to kebab-case. + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + if (Array.isArray(v)) { + if (v.length !== 2) { + throw new Error('2 elements required for light/dark mode style, got ' + v.length); + } + if (!darkst) { + const darkIndex = darkModeRule.cssRules.length; + darkModeRule.insertRule(selector + ' {}', darkIndex); + darkst = darkModeRule.cssRules[darkIndex].style; + } + st.setProperty(k, '' + v[0], important ? 'important' : ''); + darkst.setProperty(k, '' + v[1], important ? 'important' : ''); + } + else { + st.setProperty(k, '' + v, important ? 'important' : ''); + } + } +}; +// Ensure CSS styling exists for a class, returning the same kind of object +// returned by dom._class, for use with dom.*-building functions. +const css = (className, styles, important) => { + ensureCSS('.' + className, styles, important); + return dom._class(className); +}; +// todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. +// todo: add the standard padding and border-radius, perhaps more. +// todo: could make some of these {prop: value} objects and pass them directly to css() +const styles = { + color: ['black', '#ddd'], + colorMild: ['#555', '#bbb'], + colorMilder: ['#666', '#aaa'], + backgroundColor: ['white', '#222'], + backgroundColorMild: ['#f8f8f8', '#080808'], + backgroundColorMilder: ['#999', '#777'], + borderColor: ['#ccc', '#333'], + mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], + msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], + boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + buttonBackground: ['#eee', '#222'], + buttonBorderColor: ['#888', '#666'], + buttonHoverBackground: ['#ddd', '#333'], + overlayOpaqueBackgroundColor: ['#eee', '#011'], + overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + popupColor: ['black', 'white'], + popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], + popupBorderColor: ['#ccc', '#555'], + highlightBackground: ['gold', '#a70167'], + highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], + highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], + mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], + msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], + msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], + buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + successBackground: ['#d2f791', '#1fa204'], + emphasisBackground: ['#666', '#aaa'], + // For authentication/security results. + underlineGreen: '#50c40f', + underlineRed: '#e15d1c', + underlineBlue: '#09f', + underlineGrey: '#888', +}; +const styleClasses = { + // For quoted text, with multiple levels of indentations. + quoted: [ + css('quoted1', { color: ['#03828f', '#71f2ff'] }), + css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), + css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + ], + // When text switches between unicode scripts. + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + textMild: css('textMild', { color: styles.colorMild }), + // For keywords (also known as flags/labels/tags) on messages. + keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), + msgHeaders: css('msgHeaders', { marginBottom: '1ex', width: '100%' }), +}; +ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. +ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), + // Generic styling. + ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); +ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); +ensureCSS('table td, table th', { padding: '.15em .25em' }); +ensureCSS('.pad', { padding: '.5em' }); +ensureCSS('iframe', { border: 0 }); +ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); +ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); +ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); +ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +// For message view with multiple inline elements (often a single text and multiple messages). +ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); +ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); +ensureCSS('.textmulti > *:first-child', { padding: '.5em' }); // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -1127,8 +1255,7 @@ const renderText = (text) => { if (q == 0) { return [addLinks(line), '\n']; } - q = (q - 1) % 3 + 1; - return dom.div(dom._class('quoted' + q), addLinks(line)); + return dom.div(styleClasses.quoted[q % styleClasses.quoted.length], addLinks(line)); })); }; const displayName = (s) => { @@ -1172,37 +1299,44 @@ const formatAddressValidated = (a, m, use) => { // historic messages were from a mailing list. We could add a heuristic based on // List-Id headers, but it would be unreliable... // todo: add field to Message with the exact results. + let name = ''; let color = ''; let title = ''; switch (m.MsgFromValidation) { case api.Validation.ValidationStrict: - color = underlineGreen; + name = 'Strict'; + color = styles.underlineGreen; title = 'Message would have matched a strict DMARC policy.'; break; case api.Validation.ValidationDMARC: - color = underlineGreen; + name = 'DMARC'; + color = styles.underlineGreen; title = 'Message matched DMARC policy of domain.'; break; case api.Validation.ValidationRelaxed: - color = underlineGreen; + name = 'Relaxed'; + color = styles.underlineGreen; title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; break; case api.Validation.ValidationNone: if (m.IsForward || m.IsMailingList) { - color = underlineBlue; + name = 'Forwardlist'; + color = styles.underlineBlue; title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; } else { - color = underlineRed; + name = 'Bad'; + color = styles.underlineRed; title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; } break; default: // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + name = 'Unknown'; title = 'Unknown DMARC verification result.'; return dom.span(attr.title(title + extra), domstr); } - return dom.span(attr.title(title + extra), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), domstr); + return dom.span(attr.title(title + extra), css('addressValidation' + name, { borderBottom: '1.5px solid', borderBottomColor: color, textDecoration: 'none' }), domstr); }; let l = []; if (a.Name) { @@ -1246,19 +1380,21 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgenv = mi.Envelope; const received = mi.Message.Received; const receivedlocal = new Date(received.getTime()); + const msgHeaderFieldStyle = css('msgHeaderField', { textAlign: 'right', color: styles.colorMild, whiteSpace: 'nowrap' }); + const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); - })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); + })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage); const mi = api.parser.MessageItem(messageItem); - dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), style({ whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { + dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', { whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { const pathStr = [0].concat(f.Path || []).join('.'); - return dom.div(dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('view/' + pathStr), attr.title(f.Filename), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)' })))); + return dom.div(dom.div(css('msgAttachment', { flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('view/' + pathStr), attr.title(f.Filename), css('msgAttachmentImage', { maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow })))); }))); }; init() diff --git a/webmail/text.ts b/webmail/text.ts index a4659bc..74798c6 100644 --- a/webmail/text.ts +++ b/webmail/text.ts @@ -9,17 +9,17 @@ const init = async () => { const mi = api.parser.MessageItem(messageItem) dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), - style({whiteSpace: 'pre-wrap'}), + css('msgTextPreformatted', {whiteSpace: 'pre-wrap'}), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { const pathStr = [0].concat(f.Path || []).join('.') return dom.div( dom.div( - style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), + css('msgAttachment', {flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), dom.img( attr.src('view/'+pathStr), attr.title(f.Filename), - style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)'}) + css('msgAttachmentImage', {maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow}) ), ) ) diff --git a/webmail/webmail.html b/webmail/webmail.html index 7a5305c..18c94d5 100644 --- a/webmail/webmail.html +++ b/webmail/webmail.html @@ -6,79 +6,15 @@ diff --git a/webmail/webmail.js b/webmail/webmail.js index 4c0863f..5823c2b 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1047,12 +1047,140 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -// For authentication/security results. -const underlineGreen = '#50c40f'; -const underlineRed = '#e15d1c'; -const underlineBlue = '#09f'; -const underlineGrey = '#aaa'; -const underlineYellow = 'yellow'; +// We build CSS rules in JS. For several reasons: +// - To keep the style definitions closer to their use. +// - To make it easier to provide both light/regular and dark mode colors. +// - To use class names for styling, instead of the the many inline styles. +// Makes it easier to look through a DOM, and easier to change the style of all +// instances of a class. +// We keep the default/regular styles and dark-mode styles in separate stylesheets. +const cssStyle = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyle); +const styleSheet = cssStyle.sheet; +const cssStyleDark = dom.style(attr.type('text/css')); +document.head.appendChild(cssStyleDark); +const styleSheetDark = cssStyleDark.sheet; +styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); +const darkModeRule = styleSheetDark.cssRules[0]; +let cssRules = {}; // For ensuring a selector has a single definition. +// Ensure a selector has the given style properties. If a style value is an array, +// it must have 2 elements. The first is the default value, the second used for a +// rule for dark mode. +const ensureCSS = (selector, styles, important) => { + // Check that a selector isn't added again with different styling. Only during development. + const checkConsistency = location.hostname === 'localhost'; + if (cssRules[selector]) { + if (checkConsistency) { + const exp = JSON.stringify(styles); + if (cssRules[selector] !== exp) { + throw new Error('duplicate css rule for selector ' + selector + ', had ' + cssRules[selector] + ', next ' + exp); + } + } + return; + } + cssRules[selector] = checkConsistency ? JSON.stringify(styles) : 'x'; + const index = styleSheet.cssRules.length; + styleSheet.insertRule(selector + ' {}', index); + const st = styleSheet.cssRules[index].style; + let darkst; + for (let [k, v] of Object.entries(styles)) { + // We've kept the camel-case in our code which we had from when we did "st[prop] = + // value". It is more convenient as object keys. So convert to kebab-case. + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + if (Array.isArray(v)) { + if (v.length !== 2) { + throw new Error('2 elements required for light/dark mode style, got ' + v.length); + } + if (!darkst) { + const darkIndex = darkModeRule.cssRules.length; + darkModeRule.insertRule(selector + ' {}', darkIndex); + darkst = darkModeRule.cssRules[darkIndex].style; + } + st.setProperty(k, '' + v[0], important ? 'important' : ''); + darkst.setProperty(k, '' + v[1], important ? 'important' : ''); + } + else { + st.setProperty(k, '' + v, important ? 'important' : ''); + } + } +}; +// Ensure CSS styling exists for a class, returning the same kind of object +// returned by dom._class, for use with dom.*-building functions. +const css = (className, styles, important) => { + ensureCSS('.' + className, styles, important); + return dom._class(className); +}; +// todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. +// todo: add the standard padding and border-radius, perhaps more. +// todo: could make some of these {prop: value} objects and pass them directly to css() +const styles = { + color: ['black', '#ddd'], + colorMild: ['#555', '#bbb'], + colorMilder: ['#666', '#aaa'], + backgroundColor: ['white', '#222'], + backgroundColorMild: ['#f8f8f8', '#080808'], + backgroundColorMilder: ['#999', '#777'], + borderColor: ['#ccc', '#333'], + mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], + msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], + boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + buttonBackground: ['#eee', '#222'], + buttonBorderColor: ['#888', '#666'], + buttonHoverBackground: ['#ddd', '#333'], + overlayOpaqueBackgroundColor: ['#eee', '#011'], + overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + popupColor: ['black', 'white'], + popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], + popupBorderColor: ['#ccc', '#555'], + highlightBackground: ['gold', '#a70167'], + highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], + highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], + mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], + msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], + msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], + buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + successBackground: ['#d2f791', '#1fa204'], + emphasisBackground: ['#666', '#aaa'], + // For authentication/security results. + underlineGreen: '#50c40f', + underlineRed: '#e15d1c', + underlineBlue: '#09f', + underlineGrey: '#888', +}; +const styleClasses = { + // For quoted text, with multiple levels of indentations. + quoted: [ + css('quoted1', { color: ['#03828f', '#71f2ff'] }), + css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), + css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + ], + // When text switches between unicode scripts. + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + textMild: css('textMild', { color: styles.colorMild }), + // For keywords (also known as flags/labels/tags) on messages. + keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), + msgHeaders: css('msgHeaders', { marginBottom: '1ex', width: '100%' }), +}; +ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. +ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), + // Generic styling. + ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); +ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); +ensureCSS('table td, table th', { padding: '.15em .25em' }); +ensureCSS('.pad', { padding: '.5em' }); +ensureCSS('iframe', { border: 0 }); +ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); +ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); +ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); +ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +// For message view with multiple inline elements (often a single text and multiple messages). +ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); +ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); +ensureCSS('.textmulti > *:first-child', { padding: '.5em' }); // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -1127,8 +1255,7 @@ const renderText = (text) => { if (q == 0) { return [addLinks(line), '\n']; } - q = (q - 1) % 3 + 1; - return dom.div(dom._class('quoted' + q), addLinks(line)); + return dom.div(styleClasses.quoted[q % styleClasses.quoted.length], addLinks(line)); })); }; const displayName = (s) => { @@ -1172,37 +1299,44 @@ const formatAddressValidated = (a, m, use) => { // historic messages were from a mailing list. We could add a heuristic based on // List-Id headers, but it would be unreliable... // todo: add field to Message with the exact results. + let name = ''; let color = ''; let title = ''; switch (m.MsgFromValidation) { case api.Validation.ValidationStrict: - color = underlineGreen; + name = 'Strict'; + color = styles.underlineGreen; title = 'Message would have matched a strict DMARC policy.'; break; case api.Validation.ValidationDMARC: - color = underlineGreen; + name = 'DMARC'; + color = styles.underlineGreen; title = 'Message matched DMARC policy of domain.'; break; case api.Validation.ValidationRelaxed: - color = underlineGreen; + name = 'Relaxed'; + color = styles.underlineGreen; title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; break; case api.Validation.ValidationNone: if (m.IsForward || m.IsMailingList) { - color = underlineBlue; + name = 'Forwardlist'; + color = styles.underlineBlue; title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; } else { - color = underlineRed; + name = 'Bad'; + color = styles.underlineRed; title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; } break; default: // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + name = 'Unknown'; title = 'Unknown DMARC verification result.'; return dom.span(attr.title(title + extra), domstr); } - return dom.span(attr.title(title + extra), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), domstr); + return dom.span(attr.title(title + extra), css('addressValidation' + name, { borderBottom: '1.5px solid', borderBottomColor: color, textDecoration: 'none' }), domstr); }; let l = []; if (a.Name) { @@ -1246,11 +1380,13 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgenv = mi.Envelope; const received = mi.Message.Received; const receivedlocal = new Date(received.getTime()); + const msgHeaderFieldStyle = css('msgHeaderField', { textAlign: 'right', color: styles.colorMild, whiteSpace: 'nowrap' }); + const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); - })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); + })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. /* @@ -1327,7 +1463,6 @@ Enable consistency checking in UI updates: - todo: only show orange underline where it could be a problem? in addresses and anchor texts. we may be lighting up a christmas tree now, desensitizing users. - todo: saved searches that are displayed below list of mailboxes, for quick access to preset view - todo: when search on free-form text is active, highlight the searched text in the message view. -- todo: composeView: save as draft, periodically and when closing. - todo: forwarding of html parts, including inline attachments, so the html version can be rendered like the original by the receiver. - todo: buttons/mechanism to operate on all messages in a mailbox/search query, without having to list and select all messages. e.g. clearing flags/labels. - todo: can we detect if browser supports proper CSP? if not, refuse to load html messages? @@ -1336,7 +1471,6 @@ Enable consistency checking in UI updates: - todo: configurable keyboard shortcuts? we use strings like "ctrl p" which we already generate and match on, add a mapping from command name to cmd* functions, and have a map of keys to command names. the commands for up/down with shift/ctrl modifiers may need special attention. - todo: consider composing messages with bcc headers that are sent as message Bcc headers to the bcc-addressees, optionally with checkbox. - todo: improve accessibility -- todo: threading mode where we don't show messages in Trash/Sent in thread? - todo: msglistView: preload next message? - todo: previews of zip files - todo: undo? @@ -1356,6 +1490,24 @@ const zindexes = { shortcut: '6', login: '7', }; +// Buttons and input elements. +ensureCSS('.button', { display: 'inline-block' }); +ensureCSS('button, .button, select', { color: styles.color, backgroundColor: styles.buttonBackground, border: '1px solid', borderColor: styles.buttonBorderColor, borderRadius: '.15em', padding: '0 .15em' }); +ensureCSS('button.active, .button.active, button.active:hover, .button.active:hover', { backgroundColor: styles.highlightBackground }); +ensureCSS('button:hover, .button:hover, select:hover', { backgroundColor: styles.buttonHoverBackground }); +ensureCSS('input, textarea', { backgroundColor: styles.backgroundColor, color: styles.color, border: '1px solid', borderColor: '#888', borderRadius: '.15em', padding: '0 .15em' }); +ensureCSS('input:hover, textarea:hover', { borderColor: styles.colorMilder }); +ensureCSS('.btngroup button, .btngroup .button', { borderRadius: 0, borderRightWidth: 0 }); +ensureCSS('.btngroup button:first-child, .btngroup .button:first-child', { borderRadius: '.15em 0 0 .15em' }); +ensureCSS('.btngroup button:last-child, .btngroup .button:last-child', { borderRadius: '0 .15em .15em 0', borderRightWidth: '1px' }); +const keywordButtonStyle = css('keywordButton', { cursor: 'pointer' }); +ensureCSS('.keywordButton:hover', { backgroundColor: styles.highlightBackgroundHover }); +const yscrollStyle = css('yscroll', { overflowY: 'scroll', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }); +const yscrollAutoStyle = css('yscrollAuto', { overflowY: 'auto', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }); +// Input elements that automatically grow based on input, with additional JS. +const autosizeStyle = css('autosize', { display: 'inline-grid', maxWidth: '90vw' }); +ensureCSS('.autosize.input', { gridArea: '1 / 2' }); +ensureCSS('.autosize::after', { content: 'attr(data-value)', marginRight: '1em', lineHeight: 0, visibility: 'hidden', whiteSpace: 'pre-wrap', overflowX: 'hidden' }); // All logging goes through log() instead of console.log, except "should not happen" logging. let log = () => { }; try { @@ -1483,7 +1635,19 @@ const login = async (reason) => { let autosize; let username; let password; - const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(style({ marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh' }), dom.form(async function submit(e) { + const root = dom.div(css('loginOverlay', { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: styles.overlayOpaqueBackgroundColor, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(css('sessionError', { marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(css('loginPopup', { + backgroundColor: styles.popupBackgroundColor, + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, + borderRadius: '.25em', + padding: '1em', + maxWidth: '95vw', + overflowX: 'auto', + maxHeight: '95vh', + overflowY: 'auto', + marginBottom: '20vh', + }), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); reasonElem.remove(); @@ -1544,7 +1708,7 @@ const envelopeIdentity = (l) => { return null; }; // We can display keyboard shortcuts when a user clicks a button that has a shortcut. -let shortcutElem = dom.div(style({ fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em' })); +let shortcutElem = dom.div(css('shortcutFlash', { fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em' })); let shortcutTimer = 0; const showShortcut = (c) => { if (!settings.showShortcuts) { @@ -1935,8 +2099,9 @@ const flagList = (miv) => { addFlags(miv.messageitem); } } - return msgflags.map(t => dom.span(dom._class('msgitemflag'), t[1], attr.title(t[0]))) - .concat(othermsgflags.map(t => dom.span(dom._class('msgitemflag'), dom._class('msgitemflagcollapsed'), t[1], attr.title(t[0])))); + const msgItemFlagStyle = css('msgItemFlag', { marginRight: '1px', fontWeight: 'normal', fontSize: '.9em' }); + return msgflags.map(t => dom.span(msgItemFlagStyle, t[1], attr.title(t[0]))) + .concat(othermsgflags.map(t => dom.span(msgItemFlagStyle, css('msgItemFlagCollapsed', { color: styles.colorMilder }), t[1], attr.title(t[0])))); }; // Turn filters from the search bar into filters with the refine filters (buttons // above message list) applied, to send to the server in a request. The original @@ -2144,7 +2309,7 @@ const popover = (target, opts, ...kids) => { style({ bottom: '' + (window.innerHeight - (pos.y - 1)) + 'px', maxHeight: '' + (pos.y - 1 - 10) + 'px' }) : style({ top: '' + (pos.y + pos.height + 1) + 'px', maxHeight: '' + (window.innerHeight - (pos.y + pos.height + 1) - 10) + 'px' })); let content; - const root = dom.div(style({ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: 'rgba(0, 0, 0, 0.2)' }), function click(e) { + const root = dom.div(css('popoverOverlay', { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: styles.overlayBackgroundColor }), function click(e) { e.stopPropagation(); close(); }, function keydown(e) { @@ -2152,15 +2317,18 @@ const popover = (target, opts, ...kids) => { e.stopPropagation(); close(); } - }, content = dom.div(attr.tabindex('0'), style({ + }, content = dom.div(attr.tabindex('0'), css('popoverContent', { position: 'absolute', overflowY: 'auto', }), posx, posy, opts.transparent ? [] : [ - style({ - backgroundColor: 'white', + css('popoverContentOpaque', { + backgroundColor: styles.popupBackgroundColor, padding: '1em', borderRadius: '.15em', - boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, + color: styles.popupColor, }), function click(e) { e.stopPropagation(); @@ -2196,7 +2364,7 @@ const popup = (...kids) => { } }; let content; - const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0, 0, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup }), function keydown(e) { + const root = dom.div(css('popupOverlay', { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: styles.overlayBackgroundColor, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup }), function keydown(e) { if (e.key === 'Escape') { e.stopPropagation(); close(); @@ -2204,7 +2372,7 @@ const popup = (...kids) => { }, function click(e) { e.stopPropagation(); close(); - }, content = dom.div(attr.tabindex('0'), style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto' }), function click(e) { + }, content = dom.div(attr.tabindex('0'), css('popupContent', { backgroundColor: styles.popupBackgroundColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, borderRadius: '.25em', padding: '1em', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto' }), function click(e) { e.stopPropagation(); }, kids)); popupOpen = true; @@ -2221,7 +2389,7 @@ const cmdSettings = async () => { if (!accountSettings) { window.alert('No account settings fetched yet.'); } - const remove = popup(style({ padding: '1em 1em 2em 1em', minWidth: '30em' }), dom.h1('Settings'), dom.form(async function submit(e) { + const remove = popup(css('popupSettings', { padding: '1em 1em 2em 1em', minWidth: '30em' }), dom.h1('Settings'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); const accSet = { @@ -2237,7 +2405,7 @@ const cmdSettings = async () => { }; // Show help popup, with shortcuts and basic explanation. const cmdHelp = async () => { - const remove = popup(style({ padding: '1em 1em 2em 1em' }), dom.h1('Help and keyboard shortcuts'), dom.div(style({ display: 'flex' }), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({ margin: '0' })))), [ + const remove = popup(css('popupHelp', { padding: '1em 1em 2em 1em' }), dom.h1('Help and keyboard shortcuts'), dom.div(style({ display: 'flex' }), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({ margin: '0' })))), [ ['c', 'compose new message'], ['/', 'search'], ['i', 'open inbox'], @@ -2248,7 +2416,7 @@ const cmdHelp = async () => { ['←', 'collapse'], ['→', 'expand'], ['b', 'show more actions'], - ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({ margin: '1ex 0 0 0' })))), dom.tr(dom.td('↓', ', j'), dom.td('down one message'), dom.td(attr.rowspan('6'), style({ color: '#888', borderLeft: '2px solid #ddd', paddingLeft: '.5em' }), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection')), [ + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({ margin: '1ex 0 0 0' })))), dom.tr(dom.td('↓', ', j'), dom.td('down one message'), dom.td(attr.rowspan('6'), css('helpSideNote', { color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em' }), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection')), [ [['↑', ', k'], 'up one message'], ['PageDown, l', 'down one screen'], ['PageUp, h', 'up one screen'], @@ -2373,7 +2541,7 @@ const cmdTooltip = async () => { popover(document.body, { transparent: true, fullscreen: true }, ...elems.map(e => { const title = e.getAttribute('title') || ''; const pos = e.getBoundingClientRect(); - return dom.div(style({ position: 'absolute', backgroundColor: 'black', color: 'white', borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em' }), pos.x < window.innerWidth / 3 ? + return dom.div(css('tooltipContent', { position: 'absolute', backgroundColor: ['black', 'white'], color: ['white', 'black'], borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em' }), pos.x < window.innerWidth / 3 ? style({ left: '' + (pos.x) + 'px' }) : style({ right: '' + (window.innerWidth - pos.x - pos.width) + 'px' }), pos.y + pos.height > window.innerHeight * 2 / 3 ? style({ bottom: '' + (window.innerHeight - (pos.y - 2)) + 'px', maxHeight: '' + (pos.y - 2) + 'px' }) : @@ -2607,22 +2775,22 @@ const compose = (opts, listMailboxes) => { } const color = (v) => { if (v === api.SecurityResult.SecurityResultYes) { - return underlineGreen; + return styles.underlineGreen; } else if (v === api.SecurityResult.SecurityResultNo) { - return underlineRed; + return styles.underlineRed; } else if (v === api.SecurityResult.SecurityResultUnknown) { - return 'white'; + return 'transparent'; } - return underlineGrey; + return styles.underlineGrey; }; const setBar = (c0, c1, c2, c3, c4) => { const stops = [ - c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%', - c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%', - c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%', - c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%', + c0 + ' 0%', c0 + ' 19%', 'transparent 19%', 'transparent 20%', + c1 + ' 20%', c1 + ' 39%', 'transparent 39%', 'transparent 40%', + c2 + ' 40%', c2 + ' 59%', 'transparent 59%', 'transparent 60%', + c3 + ' 60%', c3 + ' 79%', 'transparent 79%', 'transparent 80%', c4 + ' 80%', c4 + ' 100%', ].join(', '); securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1'; @@ -2694,7 +2862,7 @@ const compose = (opts, listMailboxes) => { }, function change() { autosizeElem.dataset.value = inputElem.value; fetchRecipientSecurity(); - }), securityBar = dom.span(dom._class('securitybar'), style({ + }), securityBar = dom.span(css('securitybar', { margin: '0 1px', borderBottom: '1.5px solid', borderBottomColor: 'transparent', @@ -2798,21 +2966,24 @@ const compose = (opts, listMailboxes) => { let resizeTimer = 0; const initWidth = window.innerWidth === settings.composeViewportWidth ? settings.composeWidth : 0; const initHeight = window.innerHeight === settings.composeViewportHeight ? settings.composeHeight : 0; - const composeElem = dom.div(style({ + const composeTextMildStyle = css('composeTextMild', { textAlign: 'right', color: styles.colorMild }); + const composeCellStyle = css('composeCell', { lineHeight: '1.5' }); + const composeElem = dom.div(css('composePopup', { position: 'fixed', bottom: '1ex', right: '1ex', zIndex: zindexes.compose, - backgroundColor: 'white', - boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', - border: '1px solid #ccc', + backgroundColor: styles.popupBackgroundColor, + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, padding: '1em', minWidth: '40em', maxWidth: '95vw', borderRadius: '.25em', display: 'flex', flexDirection: 'column', - }), initWidth ? style({ width: initWidth + 'px' }) : [], initHeight ? style({ height: initHeight + 'px' }) : [], dom.div(style({ position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize' }), function mousedown(e) { + }), initWidth ? style({ width: initWidth + 'px' }) : [], initHeight ? style({ height: initHeight + 'px' }) : [], dom.div(css('composeResizeGrab', { position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize' }), function mousedown(e) { resizeLast = null; startDrag(e, (e) => { if (resizeLast) { @@ -2831,18 +3002,18 @@ const compose = (opts, listMailboxes) => { } resizeLast = { x: e.clientX, y: e.clientY }; }); - }), dom.form(style({ + }), dom.form(css('composeForm', { flexGrow: '1', display: 'flex', flexDirection: 'column', - }), fieldset = dom.fieldset(style({ + }), fieldset = dom.fieldset(css('composeFields', { flexGrow: '1', display: 'flex', flexDirection: 'column', - }), dom.table(style({ width: '100%' }), dom.tr(dom.td(style({ textAlign: 'right', color: '#555' }), dom.span('From:')), dom.td(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(from = dom.select(attr.required(''), style({ width: 'auto' }), fromOptions), ' ', toBtn = dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', ccBtn = dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', bccBtn = dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', replyToBtn = dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', customFromBtn = dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts))), dom.div(listMailboxes().find(mb => mb.Draft) ? [ + }), dom.table(style({ width: '100%' }), dom.tr(dom.td(composeTextMildStyle, dom.span('From:')), dom.td(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(from = dom.select(attr.required(''), style({ width: 'auto' }), fromOptions), ' ', toBtn = dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', ccBtn = dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', bccBtn = dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', replyToBtn = dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', customFromBtn = dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts))), dom.div(listMailboxes().find(mb => mb.Draft) ? [ dom.clickbutton('Save', attr.title('Save draft message.'), clickCmd(cmdSave, shortcuts)), ' ', dom.clickbutton('Close', attr.title('Close window, saving draft message if body has changed or a draft was saved earlier.'), clickCmd(cmdClose, shortcuts)), ' ', - ] : [], dom.clickbutton('Cancel', attr.title('Close window, discarding (draft) message.'), clickCmd(cmdCancel, shortcuts)))))), toRow = dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555' })), toCell = dom.td(style({ lineHeight: '1.5' }))), replyToRow = dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555' })), replyToCell = dom.td(style({ lineHeight: '1.5' }))), ccRow = dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555' })), ccCell = dom.td(style({ lineHeight: '1.5' }))), bccRow = dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555' })), bccCell = dom.td(style({ lineHeight: '1.5' }))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555' })), dom.td(subjectAutosize = dom.span(dom._class('autosize'), style({ width: '100%' }), // Without 100% width, the span takes minimal width for input, we want the full table cell. + ] : [], dom.clickbutton('Cancel', attr.title('Close window, discarding (draft) message.'), clickCmd(cmdCancel, shortcuts)))))), toRow = dom.tr(dom.td('To:', composeTextMildStyle), toCell = dom.td(composeCellStyle)), replyToRow = dom.tr(dom.td('Reply-To:', composeTextMildStyle), replyToCell = dom.td(composeCellStyle)), ccRow = dom.tr(dom.td('Cc:', composeTextMildStyle), ccCell = dom.td(composeCellStyle)), bccRow = dom.tr(dom.td('Bcc:', composeTextMildStyle), bccCell = dom.td(composeCellStyle)), dom.tr(dom.td('Subject:', composeTextMildStyle), dom.td(subjectAutosize = dom.span(dom._class('autosize'), style({ width: '100%' }), // Without 100% width, the span takes minimal width for input, we want the full table cell. subject = dom.input(style({ width: '100%' }), attr.value(opts.subject || ''), attr.required(''), focusPlaceholder('subject...'), function input() { subjectAutosize.dataset.value = subject.value; }))))), body = dom.textarea(dom._class('mono'), style({ @@ -2861,16 +3032,16 @@ const compose = (opts, listMailboxes) => { const filename = a.Filename || '(unnamed)'; const size = formatSize(a.Part.DecodedSize); const checkbox = dom.input(attr.type('checkbox'), function change() { checkAttachments(); }); - const root = dom.label(checkbox, ' ' + filename + ' ', dom.span('(' + size + ') ', style({ color: '#666' }))); + const root = dom.label(checkbox, ' ' + filename + ' ', dom.span('(' + size + ') ', styleClasses.textMild)); const v = { path: a.Path || [], root: root, checkbox: checkbox }; return v; - }), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) { + }), dom.label(styleClasses.textMild, dom.input(attr.type('checkbox'), function change(e) { forwardAttachmentViews.forEach(v => v.checkbox.checked = e.target.checked); - }), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.label(style({ margin: '1ex 0', display: 'block' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.label(style({ margin: '1ex 0', display: 'block' }), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), 'TLS ', requiretls = dom.select(dom.option(attr.value(''), 'Default'), dom.option(attr.value('yes'), 'With RequireTLS'), dom.option(attr.value('no'), 'Fallback to insecure'))), dom.div(scheduleLink = dom.a(attr.href(''), 'Schedule', function click(e) { + }), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none' }), css('composeNoAttachmentsWarning', { backgroundColor: styles.warningBackgroundColor, padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.label(style({ margin: '1ex 0', display: 'block' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.label(style({ margin: '1ex 0', display: 'block' }), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), 'TLS ', requiretls = dom.select(dom.option(attr.value(''), 'Default'), dom.option(attr.value('yes'), 'With RequireTLS'), dom.option(attr.value('no'), 'Fallback to insecure'))), dom.div(scheduleLink = dom.a(attr.href(''), 'Schedule', function click(e) { e.preventDefault(); scheduleTime.value = localdatetime(new Date()); scheduleTimeChanged(); @@ -2949,7 +3120,7 @@ const labelsPopover = (e, msgs, possibleLabels) => { const msgIDs = msgs.map(m => m.ID); let fieldsetnew; let newlabel; - const remove = popover(e.target, {}, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '1ex' }), knownLabels.map(l => dom.div(dom.label(dom.input(attr.type('checkbox'), activeLabels.includes(l) ? attr.checked('') : [], style({ marginRight: '.5em' }), attr.title('Add/remove this label to the message(s), leaving other labels unchanged.'), async function change(e) { + const remove = popover(e.target, {}, dom.div(css('popoverLabels', { display: 'flex', flexDirection: 'column', gap: '1ex' }), knownLabels.map(l => dom.div(dom.label(dom.input(attr.type('checkbox'), activeLabels.includes(l) ? attr.checked('') : [], style({ marginRight: '.5em' }), attr.title('Add/remove this label to the message(s), leaving other labels unchanged.'), async function change(e) { if (activeLabels.includes(l)) { await withStatus('Removing label', client.FlagsClear(msgIDs, [l]), e.target); activeLabels.splice(activeLabels.indexOf(l), 1); @@ -2958,7 +3129,7 @@ const labelsPopover = (e, msgs, possibleLabels) => { await withStatus('Adding label', client.FlagsAdd(msgIDs, [l]), e.target); activeLabels.push(l); } - }), ' ', dom.span(dom._class('keyword'), l))))), dom.hr(style({ margin: '2ex 0' })), dom.form(async function submit(e) { + }), ' ', dom.span(styleClasses.keyword, l))))), dom.hr(style({ margin: '2ex 0' })), dom.form(async function submit(e) { e.preventDefault(); await withStatus('Adding new label', client.FlagsAdd(msgIDs, [newlabel.value]), fieldsetnew); remove(); @@ -2970,7 +3141,7 @@ const movePopover = (e, mailboxes, msgs) => { return; // Should not happen. } let msgsMailboxID = (msgs[0].MailboxID && msgs.filter(m => m.MailboxID === msgs[0].MailboxID).length === msgs.length) ? msgs[0].MailboxID : 0; - const remove = popover(e.target, {}, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.25em' }), mailboxes.map(mb => dom.div(dom.clickbutton(mb.Name, mb.ID === msgsMailboxID ? attr.disabled('') : [], async function click() { + const remove = popover(e.target, {}, dom.div(css('popoverMove', { display: 'flex', flexDirection: 'column', gap: '.25em' }), mailboxes.map(mb => dom.div(dom.clickbutton(mb.Name, mb.ID === msgsMailboxID ? attr.disabled('') : [], async function click() { const moveMsgs = msgs.filter(m => m.MailboxID !== mb.ID); const msgIDs = moveMsgs.map(m => m.ID); await withStatus('Moving to mailbox', client.MessageMove(msgIDs, mb.ID)); @@ -3032,7 +3203,7 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi // Timer to update the age of the message. let ageTimer = 0; // Show with a tag if we are in the cc/bcc headers, or - if none. - const identityTag = (s, title) => dom.span(dom._class('msgitemidentity'), s, attr.title(title)); + const identityTag = (s, title) => dom.span(css('msgItemIdentity', { padding: '0 .15em', marginLeft: '.15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', whiteSpace: 'nowrap', backgroundColor: styles.backgroundColorMilder, color: styles.color, border: '1px solid', borderColor: styles.colorMilder }), s, attr.title(title)); const identityHeader = []; if (!envelopeIdentity(mi.Envelope.From || []) && !envelopeIdentity(mi.Envelope.To || [])) { if (envelopeIdentity(mi.Envelope.CC || [])) { @@ -3112,7 +3283,7 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi // Keywords are normally shown per message. For collapsed threads, we show the // keywords of the thread root message as normal, and any additional keywords from // children in a way that draws less attention. - const keywords = (m.Keywords || []).map(kw => dom.span(dom._class('keyword'), kw)); + const keywords = (m.Keywords || []).map(kw => dom.span(styleClasses.keyword, kw)); if (msgitemView.isCollapsedThreadRoot()) { const keywordsSeen = new Set(); for (const kw of (m.Keywords || [])) { @@ -3122,7 +3293,7 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi for (const kw of (miv.messageitem.Message.Keywords || [])) { if (!keywordsSeen.has(kw)) { keywordsSeen.add(kw); - keywords.push(dom.span(dom._class('keyword'), dom._class('keywordcollapsed'), kw)); + keywords.push(dom.span(styleClasses.keyword, dom._class('keywordCollapsed'), kw)); } } } @@ -3200,7 +3371,7 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi name = first.substring(0, 8) + '/.../' + last.substring(0, 8); } } - const e = dom.span(dom._class('msgitemmailbox'), isCollapsedKid ? dom._class('msgitemmailboxcollapsed') : [], name === mb.Name ? [] : attr.title(mb.Name), name); + const e = dom.span(css('msgItemMailbox', { padding: '0 .15em', marginLeft: '.15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', whiteSpace: 'nowrap', background: styles.backgroundColorMilder, color: ['white', '#ddd'], border: '1px solid', borderColor: styles.colorMilder }), isCollapsedKid ? css('msgItemMailboxCollapsed', { background: '#eee', color: '#333' }, true) : [], name === mb.Name ? [] : attr.title(mb.Name), name); mailboxtags.push(e); }; const othermb = otherMailbox(m.MailboxID); @@ -3277,11 +3448,16 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi ], () => ', '), ]; }; + const msgItemCellStyle = css('msgItemCell', { padding: '2px 4px' }); + const msgItemStyle = css('msgItem', { display: 'flex', userSelect: 'none', cursor: 'pointer', borderRadius: '.15em', border: '1px solid transparent' }); + ensureCSS('.msgItem.focus', { borderColor: styles.msgItemFocusBorderColor, border: '1px solid' }); + ensureCSS('.msgItem:hover', { backgroundColor: styles.msgItemHoverBackgroundColor }); + ensureCSS('.msgItem.active', { background: styles.msgItemActiveBackground }); // When rerendering, we remember active & focus states. So we don't have to make // the caller also call redraw on MsglistView. const active = msgitemView.root && msgitemView.root.classList.contains('active'); const focus = msgitemView.root && msgitemView.root.classList.contains('focus'); - const elem = dom.div(dom._class('msgitem'), active ? dom._class('active') : [], focus ? dom._class('focus') : [], attr.draggable('true'), function dragstart(e) { + const elem = dom.div(msgItemStyle, active ? dom._class('active') : [], focus ? dom._class('focus') : [], attr.draggable('true'), function dragstart(e) { if (!msglistView.selected().includes(msgitemView)) { e.preventDefault(); window.alert('Can only drag items in selection.'); @@ -3308,9 +3484,9 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi msglistView.threadCollapse(msgitemView); msglistView.viewportEnsureMessages(); } - } : [], isUnread() ? style({ fontWeight: 'bold' }) : [], + } : [], isUnread() ? css('msgItemUnread', { fontWeight: 'bold' }) : [], // Relevant means not muted and matching the query. - isRelevant() ? [] : style({ opacity: '.4' }), dom.div(dom._class('msgitemcell', 'msgitemflags'), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(flagList(msgitemView)), !msgitemView.parent && msgitemView.kids.length > 0 && msgitemView.collapsed ? + isRelevant() ? [] : css('msgItemNotRelevant', { opacity: '.4' }), dom.div(msgItemCellStyle, dom._class('msgItemFlags'), dom.div(css('msgItemFlagsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(flagList(msgitemView)), !msgitemView.parent && msgitemView.kids.length > 0 && msgitemView.collapsed ? dom.clickbutton('' + (1 + msgitemView.descendants().length), attr.tabindex('-1'), attr.title('Expand thread.'), attr.arialabel('Expand thread.'), function click(e) { e.stopPropagation(); // Prevent selection. if (settings.threading === api.ThreadMode.ThreadOn) { @@ -3325,14 +3501,14 @@ const newMsgitemView = (mi, msglistView, otherMailbox, listMailboxes, receivedTi } msglistView.threadCollapse(msgitemView); msglistView.viewportEnsureMessages(); - }) : [])), dom.div(dom._class('msgitemcell', 'msgitemfrom'), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(dom._class('msgitemfromtext', 'silenttitle'), correspondents()), identityHeader), + }) : [])), dom.div(msgItemCellStyle, dom._class('msgItemFrom'), dom.div(css('msgItemFromSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(dom._class('silenttitle'), css('msgItemFromText', { whiteSpace: 'nowrap', overflow: 'hidden' }), correspondents()), identityHeader), // Thread messages are connected by a vertical bar. The first and last message are // only half the height of the item, to indicate start/end, and so it stands out // from any thread above/below. ((msgitemView.parent || msgitemView.kids.length > 0) && !msgitemView.threadRoot().collapsed) ? - dom.div(dom._class('msgitemfromthreadbar'), !msgitemView.parent ? style({ top: '50%', bottom: '-1px' }) : (isThreadLast() ? - style({ top: '-1px', bottom: '50%' }) : - style({ top: '-1px', bottom: '-1px' }))) : []), dom.div(dom._class('msgitemcell', 'msgitemsubject'), dom.div(style({ display: 'flex', justifyContent: 'space-between', position: 'relative' }), dom.div(dom._class('msgitemsubjecttext'), threadIndent > 0 ? dom.span(threadChar, style({ paddingLeft: (threadIndent / 2) + 'em', color: '#444', fontWeight: 'normal' }), threadCharTitle ? attr.title(threadCharTitle) : []) : [], msgitemView.parent ? [] : mi.Envelope.Subject || '(no subject)', dom.span(dom._class('msgitemsubjectsnippet'), ' ' + mi.FirstLine)), dom.div(keywords, mailboxtags))), dom.div(dom._class('msgitemcell', 'msgitemage'), age(received())), function click(e) { + dom.div(css('msgItemThreadBar', { position: 'absolute', right: 0, top: 0, bottom: 0, borderRight: '2px solid', borderRightColor: styles.colorMilder }), !msgitemView.parent ? css('msgItemThreadBarFirst', { top: '50%', bottom: '-1px' }) : (isThreadLast() ? + css('msgItemThreadBarLast', { top: '-1px', bottom: '50%' }) : + css('msgItemThreadBarMiddle', { top: '-1px', bottom: '-1px' }))) : []), dom.div(msgItemCellStyle, css('msgItemSubject', { position: 'relative' }), dom.div(css('msgItemSubjectSpread', { display: 'flex', justifyContent: 'space-between', position: 'relative' }), dom.div(css('msgItemSubjectText', { whiteSpace: 'nowrap', overflow: 'hidden' }), threadIndent > 0 ? dom.span(threadChar, style({ paddingLeft: (threadIndent / 2) + 'em' }), css('msgItemThreadChar', { opacity: '.75', fontWeight: 'normal' }), threadCharTitle ? attr.title(threadCharTitle) : []) : [], msgitemView.parent ? [] : mi.Envelope.Subject || '(no subject)', dom.span(css('msgItemSubjectSnippet', { fontWeight: 'normal', color: styles.colorMilder }), ' ' + mi.FirstLine)), dom.div(keywords, mailboxtags))), dom.div(msgItemCellStyle, dom._class('msgItemAge'), age(received())), function click(e) { e.preventDefault(); e.stopPropagation(); msglistView.click(msgitemView, e.ctrlKey, e.shiftKey); @@ -3609,8 +3785,8 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }; const cmdShowInternals = async () => { const pm = await parsedMessagePromise; - const mimepart = (p) => dom.li((p.MediaType + '/' + p.MediaSubType).toLowerCase(), p.ContentTypeParams ? ' ' + JSON.stringify(p.ContentTypeParams) : [], p.Parts && p.Parts.length === 0 ? [] : dom.ul(style({ listStyle: 'disc', marginLeft: '1em' }), (p.Parts || []).map(pp => mimepart(pp)))); - popup(style({ display: 'flex', gap: '1em' }), dom.div(dom.h1('Mime structure'), dom.ul(style({ listStyle: 'disc', marginLeft: '1em' }), mimepart(pm.Part))), dom.div(style({ whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), dom.div(style({ whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t'))); + const mimepart = (p) => dom.li((p.MediaType + '/' + p.MediaSubType).toLowerCase(), p.ContentTypeParams ? ' ' + JSON.stringify(p.ContentTypeParams) : [], p.Parts && p.Parts.length === 0 ? [] : dom.ul(css('internalsList', { listStyle: 'disc', marginLeft: '1em' }), (p.Parts || []).map(pp => mimepart(pp)))); + popup(css('popupInternals', { display: 'flex', gap: '1em' }), dom.div(dom.h1('Mime structure'), dom.ul(css('internalsList', { listStyle: 'disc', marginLeft: '1em' }), mimepart(pm.Part))), dom.div(css('internalsMessage', { whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), dom.div(css('internalsParts', { whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%' }), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t'))); }; const cmdUp = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollTop - 3 * msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth' }); }; const cmdDown = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollTop + 3 * msgscrollElem.getBoundingClientRect().height / 4, behavior: 'smooth' }); }; @@ -3647,12 +3823,12 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad let urlType; // text, html, htmlexternal; for opening in new tab/print let msgbuttonElem, msgheaderElem, msgattachmentElem, msgmodeElem; let msgheaderdetailsElem = null; // When full headers are visible, or some headers are requested through settings. - const msgmetaElem = dom.div(style({ backgroundColor: '#f8f8f8', borderBottom: '5px solid white', maxHeight: '90%', overflowY: 'auto' }), attr.role('region'), attr.arialabel('Buttons and headers for message'), msgbuttonElem = dom.div(), dom.div(attr.arialive('assertive'), msgheaderElem = dom.table(dom._class('msgheaders'), style({ marginBottom: '1ex', width: '100%' })), msgattachmentElem = dom.div(), msgmodeElem = dom.div()), - // Explicit gray line with white border below that separates headers from body, to + const msgmetaElem = dom.div(css('msgmeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '5px solid', borderBottomColor: ['white', 'black'], maxHeight: '90%', overflowY: 'auto' }), attr.role('region'), attr.arialabel('Buttons and headers for message'), msgbuttonElem = dom.div(), dom.div(attr.arialive('assertive'), msgheaderElem = dom.table(styleClasses.msgHeaders), msgattachmentElem = dom.div(), msgmodeElem = dom.div()), + // Explicit separator that separates headers from body, to // prevent HTML messages from faking UI elements. - dom.div(style({ height: '2px', backgroundColor: '#ccc' }))); - const msgscrollElem = dom.div(dom._class('pad', 'yscrollauto'), attr.role('region'), attr.arialabel('Message body'), style({ backgroundColor: 'white' })); - const msgcontentElem = dom.div(dom._class('scrollparent'), style({ flexGrow: '1' })); + dom.div(css('headerBodySeparator', { height: '2px', backgroundColor: styles.borderColor }))); + const msgscrollElem = dom.div(dom._class('pad'), yscrollAutoStyle, attr.role('region'), attr.arialabel('Message body'), css('msgscroll', { backgroundColor: styles.backgroundColor })); + const msgcontentElem = dom.div(css('scrollparent', { position: 'relative', flexGrow: '1' })); const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID; // Initially called with potentially null pm, once loaded called again with pm set. const loadButtons = (pm) => { @@ -3664,7 +3840,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels.'), function click(e) { labelsPopover(e, [m], possibleLabels); }), ' ', dom.clickbutton('More...', attr.title('Show more actions.'), function click(e) { - popover(e.target, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right' }), [ + popover(e.target, { transparent: true }, dom.div(css('popupMore', { display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right' }), [ dom.clickbutton('Print', attr.title('Print message, opens in new tab and opens print dialog.'), clickCmd(cmdPrint, shortcuts)), dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdMarkNotJunk, shortcuts)), dom.clickbutton('Mark Read', clickCmd(msglistView.cmdMarkRead, shortcuts)), @@ -3679,6 +3855,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }; loadButtons(parsedMessageOpt || null); loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false); + const headerTextMildStyle = css('headerTextMild', { textAlign: 'right', color: styles.colorMild }); const loadHeaderDetails = (pm) => { if (msgheaderdetailsElem) { msgheaderdetailsElem.remove(); @@ -3687,7 +3864,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad if (!settings.showAllHeaders) { return; } - msgheaderdetailsElem = dom.table(style({ marginBottom: '1ex', width: '100%' }), Object.entries(pm.Headers || {}).sort().map(t => (t[1] || []).map(v => dom.tr(dom.td(t[0] + ':', style({ textAlign: 'right', color: '#555' })), dom.td(v))))); + msgheaderdetailsElem = dom.table(css('msgHeaderDetails', { marginBottom: '1ex', width: '100%' }), Object.entries(pm.Headers || {}).sort().map(t => (t[1] || []).map(v => dom.tr(dom.td(t[0] + ':', headerTextMildStyle), dom.td(v))))); msgattachmentElem.parentNode.insertBefore(msgheaderdetailsElem, msgattachmentElem); }; const isText = (a) => ['text', 'message'].includes(a.Part.MediaType.toLowerCase()); @@ -3738,21 +3915,23 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad '$': cmdViewLast, Escape: cmdViewClose, }; + const attachmentsArrowStyle = css('attachmentsArrow', { color: styles.backgroundColor, backgroundColor: styles.color, width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold' }); + const attachmentsIframeStyle = css('attachmentsIframe', { flexGrow: 1, boxShadow: styles.boxShadow, backgroundColor: styles.popupBackgroundColor, margin: '0 5em' }); let content; - const popupRoot = dom.div(style({ position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments }), function click(e) { + const popupRoot = dom.div(css('attachmentsOverlay', { position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: styles.overlayBackgroundColor, display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments }), function click(e) { e.stopPropagation(); cmdViewClose(); - }, attr.tabindex('0'), !(index > 0) ? [] : dom.div(style({ position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), style({ backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold' }), attr.title('To previous viewable attachment.'), '←'), attr.tabindex('0'), clickCmd(cmdViewPrev, attachShortcuts), enterCmd(cmdViewPrev, attachShortcuts)), dom.div(style({ textAlign: 'center', paddingBottom: '30px' }), dom.span(dom._class('pad'), function click(e) { + }, attr.tabindex('0'), !(index > 0) ? [] : dom.div(css('attachmentsPrevious', { position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), attachmentsArrowStyle, attr.title('To previous viewable attachment.'), '←'), attr.tabindex('0'), clickCmd(cmdViewPrev, attachShortcuts), enterCmd(cmdViewPrev, attachShortcuts)), dom.div(css('attachmentsDownloadHeaderBox', { textAlign: 'center', paddingBottom: '30px' }), dom.span(dom._class('pad'), function click(e) { e.stopPropagation(); - }, style({ backgroundColor: 'white', borderRadius: '.25em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd' }), a.Filename || '(unnamed)', ' - ', formatSize(a.Part.DecodedSize), ' - ', dom.a('Download', attr.download(''), attr.href('msg/' + m.ID + '/download/' + pathStr), function click(e) { e.stopPropagation(); }))), isImage(a) ? - dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)', margin: '0 5em' }), dom.img(attr.src('msg/' + m.ID + '/view/' + pathStr), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', margin: '0 30px' }))) : (isText(a) ? - dom.iframe(attr.title('Attachment shown as text.'), style({ flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em' }), attr.src('msg/' + m.ID + '/viewtext/' + pathStr)) : (isPDF(a) ? - dom.iframe(style({ flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em' }), attr.title('Attachment as PDF.'), attr.src('msg/' + m.ID + '/view/' + pathStr)) : + }, css('attachmentsDownloadHeader', { backgroundColor: styles.popupBackgroundColor, color: styles.popupColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, borderRadius: '.25em' }), a.Filename || '(unnamed)', ' - ', formatSize(a.Part.DecodedSize), ' - ', dom.a('Download', attr.download(''), attr.href('msg/' + m.ID + '/download/' + pathStr), function click(e) { e.stopPropagation(); }))), isImage(a) ? + dom.div(css('attachmentsImageBox', { flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)', margin: '0 5em' }), dom.img(css('attachmentsImage', { maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow, margin: '0 30px' }), attr.src('msg/' + m.ID + '/view/' + pathStr))) : (isText(a) ? + dom.iframe(attr.title('Attachment shown as text.'), attachmentsIframeStyle, attr.src('msg/' + m.ID + '/viewtext/' + pathStr)) : (isPDF(a) ? + dom.iframe(attr.title('Attachment as PDF.'), attachmentsIframeStyle, attr.src('msg/' + m.ID + '/view/' + pathStr)) : content = dom.div(function click(e) { e.stopPropagation(); - }, style({ minWidth: '30em', padding: '2ex', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em', textAlign: 'center' }), dom.div(style({ marginBottom: '2ex' }), 'Attachment could be a binary file.'), dom.clickbutton('View as text', function click() { - content.replaceWith(dom.iframe(attr.title('Attachment shown as text, though it could be a binary file.'), style({ flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em' }), attr.src('msg/' + m.ID + '/viewtext/' + pathStr))); - })))), !(index < attachments.length - 1) ? [] : dom.div(style({ position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), style({ backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold' }), attr.title('To next viewable attachment.'), '→'), attr.tabindex('0'), clickCmd(cmdViewNext, attachShortcuts), enterCmd(cmdViewNext, attachShortcuts))); + }, css('attachmentsBinary', { minWidth: '30em', padding: '2ex', boxShadow: styles.boxShadow, backgroundColor: styles.popupBackgroundColor, margin: '0 5em', textAlign: 'center' }), dom.div(style({ marginBottom: '2ex' }), 'Attachment could be a binary file.'), dom.clickbutton('View as text', function click() { + content.replaceWith(dom.iframe(attr.title('Attachment shown as text, though it could be a binary file.'), attachmentsIframeStyle, attr.src('msg/' + m.ID + '/viewtext/' + pathStr))); + })))), !(index < attachments.length - 1) ? [] : dom.div(css('attachmentsNext', { position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer' }), dom.div(dom._class('silenttitle'), attachmentsArrowStyle, attr.title('To next viewable attachment.'), '→'), attr.tabindex('0'), clickCmd(cmdViewNext, attachShortcuts), enterCmd(cmdViewNext, attachShortcuts))); document.body.appendChild(popupRoot); popupRoot.focus(); attachmentView = { key: keyHandler(attachShortcuts) }; @@ -3760,7 +3939,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad var filesAll = false; const renderAttachments = () => { const l = mi.Attachments || []; - dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', l.slice(0, filesAll ? l.length : 4).map(a => { + dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div(css('inlineAttachmentsSeparator', { borderTop: '1px solid', borderTopColor: styles.borderColor }), dom.div(dom._class('pad'), 'Attachments: ', l.slice(0, filesAll ? l.length : 4).map(a => { const name = a.Filename || '(unnamed)'; const viewable = isViewable(a); const size = formatSize(a.Part.DecodedSize); @@ -3781,7 +3960,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }), ' ', dom.a('Download all as zip', attr.download(''), style({ color: 'inherit' }), attr.href('msg/' + m.ID + '/attachments.zip'))))); }; renderAttachments(); - const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column' })); + const root = dom.div(css('msgViewRoot', { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column' })); dom._kids(root, msgmetaElem, msgcontentElem); const loadText = (pm) => { // We render text ourselves so we can make links clickable and get any selected @@ -3790,7 +3969,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad urlType = 'text'; const elem = dom.div(dom._class('mono', 'textmulti'), style({ whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { const pathStr = [0].concat(f.Path || []).join('.'); - return dom.div(dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('msg/' + m.ID + '/view/' + pathStr), attr.title(f.Filename), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)' })))); + return dom.div(dom.div(css('msgAttachmentBox', { flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('msg/' + m.ID + '/view/' + pathStr), attr.title(f.Filename), css('msgInlineImage', { boxShadow: styles.boxShadow, maxWidth: '100%', maxHeight: '100%' })))); })); dom._kids(msgcontentElem); dom._kids(msgscrollElem, elem); @@ -3799,12 +3978,12 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }; const loadHTML = () => { urlType = 'html'; - dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined, without external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined, without external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), css('msgIframeHTML', { position: 'absolute', width: '100%', height: '100%' }))); renderAttachments(); // Rerender opaciy on inline images. }; const loadHTMLexternal = () => { urlType = 'htmlexternal'; - dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined and with external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined and with external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), css('msgIframeHTML', { position: 'absolute', width: '100%', height: '100%' }))); renderAttachments(); // Rerender opaciy on inline images. }; const loadMoreHeaders = (pm) => { @@ -3820,7 +3999,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad return; } vl.forEach(v => { - const e = dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(v)); + const e = dom.tr(dom.td(k + ':', headerTextMildStyle, style({ whiteSpace: 'nowrap' })), dom.td(v)); msgheaderElem.appendChild(e); }); }); @@ -3862,11 +4041,13 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad loadButtons(pm); loadHeaderDetails(pm); loadMoreHeaders(pm); + const msgHeaderSeparatorStyle = css('msgHeaderSeparator', { borderTop: '1px solid', borderTopColor: styles.borderColor }); + const msgModeWarningStyle = css('msgModeWarning', { backgroundColor: styles.warningBackgroundColor, padding: '0 .15em' }); const htmlNote = 'In the HTML viewer, the following potentially dangerous functionality is disabled: submitting forms, starting a download from a link, navigating away from this page by clicking a link. If a link does not work, try explicitly opening it in a new tab.'; const haveText = pm.Texts && pm.Texts.length > 0; if (!haveText && !pm.HasHTML) { dom._kids(msgcontentElem); - dom._kids(msgmodeElem, dom.div(dom._class('pad'), style({ borderTop: '1px solid #ccc' }), dom.span('No textual content', style({ backgroundColor: '#ffca91', padding: '0 .15em' })))); + dom._kids(msgmodeElem, dom.div(dom._class('pad'), msgHeaderSeparatorStyle, dom.span('No textual content', msgModeWarningStyle))); } else if (haveText && !pm.HasHTML) { loadText(pm); @@ -3874,7 +4055,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad } else { const text = haveText && (pm.ViewMode == api.ViewMode.ModeText || pm.ViewMode == api.ViewMode.ModeDefault && !settings.showHTML); - dom._kids(msgmodeElem, dom.div(dom._class('pad'), style({ borderTop: '1px solid #ccc' }), !haveText ? dom.span('HTML-only message', attr.title(htmlNote), style({ backgroundColor: '#ffca91', padding: '0 .15em', marginRight: '.25em' })) : [], dom.span(dom._class('btngroup'), haveText ? textbtn = dom.clickbutton(text ? dom._class('active') : [], 'Text', clickCmd(cmdShowText, shortcuts)) : [], htmlbtn = dom.clickbutton(text || pm.ViewMode != api.ViewMode.ModeHTML ? [] : dom._class('active'), 'HTML', attr.title(htmlNote), async function click() { + dom._kids(msgmodeElem, dom.div(dom._class('pad'), msgHeaderSeparatorStyle, !haveText ? dom.span('HTML-only message', attr.title(htmlNote), msgModeWarningStyle, style({ marginRight: '.25em' })) : [], dom.span(dom._class('btngroup'), haveText ? textbtn = dom.clickbutton(text ? dom._class('active') : [], 'Text', clickCmd(cmdShowText, shortcuts)) : [], htmlbtn = dom.clickbutton(text || pm.ViewMode != api.ViewMode.ModeHTML ? [] : dom._class('active'), 'HTML', attr.title(htmlNote), async function click() { // Shortcuts has a function that cycles through html and htmlexternal. showShortcut('T'); await cmdShowHTML(); @@ -4265,7 +4446,7 @@ const newMsglistView = (msgElem, listMailboxes, setLocationHash, otherMailbox, p else { const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID; const allTrash = trashMailboxID && !effselected.find(miv => miv.messageitem.Message.MailboxID !== trashMailboxID); - dom._kids(msgElem, dom.div(attr.role('region'), attr.arialabel('Buttons for multiple messages'), style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }), dom.div(style({ padding: '4ex', backgroundColor: 'white', borderRadius: '.25em', border: '1px solid #ccc' }), dom.div(style({ textAlign: 'center', marginBottom: '4ex' }), '' + effselected.length + ' messages selected'), dom.div(dom.clickbutton('Archive', attr.title('Move to the Archive mailbox. Messages in the designated Sent mailbox are only moved if a single message is selected, or the current mailbox is the Sent mailbox.'), clickCmd(cmdArchive, shortcuts)), ' ', allTrash ? + dom._kids(msgElem, dom.div(attr.role('region'), attr.arialabel('Buttons for multiple messages'), css('multimsgBg', { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }), dom.div(css('multimsgBox', { backgroundColor: styles.backgroundColor, border: '1px solid', borderColor: styles.borderColor, padding: '4ex', borderRadius: '.25em' }), dom.div(style({ textAlign: 'center', marginBottom: '4ex' }), '' + effselected.length + ' messages selected'), dom.div(dom.clickbutton('Archive', attr.title('Move to the Archive mailbox. Messages in the designated Sent mailbox are only moved if a single message is selected, or the current mailbox is the Sent mailbox.'), clickCmd(cmdArchive, shortcuts)), ' ', allTrash ? dom.clickbutton('Delete', attr.title('Permanently delete messages.'), clickCmd(cmdDelete, shortcuts)) : dom.clickbutton('Trash', attr.title('Move to the Trash mailbox. Messages in the designated Sent mailbox are only moved if a single message is selected, or the current mailbox is the Sent mailbox.'), clickCmd(cmdTrash, shortcuts)), ' ', dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages. Messages in the designated Sent mailbox are only moved if a single message is selected, or the current mailbox is the Sent mailbox.'), clickCmd(cmdJunk, shortcuts)), ' ', dom.clickbutton('Move to...', function click(e) { const sentMailboxID = listMailboxes().find(mb => mb.Sent)?.ID; @@ -5198,7 +5379,7 @@ const popoverExport = (reference, mailboxName) => { const removeExport = popover(reference, {}, dom.h1('Export ', mailboxName || 'all mailboxes'), dom.form(function submit() { // If we would remove the popup immediately, the form would be deleted too and never submitted. window.setTimeout(() => removeExport(), 100); - }, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox')), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None')), dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Export'))))); + }, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.div(css('exportFields', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox')), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None')), dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Export'))))); }; const newMailboxView = (xmb, mailboxlistView, otherMailbox) => { const plusbox = '⊞'; @@ -5215,7 +5396,7 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => { mailboxlistView.updateHidden(); mbv.root.focus(); }; - const collapseElem = dom.span(dom._class('mailboxcollapse'), minusbox, function click(e) { + const collapseElem = dom.span(dom._class('mailboxCollapse'), minusbox, function click(e) { e.stopPropagation(); cmdCollapse(); }); @@ -5274,7 +5455,15 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => { // Keep track of dragenter/dragleave ourselves, we don't get a neat 1 enter and 1 // leave event from browsers, we get events for multiple of this elements children. let drags = 0; - const root = dom.div(dom._class('mailboxitem'), attr.tabindex('0'), async function keydown(e) { + const mailboxItemStyle = css('mailboxItem', { cursor: 'pointer', borderRadius: '.15em', userSelect: 'none' }); + ensureCSS('.mailboxItem.dropping', { background: styles.highlightBackground }, true); + ensureCSS('.mailboxItem:hover', { backgroundColor: styles.mailboxHoverBackgroundColor }); + ensureCSS('.mailboxItem.active', { background: styles.mailboxActiveBackground }); + ensureCSS('.mailboxHoverOnly', { visibility: 'hidden' }); + ensureCSS('.mailboxItem:hover .mailboxHoverOnly, .mailboxItem:focus .mailboxHoverOnly', { visibility: 'visible' }); + ensureCSS('.mailboxCollapse', { visibility: 'hidden' }); + ensureCSS('.mailboxItem:hover .mailboxCollapse, .mailboxItem:focus .mailboxCollapse', { visibility: 'visible' }); + const root = dom.div(mailboxItemStyle, attr.tabindex('0'), async function keydown(e) { if (e.key === 'Enter') { e.stopPropagation(); await withStatus('Opening mailbox', mbv.open(true)); @@ -5335,7 +5524,7 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => { const mbSrcID = mailboxMsgIDs.find(mbMsgID => mbMsgID[1] === msgID)[0]; await moveAskRuleset(msgID, mbSrcID, xmb, mailboxlistView.mailboxes()); } - }, dom.div(dom._class('mailbox'), style({ display: 'flex', justifyContent: 'space-between' }), name = dom.div(style({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' })), dom.div(style({ whiteSpace: 'nowrap' }), actionBtn = dom.clickbutton(dom._class('mailboxhoveronly'), '...', attr.tabindex('-1'), // Without, tab breaks because this disappears when mailbox loses focus. + }, dom.div(css('mailbox', { padding: '.15em .25em', display: 'flex', justifyContent: 'space-between' }), name = dom.div(css('mailboxName', { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' })), dom.div(style({ whiteSpace: 'nowrap' }), actionBtn = dom.clickbutton(dom._class('mailboxHoverOnly'), '...', attr.tabindex('-1'), // Without, tab breaks because this disappears when mailbox loses focus. attr.arialabel('Mailbox actions'), attr.title('Actions on mailbox, like deleting, emptying, renaming.'), function click(e) { e.stopPropagation(); cmdOpenActions(); @@ -5492,9 +5681,9 @@ const newMailboxlistView = (msglistView, requestNewView, updatePageTitle, setLoc }; const root = dom.div(); const mailboxesElem = dom.div(); - dom._kids(root, dom.div(attr.role('region'), attr.arialabel('Mailboxes'), dom.div(dom.h1('Mailboxes', style({ display: 'inline', fontSize: 'inherit' })), ' ', dom.clickbutton('...', attr.arialabel('Mailboxes actions'), attr.title('Actions on mailboxes like creating a new mailbox or exporting all email.'), function click(e) { + dom._kids(root, dom.div(attr.role('region'), attr.arialabel('Mailboxes'), dom.div(dom.h1('Mailboxes', css('mailboxesTitle', { display: 'inline', fontSize: 'inherit' })), ' ', dom.clickbutton('...', attr.arialabel('Mailboxes actions'), attr.title('Actions on mailboxes like creating a new mailbox or exporting all email.'), function click(e) { e.stopPropagation(); - const remove = popover(e.target, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Create mailbox', attr.arialabel('Create new mailbox.'), attr.title('Create new mailbox.'), style({ padding: '0 .25em' }), function click(e) { + const remove = popover(e.target, { transparent: true }, dom.div(css('mailboxesActions', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Create mailbox', attr.arialabel('Create new mailbox.'), attr.title('Create new mailbox.'), style({ padding: '0 .25em' }), function click(e) { let fieldset; let name; const ref = e.target; @@ -5860,7 +6049,9 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl oldestTime.value = tm; updateSearchbar(); }; - const root = dom.div(style({ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', zIndex: zindexes.compose }), function click(e) { + const searchTableStyle = css('searchTable', { width: '100%' }); + ensureCSS('.searchTable td', { padding: '.25em' }); + const root = dom.div(css('searchOverlay', { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: styles.overlayBackgroundColor, zIndex: zindexes.compose }), function click(e) { e.stopPropagation(); searchViewClose(); }, function keyup(e) { @@ -5868,12 +6059,12 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl e.stopPropagation(); searchViewClose(); } - }, dom.search(style({ position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: 'white', boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', borderRadius: '.15em' }), function click(e) { + }, dom.search(css('searchContent', { position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: styles.popupBackgroundColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, color: styles.popupColor, borderRadius: '.15em' }), function click(e) { e.stopPropagation(); }, // This is a separate form, inside the form with the overall search field because // when updating the form based on the parsed searchbar, we first need to reset it. - form = dom.form(dom.table(dom._class('search'), style({ width: '100%' }), dom.tr(dom.td(dom.label('Mailbox', attr.for('searchMailbox')), attr.title('Filter by mailbox, including children of the mailbox.')), dom.td(mailbox = dom.select(attr.id('searchMailbox'), style({ width: '100%' }), dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), dom.option('All mailboxes', attr.value('0')), changeHandlers), dom.div(style({ paddingTop: '.5ex' }), dom.label(mailboxkids = dom.input(attr.type('checkbox'), changeHandlers), ' Also search in mailboxes below the selected mailbox.')))), dom.tr(dom.td(dom.label('Text', attr.for('searchWords'))), dom.td(words = dom.input(attr.id('searchWords'), attr.title('Filter by text, case-insensitive, substring match, not necessarily whole words.'), focusPlaceholder('word "exact match" -notword'), style({ width: '100%' }), changeHandlers))), dom.tr(dom.td(dom.label('From', attr.for('searchFrom'))), dom.td(from = dom.input(attr.id('searchFrom'), style({ width: '100%' }), focusPlaceholder('Address or name'), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('To', attr.for('searchTo')), attr.title('Search on addressee, including Cc and Bcc headers.')), dom.td(to = dom.input(attr.id('searchTo'), focusPlaceholder('Address or name, also matches Cc and Bcc addresses'), style({ width: '100%' }), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('Subject', attr.for('searchSubject'))), dom.td(subject = dom.input(attr.id('searchSubject'), style({ width: '100%' }), focusPlaceholder('"exact match"'), changeHandlers))), dom.tr(dom.td('Received between', style({ whiteSpace: 'nowrap' })), dom.td(style({ lineHeight: 2 }), dom.div(oldestDate = dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), oldestTime = dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), ' ', dom.clickbutton('x', style({ padding: '0 .3em' }), attr.arialabel('Clear start date.'), attr.title('Clear start date.'), function click() { + form = dom.form(dom.table(searchTableStyle, dom.tr(dom.td(dom.label('Mailbox', attr.for('searchMailbox')), attr.title('Filter by mailbox, including children of the mailbox.')), dom.td(mailbox = dom.select(attr.id('searchMailbox'), style({ width: '100%' }), dom.option('All mailboxes except Trash/Junk/Rejects', attr.value('-1')), dom.option('All mailboxes', attr.value('0')), changeHandlers), dom.div(style({ paddingTop: '.5ex' }), dom.label(mailboxkids = dom.input(attr.type('checkbox'), changeHandlers), ' Also search in mailboxes below the selected mailbox.')))), dom.tr(dom.td(dom.label('Text', attr.for('searchWords'))), dom.td(words = dom.input(attr.id('searchWords'), attr.title('Filter by text, case-insensitive, substring match, not necessarily whole words.'), focusPlaceholder('word "exact match" -notword'), style({ width: '100%' }), changeHandlers))), dom.tr(dom.td(dom.label('From', attr.for('searchFrom'))), dom.td(from = dom.input(attr.id('searchFrom'), style({ width: '100%' }), focusPlaceholder('Address or name'), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('To', attr.for('searchTo')), attr.title('Search on addressee, including Cc and Bcc headers.')), dom.td(to = dom.input(attr.id('searchTo'), focusPlaceholder('Address or name, also matches Cc and Bcc addresses'), style({ width: '100%' }), newAddressComplete(), changeHandlers))), dom.tr(dom.td(dom.label('Subject', attr.for('searchSubject'))), dom.td(subject = dom.input(attr.id('searchSubject'), style({ width: '100%' }), focusPlaceholder('"exact match"'), changeHandlers))), dom.tr(dom.td('Received between', style({ whiteSpace: 'nowrap' })), dom.td(style({ lineHeight: 2 }), dom.div(oldestDate = dom.input(attr.type('date'), focusPlaceholder('2023-07-20'), changeHandlers), oldestTime = dom.input(attr.type('time'), focusPlaceholder('23:10'), changeHandlers), ' ', dom.clickbutton('x', style({ padding: '0 .3em' }), attr.arialabel('Clear start date.'), attr.title('Clear start date.'), function click() { oldestDate.value = ''; oldestTime.value = ''; updateSearchbar(); @@ -5908,7 +6099,10 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl updateSearchbar(); }), update: () => { - v.root.style.backgroundColor = v.active === true ? '#c4ffa9' : (v.active === false ? '#ffb192' : ''); + css('searchFlagTrue', { backgroundColor: styles.buttonTristateOnBackground }, true); + css('searchFlagFalse', { backgroundColor: styles.buttonTristateOffBackground }, true); + v.root.classList.toggle('searchFlagTrue', v.active === true); + v.root.classList.toggle('searchFlagFalse', v.active === false); }, }; return v; @@ -5973,9 +6167,9 @@ const init = async () => { let msglistscrollElem; let queryactivityElem; // We show ... when a query is active and data is forthcoming. // Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate. - const listendElem = dom.div(style({ borderTop: '1px solid #ccc', color: '#666', margin: '1ex' })); - const listloadingElem = dom.div(style({ textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8' }), 'loading...'); - const listerrElem = dom.div(style({ textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8' })); + const listendElem = dom.div(css('msgListEnd', { borderTop: '1px solid', borderColor: styles.borderColor, color: styles.colorMilder, margin: '1ex' })); + const listloadingElem = dom.div(css('msgListLoading', { textAlign: 'center', padding: '.15em 0', color: styles.colorMild, border: '1px solid', borderColor: styles.borderColor, margin: '1ex', backgroundColor: styles.backgroundColorMild }), 'loading...'); + const listerrElem = dom.div(css('msgListErr', { textAlign: 'center', padding: '.15em 0', color: styles.colorMild, border: '1px solid', borderColor: styles.borderColor, margin: '1ex', backgroundColor: styles.backgroundColorMild })); let sseID = 0; // Sent by server in initial SSE response. We use it in API calls to make the SSE endpoint return new data we need. let viewSequence = 0; // Counter for assigning viewID. let viewID = 0; // Updated when a new view is started, e.g. when opening another mailbox or starting a search. @@ -6023,12 +6217,12 @@ const init = async () => { const loadSearch = (q) => { search = { active: true, query: q }; searchbarElem.value = q; - searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)'; // Cleared when another view is loaded. + searchbarElem.classList.toggle('searchbarActive', true); // Cleared when another view is loaded. searchbarElemBox.style.flexGrow = '4'; }; const unloadSearch = () => { searchbarElem.value = ''; - searchbarElem.style.background = ''; + searchbarElem.classList.toggle('searchbarActive', false); searchbarElem.style.zIndex = ''; searchbarElemBox.style.flexGrow = ''; // Make search bar smaller again. search = { active: false, query: '' }; @@ -6089,7 +6283,7 @@ const init = async () => { await client.Request(request); }; // msgElem can show a message, show actions on multiple messages, or be empty. - let msgElem = dom.div(style({ position: 'absolute', right: 0, left: 0, top: 0, bottom: 0 }), style({ backgroundColor: '#f8f8f8' })); + let msgElem = dom.div(css('msgElem', { position: 'absolute', right: 0, left: 0, top: 0, bottom: 0, backgroundColor: styles.backgroundColorMild })); // Returns possible labels based, either from active mailbox (possibly from search), or all mailboxes. const possibleLabels = () => { if (requestFilter.MailboxID > 0) { @@ -6138,7 +6332,8 @@ const init = async () => { } }; let threadMode; - let msglistElem = dom.div(dom._class('msglist'), style({ position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column' }), dom.div(attr.role('region'), attr.arialabel('Filter and sorting buttons for message list'), style({ display: 'flex', justifyContent: 'space-between', backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', padding: '.25em .5em' }), dom.div(dom.h1('Refine:', style({ fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0 }), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), ' ', dom.span(dom._class('btngroup'), refineUnreadBtn = dom.clickbutton(settings.refine === 'unread' ? dom._class('active') : [], 'Unread', attr.title('Only show messages marked as unread.'), async function click(e) { + const msgColumnDraggerStyle = css('msgColumnDragger', { position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: styles.popupBorderColor, left: '2.5px' }); + let msglistElem = dom.div(css('msgList', { backgroundColor: styles.msglistBackgroundColor, position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column' }), dom.div(attr.role('region'), attr.arialabel('Filter and sorting buttons for message list'), css('msgListFilterSorting', { display: 'flex', justifyContent: 'space-between', backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor, padding: '.25em .5em' }), dom.div(dom.h1('Refine:', css('refineTitle', { fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0 }), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), ' ', dom.span(dom._class('btngroup'), refineUnreadBtn = dom.clickbutton(settings.refine === 'unread' ? dom._class('active') : [], 'Unread', attr.title('Only show messages marked as unread.'), async function click(e) { settingsPut({ ...settings, refine: 'unread' }); refineToggleActive(e.target); await withStatus('Requesting messages', requestNewView(false)); @@ -6160,7 +6355,7 @@ const init = async () => { await withStatus('Requesting messages', requestNewView(false)); remove(); }; - return dom.div(dom.clickbutton(dom._class('keyword'), l, async function click() { + return dom.div(dom.clickbutton(styleClasses.keyword, keywordButtonStyle, l, async function click() { await selectLabel(); })); }), labels.length === 0 ? dom.div('No labels yet, set one on a message first.') : [])); @@ -6185,14 +6380,14 @@ const init = async () => { // huge amount of messages to be fetched. e.g. when first message in large mailbox // was selected, it would now be the last message. await withStatus('Requesting messages', requestNewView(true)); - }))), dom.div(style({ height: '1ex', position: 'relative' }), dom.div(dom._class('msgitemflags')), dom.div(dom._class('msgitemflagsoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + }))), dom.div(style({ height: '1ex', position: 'relative' }), dom.div(dom._class('msgItemFlags')), dom.div(dom._class('msgItemFlagsOffset'), css('msgItemFlagsGrab', { position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(msgColumnDraggerStyle), function mousedown(e) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect(); const width = Math.round(e.clientX - bounds.x); settingsPut({ ...settings, msglistflagsWidth: width }); updateMsglistWidths(); }); - }), dom.div(dom._class('msgitemfrom')), dom.div(dom._class('msgitemfromoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + }), dom.div(dom._class('msgItemFrom')), dom.div(dom._class('msgItemFromOffset'), css('msgItemFlagsGrab', { position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(msgColumnDraggerStyle), function mousedown(e) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect(); const x = Math.round(e.clientX - bounds.x - lastflagswidth); @@ -6201,14 +6396,14 @@ const init = async () => { settingsPut({ ...settings, msglistfromPct: pct }); updateMsglistWidths(); }); - }), dom.div(dom._class('msgitemsubject')), dom.div(dom._class('msgitemsubjectoffset'), style({ position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(style({ position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px' })), function mousedown(e) { + }), dom.div(dom._class('msgItemSubject')), dom.div(dom._class('msgItemSubjectOffset'), css('msgItemFlagsGrab', { position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize' }), dom.div(msgColumnDraggerStyle), function mousedown(e) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect(); const width = Math.round(bounds.x + bounds.width - e.clientX); settingsPut({ ...settings, msglistageWidth: width }); updateMsglistWidths(); }); - }), dom.div(dom._class('msgitemage'))), dom.div(style({ flexGrow: '1', position: 'relative' }), msglistscrollElem = dom.div(dom._class('yscroll'), attr.role('region'), attr.arialabel('Message list'), async function scroll() { + }), dom.div(dom._class('msgItemAge'))), dom.div(style({ flexGrow: '1', position: 'relative' }), msglistscrollElem = dom.div(yscrollStyle, attr.role('region'), attr.arialabel('Message list'), async function scroll() { if (!sseID || requestViewEnd || requestID) { return; } @@ -6230,7 +6425,7 @@ const init = async () => { search = { active: true, query: searchbarElem.value }; mailboxlistView.closeMailbox(); setLocationHash(); - searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)'; // Cleared when another view is loaded. + searchbarElem.classList.toggle('searchbarActive', true); // Cleared when another view is loaded. searchView.root.remove(); searchbarElem.blur(); document.body.focus(); @@ -6313,9 +6508,11 @@ const init = async () => { 'ctrl m': cmdFocusMsg, 'ctrl !': cmdSettings, }; - const webmailroot = dom.div(style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh' }), dom.div(dom._class('topbar'), style({ display: 'flex' }), attr.role('region'), attr.arialabel('Top bar'), topcomposeboxElem = dom.div(dom._class('pad'), style({ width: settings.mailboxesWidth + 'px', textAlign: 'center' }), dom.clickbutton('Compose', attr.title('Compose new email message.'), function click() { + const topMailboxesStyle = css('topMailboxes', { backgroundColor: styles.mailboxesTopBackgroundColor }); + css('searchbarActive', { background: styles.mailboxActiveBackground }); // class set on searchbarElem when active. + const webmailroot = dom.div(css('webmailRoot', { display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh' }), dom.div(topMailboxesStyle, style({ display: 'flex' }), attr.role('region'), attr.arialabel('Top bar'), topcomposeboxElem = dom.div(dom._class('pad'), style({ width: settings.mailboxesWidth + 'px', textAlign: 'center' }), dom.clickbutton('Compose', attr.title('Compose new email message.'), function click() { shortcutCmd(cmdCompose, shortcuts); - })), dom.div(dom._class('pad'), style({ paddingLeft: 0, display: 'flex', flexGrow: 1 }), searchbarElemBox = dom.search(style({ display: 'flex', marginRight: '.5em' }), dom.form(style({ display: 'flex', flexGrow: 1 }), searchbarElem = dom.input(attr.placeholder('Search...'), style({ position: 'relative', width: '100%' }), attr.title('Search messages based on criteria like matching free-form text, in a mailbox, labels, addressees.'), focusPlaceholder('word "with space" -notword mb:Inbox f:from@x.example t:rcpt@x.example start:2023-7-1 end:2023-7-8 s:"subject" a:images l:$Forwarded h:Reply-To:other@x.example minsize:500kb'), function click() { + })), dom.div(dom._class('pad'), css('searchbarBox', { paddingLeft: 0, display: 'flex', flexGrow: 1 }), searchbarElemBox = dom.search(style({ display: 'flex', marginRight: '.5em' }), dom.form(style({ display: 'flex', flexGrow: 1 }), searchbarElem = dom.input(attr.placeholder('Search...'), style({ position: 'relative', width: '100%' }), attr.title('Search messages based on criteria like matching free-form text, in a mailbox, labels, addressees.'), focusPlaceholder('word "with space" -notword mb:Inbox f:from@x.example t:rcpt@x.example start:2023-7-1 end:2023-7-8 s:"subject" a:images l:$Forwarded h:Reply-To:other@x.example minsize:500kb'), function click() { cmdSearch(); showShortcut('/'); }, function focus() { @@ -6360,7 +6557,7 @@ const init = async () => { }), async function submit(e) { e.preventDefault(); await searchView.submit(); - })), connectionElem = dom.div(), statusElem = dom.div(style({ marginLeft: '.5em', flexGrow: '1' }), attr.role('status')), dom.div(style({ paddingLeft: '1em' }), layoutElem = dom.select(attr.title('Layout of message list and message panes. Top/bottom has message list above message view. Left/Right has message list left, message view right. Auto selects based on window width and automatically switches on resize. Wide screens get left/right, smaller screens get top/bottom.'), dom.option('Auto layout', attr.value('auto'), settings.layout === 'auto' ? attr.selected('') : []), dom.option('Top/bottom', attr.value('topbottom'), settings.layout === 'topbottom' ? attr.selected('') : []), dom.option('Left/right', attr.value('leftright'), settings.layout === 'leftright' ? attr.selected('') : []), function change() { + })), connectionElem = dom.div(), statusElem = dom.div(css('status', { marginLeft: '.5em', flexGrow: '1' }), attr.role('status')), dom.div(style({ paddingLeft: '1em' }), layoutElem = dom.select(attr.title('Layout of message list and message panes. Top/bottom has message list above message view. Left/Right has message list left, message view right. Auto selects based on window width and automatically switches on resize. Wide screens get left/right, smaller screens get top/bottom.'), dom.option('Auto layout', attr.value('auto'), settings.layout === 'auto' ? attr.selected('') : []), dom.option('Top/bottom', attr.value('topbottom'), settings.layout === 'topbottom' ? attr.selected('') : []), dom.option('Left/right', attr.value('leftright'), settings.layout === 'leftright' ? attr.selected('') : []), function change() { settingsPut({ ...settings, layout: layoutElem.value }); if (layoutElem.value === 'auto') { autoselectLayout(); @@ -6377,7 +6574,7 @@ const init = async () => { } // Reload so all state is cleared from memory. window.location.reload(); - })))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) { + })))), dom.div(css('mailboxesListMsgBox', { flexGrow: '1', position: 'relative' }), mailboxesElem = dom.div(topMailboxesStyle, style({ width: settings.mailboxesWidth + 'px' }), css('mailboxesBox', { display: 'flex', flexDirection: 'column', alignContent: 'stretch', position: 'absolute', left: 0, top: 0, bottom: 0 }), dom.div(dom._class('pad'), yscrollAutoStyle, style({ flexGrow: '1', position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(css('mailboxesListGrab', { position: 'absolute', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), style({ left: 'calc(' + settings.mailboxesWidth + 'px - 2px)' }), dom.div(css('mailboxesListLine', { position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: styles.popupBorderColor })), function mousedown(e) { startDrag(e, (e) => { mailboxesElem.style.width = Math.round(e.clientX) + 'px'; topcomposeboxElem.style.width = Math.round(e.clientX) + 'px'; @@ -6385,7 +6582,7 @@ const init = async () => { splitElem.style.left = 'calc(' + e.clientX + 'px + 1px)'; settingsPut({ ...settings, mailboxesWidth: Math.round(e.clientX) }); }); - }), splitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid #bbb' })))); + }), splitElem = dom.div(css('listMsgBox', { position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid', borderTopColor: styles.borderColor })))); // searchView is shown when search gets focus. const searchView = newSearchView(searchbarElem, mailboxlistView, startSearch, searchViewClose); document.body.addEventListener('keydown', async (e) => { @@ -6446,7 +6643,7 @@ const init = async () => { } if (want === 'leftright') { let left, split, right; - dom._kids(splitElem, left = dom.div(style({ position: 'absolute', left: 0, width: 'calc(' + settings.leftWidthPct + '% - 1px)', top: 0, bottom: 0 }), msglistElem), split = dom.div(style({ position: 'absolute', left: 'calc(' + settings.leftWidthPct + '% - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', backgroundColor: '#aaa', top: 0, bottom: 0, width: '1px', left: '2px', right: '2px' })), function mousedown(e) { + dom._kids(splitElem, left = dom.div(css('layoutLeft', { position: 'absolute', left: 0, top: 0, bottom: 0 }), style({ width: 'calc(' + settings.leftWidthPct + '% - 1px)' }), msglistElem), split = dom.div(css('listMsgLeftRightGrab', { position: 'absolute', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), style({ left: 'calc(' + settings.leftWidthPct + '% - 2px)' }), dom.div(css('listMsgLeftRightLine', { position: 'absolute', backgroundColor: styles.popupBorderColor, top: 0, bottom: 0, width: '1px', left: '2px', right: '2px' })), function mousedown(e) { startDrag(e, (e) => { const bounds = left.getBoundingClientRect(); const x = Math.round(e.clientX - bounds.x); @@ -6456,11 +6653,11 @@ const init = async () => { settingsPut({ ...settings, leftWidthPct: Math.round(100 * bounds.width / splitElem.getBoundingClientRect().width) }); updateMsglistWidths(); }); - }), right = dom.div(style({ position: 'absolute', right: 0, left: 'calc(' + settings.leftWidthPct + '% + 1px)', top: 0, bottom: 0 }), msgElem)); + }), right = dom.div(css('layoutRight', { position: 'absolute', right: 0, top: 0, bottom: 0 }), style({ left: 'calc(' + settings.leftWidthPct + '% + 1px)' }), msgElem)); } else { let top, split, bottom; - dom._kids(splitElem, top = dom.div(style({ position: 'absolute', top: 0, height: 'calc(' + settings.topHeightPct + '% - 1px)', left: 0, right: 0 }), msglistElem), split = dom.div(style({ position: 'absolute', top: 'calc(' + settings.topHeightPct + '% - 2px)', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', backgroundColor: '#aaa', left: 0, right: 0, height: '1px', top: '2px', bottom: '2px' })), function mousedown(e) { + dom._kids(splitElem, top = dom.div(css('layoutTop', { position: 'absolute', top: 0, left: 0, right: 0 }), style({ height: 'calc(' + settings.topHeightPct + '% - 1px)' }), msglistElem), split = dom.div(css('listMsgTopBottomGrab', { position: 'absolute', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter }), style({ top: 'calc(' + settings.topHeightPct + '% - 2px)' }), dom.div(css('listmsgTopBottomLine', { position: 'absolute', backgroundColor: styles.popupBorderColor, left: 0, right: 0, height: '1px', top: '2px', bottom: '2px' })), function mousedown(e) { startDrag(e, (e) => { const bounds = top.getBoundingClientRect(); const y = Math.round(e.clientY - bounds.y); @@ -6469,7 +6666,7 @@ const init = async () => { bottom.style.top = 'calc(' + y + 'px + 1px)'; settingsPut({ ...settings, topHeightPct: Math.round(100 * bounds.height / splitElem.getBoundingClientRect().height) }); }); - }), bottom = dom.div(style({ position: 'absolute', bottom: 0, top: 'calc(' + settings.topHeightPct + '% + 1px)', left: 0, right: 0 }), msgElem)); + }), bottom = dom.div(css('layoutBottom', { position: 'absolute', bottom: 0, left: 0, right: 0 }), style({ top: 'calc(' + settings.topHeightPct + '% + 1px)' }), msgElem)); } currentLayout = want; checkMsglistWidth(); @@ -6511,13 +6708,13 @@ const init = async () => { const fromwidth = Math.floor(frompct * remain / 100); const subjectwidth = Math.floor(remain - fromwidth); const cssRules = [ - ['.msgitemflags', { width: flagswidth }], - ['.msgitemfrom', { width: fromwidth }], - ['.msgitemsubject', { width: subjectwidth }], - ['.msgitemage', { width: agewidth }], - ['.msgitemflagsoffset', { left: flagswidth }], - ['.msgitemfromoffset', { left: flagswidth + fromwidth }], - ['.msgitemsubjectoffset', { left: flagswidth + fromwidth + subjectwidth }], + ['.msgItemFlags', { width: flagswidth }], + ['.msgItemFrom', { width: fromwidth, position: 'relative' }], + ['.msgItemSubject', { width: subjectwidth }], + ['.msgItemAge', { width: agewidth, 'text-align': 'right' }], + ['.msgItemFlagsOffset', { left: flagswidth }], + ['.msgItemFromOffset', { left: flagswidth + fromwidth }], + ['.msgItemSubjectOffset', { left: flagswidth + fromwidth + subjectwidth }], ]; if (!rulesInserted) { cssRules.forEach((rule, i) => { stylesheet.insertRule(rule[0] + '{}', i); }); @@ -6526,7 +6723,11 @@ const init = async () => { cssRules.forEach((rule, i) => { const r = stylesheet.cssRules[i]; for (const k in rule[1]) { - r.style.setProperty(k, '' + rule[1][k] + 'px'); + let v = rule[1][k]; + if (typeof v !== 'string') { + v = '' + v + 'px'; + } + r.style.setProperty(k, v); } }); lastflagswidth = flagswidth; @@ -6621,7 +6822,7 @@ const init = async () => { } }); const showNotConnected = () => { - dom._kids(connectionElem, attr.role('status'), dom.span(style({ backgroundColor: '#ffa9a9', padding: '0 .15em', borderRadius: '.15em' }), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), ' ', dom.clickbutton('Reconnect', function click() { + dom._kids(connectionElem, attr.role('status'), dom.span(css('connectionStatus', { backgroundColor: styles.warningBackgroundColor, padding: '0 .15em', borderRadius: '.15em' }), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), ' ', dom.clickbutton('Reconnect', function click() { if (!eventSource && !connecting) { noreconnect = false; connect(true); @@ -6997,7 +7198,7 @@ const showUnhandledError = (err, lineno, colno) => { stack = ' (not available)'; } const xerrmsg = err.toString(); - const box = dom.div(style({ position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(249, 191, 191, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut }), dom.div(style({ marginBottom: '.5ex' }), '' + xerrmsg), dom.clickbutton('Details', function click() { + const box = dom.div(css('unhandledErrorBox', { position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(255, 110, 110, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut }), dom.div(style({ marginBottom: '.5ex' }), '' + xerrmsg), dom.clickbutton('Details', function click() { box.remove(); let msg = `Mox version: ${moxversion} Browser: ${window.navigator.userAgent} @@ -7013,7 +7214,7 @@ Stack trace: ${stack} Details of the error and browser: ` + '```\n' + msg + '```\n'; - const remove = popup(style({ maxWidth: '60em' }), dom.h1('A JavaScript error occurred'), dom.pre(dom._class('mono'), style({ backgroundColor: '#f8f8f8', padding: '1ex', borderRadius: '.15em', border: '1px solid #ccc', whiteSpace: 'pre-wrap' }), msg), dom.br(), dom.div('There is a good chance this is a bug in Mox Webmail.'), dom.div('Consider filing a bug report ("issue") at ', link('https://github.com/mjl-/mox/issues/new?title=' + encodeURIComponent('mox webmail js error: "' + xerrmsg + '"') + '&body=' + encodeURIComponent(body), 'https://github.com/mjl-/mox/issues/new'), '. The link includes the error details.'), dom.div('Before reporting you could check previous ', link('https://github.com/mjl-/mox/issues?q=is%3Aissue+"mox+webmail+js+error%3A"', 'webmail bug reports'), '.'), dom.br(), dom.div('Your feedback will help improve mox, thanks!'), dom.br(), dom.div(style({ textAlign: 'right' }), dom.clickbutton('Close and silence errors for 1 week', function click() { + const remove = popup(style({ maxWidth: '60em' }), dom.h1('A JavaScript error occurred'), dom.pre(dom._class('mono'), css('unhandledErrorMsg', { backgroundColor: styles.backgroundColorMild, padding: '1ex', borderRadius: '.15em', border: '1px solid', borderColor: styles.borderColor, whiteSpace: 'pre-wrap' }), msg), dom.br(), dom.div('There is a good chance this is a bug in Mox Webmail.'), dom.div('Consider filing a bug report ("issue") at ', link('https://github.com/mjl-/mox/issues/new?title=' + encodeURIComponent('mox webmail js error: "' + xerrmsg + '"') + '&body=' + encodeURIComponent(body), 'https://github.com/mjl-/mox/issues/new'), '. The link includes the error details.'), dom.div('Before reporting you could check previous ', link('https://github.com/mjl-/mox/issues?q=is%3Aissue+"mox+webmail+js+error%3A"', 'webmail bug reports'), '.'), dom.br(), dom.div('Your feedback will help improve mox, thanks!'), dom.br(), dom.div(style({ textAlign: 'right' }), dom.clickbutton('Close and silence errors for 1 week', function click() { remove(); settingsPut({ ...settings, ignoreErrorsUntil: Math.round(new Date().getTime() / 1000 + 7 * 24 * 3600) }); }), ' ', dom.clickbutton('Close', function click() { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 9a0f763..69cfa83 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -74,7 +74,6 @@ Enable consistency checking in UI updates: - todo: only show orange underline where it could be a problem? in addresses and anchor texts. we may be lighting up a christmas tree now, desensitizing users. - todo: saved searches that are displayed below list of mailboxes, for quick access to preset view - todo: when search on free-form text is active, highlight the searched text in the message view. -- todo: composeView: save as draft, periodically and when closing. - todo: forwarding of html parts, including inline attachments, so the html version can be rendered like the original by the receiver. - todo: buttons/mechanism to operate on all messages in a mailbox/search query, without having to list and select all messages. e.g. clearing flags/labels. - todo: can we detect if browser supports proper CSP? if not, refuse to load html messages? @@ -83,7 +82,6 @@ Enable consistency checking in UI updates: - todo: configurable keyboard shortcuts? we use strings like "ctrl p" which we already generate and match on, add a mapping from command name to cmd* functions, and have a map of keys to command names. the commands for up/down with shift/ctrl modifiers may need special attention. - todo: consider composing messages with bcc headers that are sent as message Bcc headers to the bcc-addressees, optionally with checkbox. - todo: improve accessibility -- todo: threading mode where we don't show messages in Trash/Sent in thread? - todo: msglistView: preload next message? - todo: previews of zip files - todo: undo? @@ -106,6 +104,31 @@ const zindexes = { login: '7', } +// Buttons and input elements. +ensureCSS('.button', {display: 'inline-block'}) +ensureCSS('button, .button, select', {color: styles.color, backgroundColor: styles.buttonBackground, border: '1px solid', borderColor: styles.buttonBorderColor, borderRadius: '.15em', padding: '0 .15em'}) +ensureCSS('button.active, .button.active, button.active:hover, .button.active:hover', {backgroundColor: styles.highlightBackground}) +ensureCSS('button:hover, .button:hover, select:hover', {backgroundColor: styles.buttonHoverBackground}) +ensureCSS('input, textarea', {backgroundColor: styles.backgroundColor, color: styles.color, border: '1px solid', borderColor: '#888', borderRadius: '.15em', padding: '0 .15em'}) +ensureCSS('input:hover, textarea:hover', {borderColor: styles.colorMilder}) + +ensureCSS('.btngroup button, .btngroup .button', {borderRadius: 0, borderRightWidth: 0 }) +ensureCSS('.btngroup button:first-child, .btngroup .button:first-child', {borderRadius: '.15em 0 0 .15em'}) +ensureCSS('.btngroup button:last-child, .btngroup .button:last-child', {borderRadius: '0 .15em .15em 0', borderRightWidth: '1px'}) + +const keywordButtonStyle = css('keywordButton', {cursor: 'pointer'}) +ensureCSS('.keywordButton:hover', {backgroundColor: styles.highlightBackgroundHover}) + + +const yscrollStyle = css('yscroll', {overflowY: 'scroll', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0}) +const yscrollAutoStyle = css('yscrollAuto', {overflowY: 'auto', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0}) + +// Input elements that automatically grow based on input, with additional JS. +const autosizeStyle = css('autosize', {display: 'inline-grid', maxWidth: '90vw'}) +ensureCSS('.autosize.input', {gridArea: '1 / 2'}) +ensureCSS('.autosize::after', {content: 'attr(data-value)', marginRight: '1em', lineHeight: 0, visibility: 'hidden', whiteSpace: 'pre-wrap', overflowX: 'hidden'}) + + // From HTML. declare let page: HTMLElement declare let moxversion: string @@ -249,11 +272,23 @@ const login = async (reason: string) => { let username: HTMLInputElement let password: HTMLInputElement const root = dom.div( - style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in'}), + css('loginOverlay', {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: styles.overlayOpaqueBackgroundColor, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in'}), dom.div( - reasonElem=reason ? dom.div(style({marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(), + reasonElem=reason ? dom.div(css('sessionError', {marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(), dom.div( - style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh'}), + css('loginPopup', { + backgroundColor: styles.popupBackgroundColor, + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, + borderRadius: '.25em', + padding: '1em', + maxWidth: '95vw', + overflowX: 'auto', + maxHeight: '95vh', + overflowY: 'auto', + marginBottom: '20vh', + }), dom.form( async function submit(e: SubmitEvent) { e.preventDefault() @@ -347,7 +382,7 @@ const envelopeIdentity = (l: api.MessageAddress[]): api.MessageAddress | null => } // We can display keyboard shortcuts when a user clicks a button that has a shortcut. -let shortcutElem = dom.div(style({fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em'})) +let shortcutElem = dom.div(css('shortcutFlash', {fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em'})) let shortcutTimer = 0 const showShortcut = (c: string) => { if (!settings.showShortcuts) { @@ -756,8 +791,9 @@ const flagList = (miv: MsgitemView): HTMLElement[] => { } } - return msgflags.map(t => dom.span(dom._class('msgitemflag'), t[1], attr.title(t[0]))) - .concat(othermsgflags.map(t => dom.span(dom._class('msgitemflag'), dom._class('msgitemflagcollapsed'), t[1], attr.title(t[0])))) + const msgItemFlagStyle = css('msgItemFlag', {marginRight: '1px', fontWeight: 'normal', fontSize: '.9em'}) + return msgflags.map(t => dom.span(msgItemFlagStyle, t[1], attr.title(t[0]))) + .concat(othermsgflags.map(t => dom.span(msgItemFlagStyle, css('msgItemFlagCollapsed', {color: styles.colorMilder}), t[1], attr.title(t[0])))) } // Turn filters from the search bar into filters with the refine filters (buttons @@ -976,7 +1012,7 @@ const popover = (target: HTMLElement, opts: {transparent?: boolean, fullscreen?: let content: HTMLElement const root = dom.div( - style({position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: 'rgba(0, 0, 0, 0.2)'}), + css('popoverOverlay', {position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: zindexes.popover, backgroundColor: styles.overlayBackgroundColor}), function click(e: MouseEvent) { e.stopPropagation() close() @@ -989,17 +1025,20 @@ const popover = (target: HTMLElement, opts: {transparent?: boolean, fullscreen?: }, content=dom.div( attr.tabindex('0'), - style({ + css('popoverContent', { position: 'absolute', overflowY: 'auto', }), posx, posy, opts.transparent ? [] : [ - style({ - backgroundColor: 'white', + css('popoverContentOpaque', { + backgroundColor: styles.popupBackgroundColor, padding: '1em', borderRadius: '.15em', - boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, + color: styles.popupColor, }), function click(e: MouseEvent) { e.stopPropagation() @@ -1039,7 +1078,7 @@ const popup = (...kids: ElemArg[]) => { } let content: HTMLElement const root = dom.div( - style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0, 0, 0, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup}), + css('popupOverlay', {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: styles.overlayBackgroundColor, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.popup}), function keydown(e: KeyboardEvent) { if (e.key === 'Escape') { e.stopPropagation() @@ -1052,7 +1091,7 @@ const popup = (...kids: ElemArg[]) => { }, content=dom.div( attr.tabindex('0'), - style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto'}), + css('popupContent', {backgroundColor: styles.popupBackgroundColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, borderRadius: '.25em', padding: '1em', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto'}), function click(e: MouseEvent) { e.stopPropagation() }, @@ -1077,7 +1116,7 @@ const cmdSettings = async () => { } const remove = popup( - style({padding: '1em 1em 2em 1em', minWidth: '30em'}), + css('popupSettings', {padding: '1em 1em 2em 1em', minWidth: '30em'}), dom.h1('Settings'), dom.form( async function submit(e: SubmitEvent) { @@ -1131,7 +1170,7 @@ const cmdSettings = async () => { // Show help popup, with shortcuts and basic explanation. const cmdHelp = async () => { const remove = popup( - style({padding: '1em 1em 2em 1em'}), + css('popupHelp', {padding: '1em 1em 2em 1em'}), dom.h1('Help and keyboard shortcuts'), dom.div(style({display: 'flex'}), dom.div( @@ -1158,7 +1197,7 @@ const cmdHelp = async () => { dom.tr( dom.td('↓', ', j'), dom.td('down one message'), - dom.td(attr.rowspan('6'), style({color: '#888', borderLeft: '2px solid #ddd', paddingLeft: '.5em'}), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection'), + dom.td(attr.rowspan('6'), css('helpSideNote', {color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em'}), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection'), ), [ [['↑', ', k'], 'up one message'], @@ -1324,7 +1363,7 @@ const cmdTooltip = async () => { const title = e.getAttribute('title') || '' const pos = e.getBoundingClientRect() return dom.div( - style({position: 'absolute', backgroundColor: 'black', color: 'white', borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em'}), + css('tooltipContent', {position: 'absolute', backgroundColor: ['black', 'white'], color: ['white', 'black'], borderRadius: '.15em', padding: '.15em .25em', maxWidth: '50em'}), pos.x < window.innerWidth/3 ? style({left: ''+(pos.x)+'px'}) : style({right: ''+(window.innerWidth - pos.x - pos.width)+'px'}), @@ -1639,20 +1678,20 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { const color = (v: api.SecurityResult) => { if (v === api.SecurityResult.SecurityResultYes) { - return underlineGreen + return styles.underlineGreen } else if (v === api.SecurityResult.SecurityResultNo) { - return underlineRed + return styles.underlineRed } else if (v === api.SecurityResult.SecurityResultUnknown) { - return 'white' + return 'transparent' } - return underlineGrey + return styles.underlineGrey } const setBar = (c0: string, c1: string, c2: string, c3: string, c4: string) => { const stops = [ - c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%', - c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%', - c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%', - c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%', + c0 + ' 0%', c0 + ' 19%', 'transparent 19%', 'transparent 20%', + c1 + ' 20%', c1 + ' 39%', 'transparent 39%', 'transparent 40%', + c2 + ' 40%', c2 + ' 59%', 'transparent 59%', 'transparent 60%', + c3 + ' 60%', c3 + ' 79%', 'transparent 79%', 'transparent 80%', c4 + ' 80%', c4 + ' 100%', ].join(', ') securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1' @@ -1740,8 +1779,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { }, ), securityBar=dom.span( - dom._class('securitybar'), - style({ + css('securitybar', { margin: '0 1px', borderBottom: '1.5px solid', borderBottomColor: 'transparent', @@ -1862,15 +1900,19 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { const initWidth = window.innerWidth === settings.composeViewportWidth ? settings.composeWidth : 0 const initHeight = window.innerHeight === settings.composeViewportHeight ? settings.composeHeight : 0 + const composeTextMildStyle = css('composeTextMild', {textAlign: 'right', color: styles.colorMild}) + const composeCellStyle = css('composeCell', {lineHeight: '1.5'}) + const composeElem = dom.div( - style({ + css('composePopup', { position: 'fixed', bottom: '1ex', right: '1ex', zIndex: zindexes.compose, - backgroundColor: 'white', - boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', - border: '1px solid #ccc', + backgroundColor: styles.popupBackgroundColor, + boxShadow: styles.boxShadow, + border: '1px solid', + borderColor: styles.popupBorderColor, padding: '1em', minWidth: '40em', maxWidth: '95vw', @@ -1881,7 +1923,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { initWidth ? style({width: initWidth+'px'}) : [], initHeight ? style({height: initHeight+'px'}) : [], dom.div( - style({position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize'}), + css('composeResizeGrab', {position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize'}), function mousedown(e: MouseEvent) { resizeLast = null startDrag(e, (e: MouseEvent) => { @@ -1904,13 +1946,13 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { }, ), dom.form( - style({ + css('composeForm', { flexGrow: '1', display: 'flex', flexDirection: 'column', }), fieldset=dom.fieldset( - style({ + css('composeFields', { flexGrow: '1', display: 'flex', flexDirection: 'column', @@ -1919,7 +1961,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { style({width: '100%'}), dom.tr( dom.td( - style({textAlign: 'right', color: '#555'}), + composeTextMildStyle, dom.span('From:'), ), dom.td( @@ -1949,23 +1991,23 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { ), ), toRow=dom.tr( - dom.td('To:', style({textAlign: 'right', color: '#555'})), - toCell=dom.td(style({lineHeight: '1.5'})), + dom.td('To:', composeTextMildStyle), + toCell=dom.td(composeCellStyle), ), replyToRow=dom.tr( - dom.td('Reply-To:', style({textAlign: 'right', color: '#555'})), - replyToCell=dom.td(style({lineHeight: '1.5'})), + dom.td('Reply-To:', composeTextMildStyle), + replyToCell=dom.td(composeCellStyle), ), ccRow=dom.tr( - dom.td('Cc:', style({textAlign: 'right', color: '#555'})), - ccCell=dom.td(style({lineHeight: '1.5'})), + dom.td('Cc:', composeTextMildStyle), + ccCell=dom.td(composeCellStyle), ), bccRow=dom.tr( - dom.td('Bcc:', style({textAlign: 'right', color: '#555'})), - bccCell=dom.td(style({lineHeight: '1.5'})), + dom.td('Bcc:', composeTextMildStyle), + bccCell=dom.td(composeCellStyle), ), dom.tr( - dom.td('Subject:', style({textAlign: 'right', color: '#555'})), + dom.td('Subject:', composeTextMildStyle), dom.td( subjectAutosize=dom.span( dom._class('autosize'), @@ -2010,7 +2052,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { const filename = a.Filename || '(unnamed)' const size = formatSize(a.Part.DecodedSize) const checkbox = dom.input(attr.type('checkbox'), function change() { checkAttachments() }) - const root = dom.label(checkbox, ' '+filename+' ', dom.span('('+size+') ', style({color: '#666'}))) + const root = dom.label(checkbox, ' '+filename+' ', dom.span('('+size+') ', styleClasses.textMild)) const v: ForwardAttachmentView = { path: a.Path || [], root: root, @@ -2018,11 +2060,11 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { } return v }), - dom.label(style({color: '#666'}), dom.input(attr.type('checkbox'), function change(e: Event) { + dom.label(styleClasses.textMild, dom.input(attr.type('checkbox'), function change(e: Event) { forwardAttachmentViews.forEach(v => v.checkbox.checked = (e.target! as HTMLInputElement).checked) }), ' (Toggle all)') ), - noAttachmentsWarning=dom.div(style({display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0'}), 'Message mentions attachments, but no files are attached.'), + noAttachmentsWarning=dom.div(style({display: 'none'}), css('composeNoAttachmentsWarning', {backgroundColor: styles.warningBackgroundColor, padding: '0.15em .25em', margin: '.5em 0'}), 'Message mentions attachments, but no files are attached.'), dom.label(style({margin: '1ex 0', display: 'block'}), 'Attachments ', attachments=dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments() })), dom.label( style({margin: '1ex 0', display: 'block'}), @@ -2147,7 +2189,7 @@ const labelsPopover = (e: MouseEvent, msgs: api.Message[], possibleLabels: possi const remove = popover(e.target! as HTMLElement, {}, dom.div( - style({display: 'flex', flexDirection: 'column', gap: '1ex'}), + css('popoverLabels', {display: 'flex', flexDirection: 'column', gap: '1ex'}), knownLabels.map(l => dom.div( dom.label( @@ -2166,7 +2208,7 @@ const labelsPopover = (e: MouseEvent, msgs: api.Message[], possibleLabels: possi }, ), ' ', - dom.span(dom._class('keyword'), l), + dom.span(styleClasses.keyword, l), ), ) ), @@ -2198,7 +2240,7 @@ const movePopover = (e: MouseEvent, mailboxes: api.Mailbox[], msgs: api.Message[ const remove = popover(e.target! as HTMLElement, {}, dom.div( - style({display: 'flex', flexDirection: 'column', gap: '.25em'}), + css('popoverMove', {display: 'flex', flexDirection: 'column', gap: '.25em'}), mailboxes.map(mb => dom.div( dom.clickbutton( @@ -2357,7 +2399,12 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail let ageTimer = 0 // Show with a tag if we are in the cc/bcc headers, or - if none. - const identityTag = (s: string, title: string) => dom.span(dom._class('msgitemidentity'), s, attr.title(title)) + const identityTag = (s: string, title: string) => + dom.span( + css('msgItemIdentity', {padding: '0 .15em', marginLeft: '.15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', whiteSpace: 'nowrap', backgroundColor: styles.backgroundColorMilder, color: styles.color, border: '1px solid', borderColor: styles.colorMilder}), + s, + attr.title(title), + ) const identityHeader: HTMLElement[] = [] if (!envelopeIdentity(mi.Envelope.From || []) && !envelopeIdentity(mi.Envelope.To || [])) { if (envelopeIdentity(mi.Envelope.CC || [])) { @@ -2444,7 +2491,7 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail // Keywords are normally shown per message. For collapsed threads, we show the // keywords of the thread root message as normal, and any additional keywords from // children in a way that draws less attention. - const keywords = (m.Keywords || []).map(kw => dom.span(dom._class('keyword'), kw)) + const keywords = (m.Keywords || []).map(kw => dom.span(styleClasses.keyword, kw)) if (msgitemView.isCollapsedThreadRoot()) { const keywordsSeen = new Set() for (const kw of (m.Keywords || [])) { @@ -2454,7 +2501,7 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail for (const kw of (miv.messageitem.Message.Keywords || [])) { if (!keywordsSeen.has(kw)) { keywordsSeen.add(kw) - keywords.push(dom.span(dom._class('keyword'), dom._class('keywordcollapsed'), kw)) + keywords.push(dom.span(styleClasses.keyword, dom._class('keywordCollapsed'), kw)) } } } @@ -2536,7 +2583,9 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail name = first.substring(0, 8) + '/.../' + last.substring(0, 8) } } - const e = dom.span(dom._class('msgitemmailbox'), isCollapsedKid ? dom._class('msgitemmailboxcollapsed') : [], + const e = dom.span( + css('msgItemMailbox', {padding: '0 .15em', marginLeft: '.15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', whiteSpace: 'nowrap', background: styles.backgroundColorMilder, color: ['white', '#ddd'], border: '1px solid', borderColor: styles.colorMilder}), + isCollapsedKid ? css('msgItemMailboxCollapsed', {background: '#eee', color: '#333'}, true) : [], name === mb.Name ? [] : attr.title(mb.Name), name, ) @@ -2621,11 +2670,19 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail ] } + const msgItemCellStyle = css('msgItemCell', {padding: '2px 4px'}) + + const msgItemStyle = css('msgItem', {display: 'flex', userSelect: 'none', cursor: 'pointer', borderRadius: '.15em', border: '1px solid transparent'}) + ensureCSS('.msgItem.focus', {borderColor: styles.msgItemFocusBorderColor, border: '1px solid'}) + ensureCSS('.msgItem:hover', {backgroundColor: styles.msgItemHoverBackgroundColor}) + ensureCSS('.msgItem.active', {background: styles.msgItemActiveBackground}) + // When rerendering, we remember active & focus states. So we don't have to make // the caller also call redraw on MsglistView. const active = msgitemView.root && msgitemView.root.classList.contains('active') const focus = msgitemView.root && msgitemView.root.classList.contains('focus') - const elem = dom.div(dom._class('msgitem'), + const elem = dom.div( + msgItemStyle, active ? dom._class('active') : [], focus ? dom._class('focus') : [], attr.draggable('true'), @@ -2656,11 +2713,12 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail msglistView.viewportEnsureMessages() } } : [], - isUnread() ? style({fontWeight: 'bold'}) : [], + isUnread() ? css('msgItemUnread', {fontWeight: 'bold'}) : [], // Relevant means not muted and matching the query. - isRelevant() ? [] : style({opacity: '.4'}), - dom.div(dom._class('msgitemcell', 'msgitemflags'), - dom.div(style({display: 'flex', justifyContent: 'space-between'}), + isRelevant() ? [] : css('msgItemNotRelevant', {opacity: '.4'}), + dom.div(msgItemCellStyle, dom._class('msgItemFlags'), + dom.div( + css('msgItemFlagsSpread', {display: 'flex', justifyContent: 'space-between'}), dom.div(flagList(msgitemView)), !msgitemView.parent && msgitemView.kids.length > 0 && msgitemView.collapsed ? dom.clickbutton('' + (1+msgitemView.descendants().length), attr.tabindex('-1'), attr.title('Expand thread.'), attr.arialabel('Expand thread.'), function click(e: MouseEvent) { @@ -2681,29 +2739,34 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail }) : [], ), ), - dom.div(dom._class('msgitemcell', 'msgitemfrom'), - dom.div(style({display: 'flex', justifyContent: 'space-between'}), - dom.div(dom._class('msgitemfromtext', 'silenttitle'), correspondents()), + dom.div(msgItemCellStyle, dom._class('msgItemFrom'), + dom.div(css('msgItemFromSpread', {display: 'flex', justifyContent: 'space-between'}), + dom.div( + dom._class('silenttitle'), + css('msgItemFromText', {whiteSpace: 'nowrap', overflow: 'hidden'}), + correspondents(), + ), identityHeader, ), // Thread messages are connected by a vertical bar. The first and last message are // only half the height of the item, to indicate start/end, and so it stands out // from any thread above/below. ((msgitemView.parent || msgitemView.kids.length > 0) && !msgitemView.threadRoot().collapsed) ? - dom.div(dom._class('msgitemfromthreadbar'), - !msgitemView.parent ? style({top: '50%', bottom: '-1px'}) : ( + dom.div(css('msgItemThreadBar', {position: 'absolute', right: 0, top: 0, bottom: 0, borderRight: '2px solid', borderRightColor: styles.colorMilder}), + !msgitemView.parent ? css('msgItemThreadBarFirst', {top: '50%', bottom: '-1px'}) : ( isThreadLast() ? - style({top: '-1px', bottom: '50%'}) : - style({top: '-1px', bottom: '-1px'}) + css('msgItemThreadBarLast', {top: '-1px', bottom: '50%'}) : + css('msgItemThreadBarMiddle', {top: '-1px', bottom: '-1px'}) ) ) : [] ), - dom.div(dom._class('msgitemcell', 'msgitemsubject'), - dom.div(style({display: 'flex', justifyContent: 'space-between', position: 'relative'}), - dom.div(dom._class('msgitemsubjecttext'), - threadIndent > 0 ? dom.span(threadChar, style({paddingLeft: (threadIndent/2)+'em', color: '#444', fontWeight: 'normal'}), threadCharTitle ? attr.title(threadCharTitle) : []) : [], + dom.div(msgItemCellStyle, css('msgItemSubject', {position: 'relative'}), + dom.div(css('msgItemSubjectSpread', {display: 'flex', justifyContent: 'space-between', position: 'relative'}), + dom.div( + css('msgItemSubjectText', {whiteSpace: 'nowrap', overflow: 'hidden'}), + threadIndent > 0 ? dom.span(threadChar, style({paddingLeft: (threadIndent/2)+'em'}), css('msgItemThreadChar', {opacity: '.75', fontWeight: 'normal'}), threadCharTitle ? attr.title(threadCharTitle) : []) : [], msgitemView.parent ? [] : mi.Envelope.Subject || '(no subject)', - dom.span(dom._class('msgitemsubjectsnippet'), ' '+mi.FirstLine), + dom.span(css('msgItemSubjectSnippet', {fontWeight: 'normal', color: styles.colorMilder}), ' '+mi.FirstLine), ), dom.div( keywords, @@ -2711,7 +2774,7 @@ const newMsgitemView = (mi: api.MessageItem, msglistView: MsglistView, otherMail ), ), ), - dom.div(dom._class('msgitemcell', 'msgitemage'), age(received())), + dom.div(msgItemCellStyle, dom._class('msgItemAge'), age(received())), function click(e: MouseEvent) { e.preventDefault() e.stopPropagation() @@ -3018,15 +3081,15 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l (p.MediaType + '/' + p.MediaSubType).toLowerCase(), p.ContentTypeParams ? ' '+JSON.stringify(p.ContentTypeParams) : [], p.Parts && p.Parts.length === 0 ? [] : dom.ul( - style({listStyle: 'disc', marginLeft: '1em'}), + css('internalsList', {listStyle: 'disc', marginLeft: '1em'}), (p.Parts || []).map(pp => mimepart(pp)) ) ) popup( - style({display: 'flex', gap: '1em'}), - dom.div(dom.h1('Mime structure'), dom.ul(style({listStyle: 'disc', marginLeft: '1em'}), mimepart(pm.Part))), - dom.div(style({whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), - dom.div(style({whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t')), + css('popupInternals', {display: 'flex', gap: '1em'}), + dom.div(dom.h1('Mime structure'), dom.ul(css('internalsList', {listStyle: 'disc', marginLeft: '1em'}), mimepart(pm.Part))), + dom.div(css('internalsMessage', {whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Message'), JSON.stringify(m, undefined, '\t')), + dom.div(css('internalsParts', {whiteSpace: 'pre-wrap', tabSize: 4, maxWidth: '50%'}), dom.h1('Part'), JSON.stringify(pm.Part, undefined, '\t')), ) } @@ -3072,26 +3135,26 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l let msgheaderdetailsElem: HTMLElement | null = null // When full headers are visible, or some headers are requested through settings. const msgmetaElem = dom.div( - style({backgroundColor: '#f8f8f8', borderBottom: '5px solid white', maxHeight: '90%', overflowY: 'auto'}), + css('msgmeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '5px solid', borderBottomColor: ['white', 'black'], maxHeight: '90%', overflowY: 'auto'}), attr.role('region'), attr.arialabel('Buttons and headers for message'), msgbuttonElem=dom.div(), dom.div( attr.arialive('assertive'), - msgheaderElem=dom.table(dom._class('msgheaders'), style({marginBottom: '1ex', width: '100%'})), + msgheaderElem=dom.table(styleClasses.msgHeaders), msgattachmentElem=dom.div(), msgmodeElem=dom.div(), ), - // Explicit gray line with white border below that separates headers from body, to + // Explicit separator that separates headers from body, to // prevent HTML messages from faking UI elements. - dom.div(style({height: '2px', backgroundColor: '#ccc'})), + dom.div(css('headerBodySeparator', {height: '2px', backgroundColor: styles.borderColor})), ) - const msgscrollElem = dom.div(dom._class('pad', 'yscrollauto'), + const msgscrollElem = dom.div(dom._class('pad'), yscrollAutoStyle, attr.role('region'), attr.arialabel('Message body'), - style({backgroundColor: 'white'}), + css('msgscroll', {backgroundColor: styles.backgroundColor}), ) - const msgcontentElem = dom.div(dom._class('scrollparent'), - style({flexGrow: '1'}), + const msgcontentElem = dom.div( + css('scrollparent', {position: 'relative', flexGrow: '1'}), ) const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID @@ -3121,7 +3184,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l dom.clickbutton('More...', attr.title('Show more actions.'), function click(e: MouseEvent) { popover(e.target! as HTMLElement, {transparent: true}, dom.div( - style({display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right'}), + css('popupMore', {display: 'flex', flexDirection: 'column', gap: '.5ex', textAlign: 'right'}), [ dom.clickbutton('Print', attr.title('Print message, opens in new tab and opens print dialog.'), clickCmd(cmdPrint, shortcuts)), dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdMarkNotJunk, shortcuts)), @@ -3143,6 +3206,8 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false) + const headerTextMildStyle = css('headerTextMild', {textAlign: 'right', color: styles.colorMild}) + const loadHeaderDetails = (pm: api.ParsedMessage) => { if (msgheaderdetailsElem) { msgheaderdetailsElem.remove() @@ -3152,11 +3217,11 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l return } msgheaderdetailsElem = dom.table( - style({marginBottom: '1ex', width: '100%'}), + css('msgHeaderDetails', {marginBottom: '1ex', width: '100%'}), Object.entries(pm.Headers || {}).sort().map(t => (t[1] || []).map(v => dom.tr( - dom.td(t[0]+':', style({textAlign: 'right', color: '#555'})), + dom.td(t[0]+':', headerTextMildStyle), dom.td(v), ) ) @@ -3218,18 +3283,21 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l Escape: cmdViewClose, } + const attachmentsArrowStyle = css('attachmentsArrow', {color: styles.backgroundColor, backgroundColor: styles.color, width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold'}) + const attachmentsIframeStyle = css('attachmentsIframe', {flexGrow: 1, boxShadow: styles.boxShadow, backgroundColor: styles.popupBackgroundColor, margin: '0 5em'}) + let content: HTMLElement const popupRoot = dom.div( - style({position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments}), + css('attachmentsOverlay', {position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: styles.overlayBackgroundColor, display: 'flex', flexDirection: 'column', alignContent: 'stretch', padding: '1em', zIndex: zindexes.attachments}), function click(e: MouseEvent) { e.stopPropagation() cmdViewClose() }, attr.tabindex('0'), !(index > 0) ? [] : dom.div( - style({position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), + css('attachmentsPrevious', {position: 'absolute', left: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), dom.div(dom._class('silenttitle'), - style({backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold'}), + attachmentsArrowStyle, attr.title('To previous viewable attachment.'), '←', ), @@ -3238,12 +3306,12 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l enterCmd(cmdViewPrev, attachShortcuts), ), dom.div( - style({textAlign: 'center', paddingBottom: '30px'}), + css('attachmentsDownloadHeaderBox', {textAlign: 'center', paddingBottom: '30px'}), dom.span(dom._class('pad'), function click(e: MouseEvent) { e.stopPropagation() }, - style({backgroundColor: 'white', borderRadius: '.25em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd'}), + css('attachmentsDownloadHeader', {backgroundColor: styles.popupBackgroundColor, color: styles.popupColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, borderRadius: '.25em'}), a.Filename || '(unnamed)', ' - ', formatSize(a.Part.DecodedSize), ' - ', dom.a('Download', attr.download(''), attr.href('msg/'+m.ID+'/download/'+pathStr), function click(e: MouseEvent) { e.stopPropagation() }), @@ -3251,35 +3319,35 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l ), isImage(a) ? dom.div( - style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)', margin: '0 5em'}), + css('attachmentsImageBox', {flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)', margin: '0 5em'}), dom.img( - attr.src('msg/'+m.ID+'/view/'+pathStr), - style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', margin: '0 30px'}) + css('attachmentsImage', {maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow , margin: '0 30px'}), + attr.src('msg/'+m.ID+'/view/'+pathStr) ), ) : ( isText(a) ? dom.iframe( attr.title('Attachment shown as text.'), - style({flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em'}), + attachmentsIframeStyle, attr.src('msg/'+m.ID+'/viewtext/'+pathStr) ) : ( isPDF(a) ? dom.iframe( - style({flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em'}), attr.title('Attachment as PDF.'), + attachmentsIframeStyle, attr.src('msg/'+m.ID+'/view/'+pathStr) ) : content=dom.div( function click(e: MouseEvent) { e.stopPropagation() }, - style({minWidth: '30em', padding: '2ex', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em', textAlign: 'center'}), + css('attachmentsBinary', {minWidth: '30em', padding: '2ex', boxShadow: styles.boxShadow, backgroundColor: styles.popupBackgroundColor, margin: '0 5em', textAlign: 'center'}), dom.div(style({marginBottom: '2ex'}), 'Attachment could be a binary file.'), dom.clickbutton('View as text', function click() { content.replaceWith( dom.iframe( attr.title('Attachment shown as text, though it could be a binary file.'), - style({flexGrow: 1, boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', backgroundColor: 'white', margin: '0 5em'}), + attachmentsIframeStyle, attr.src('msg/'+m.ID+'/viewtext/'+pathStr) ) ) @@ -3288,9 +3356,9 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l ) ), !(index < attachments.length-1) ? [] : dom.div( - style({position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), + css('attachmentsNext', {position: 'absolute', right: '1em', top: 0, bottom: 0, fontSize: '1.5em', width: '2em', display: 'flex', alignItems: 'center', cursor: 'pointer'}), dom.div(dom._class('silenttitle'), - style({backgroundColor: 'rgba(0, 0, 0, .8)', color: 'white', width: '2em', height: '2em', borderRadius: '1em', lineHeight: '2em', textAlign: 'center', fontWeight: 'bold'}), + attachmentsArrowStyle, attr.title('To next viewable attachment.'), '→', ), @@ -3309,7 +3377,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l const l = mi.Attachments || [] dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div( - style({borderTop: '1px solid #ccc'}), + css('inlineAttachmentsSeparator', {borderTop: '1px solid', borderTopColor: styles.borderColor}), dom.div(dom._class('pad'), 'Attachments: ', l.slice(0, filesAll ? l.length : 4).map(a => { @@ -3339,7 +3407,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l } renderAttachments() - const root = dom.div(style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column'})) + const root = dom.div(css('msgViewRoot', {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column'})) dom._kids(root, msgmetaElem, msgcontentElem) const loadText = (pm: api.ParsedMessage): void => { @@ -3354,11 +3422,11 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l const pathStr = [0].concat(f.Path || []).join('.') return dom.div( dom.div( - style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), + css('msgAttachmentBox', {flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), dom.img( attr.src('msg/'+m.ID+'/view/'+pathStr), attr.title(f.Filename), - style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)'}) + css('msgInlineImage', {boxShadow: styles.boxShadow, maxWidth: '100%', maxHeight: '100%'}) ), ) ) @@ -3376,7 +3444,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l attr.tabindex('0'), attr.title('HTML version of message with images inlined, without external resources loaded.'), attr.src('msg/'+m.ID+'/'+urlType), - style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), + css('msgIframeHTML', {position: 'absolute', width: '100%', height: '100%'}), ) ) renderAttachments() // Rerender opaciy on inline images. @@ -3388,7 +3456,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l attr.tabindex('0'), attr.title('HTML version of message with images inlined and with external resources loaded.'), attr.src('msg/'+m.ID+'/'+urlType), - style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), + css('msgIframeHTML', {position: 'absolute', width: '100%', height: '100%'}), ) ) renderAttachments() // Rerender opaciy on inline images. @@ -3408,7 +3476,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l } vl.forEach(v => { const e = dom.tr( - dom.td(k+':', style({textAlign: 'right', color: '#555', whiteSpace: 'nowrap'})), + dom.td(k+':', headerTextMildStyle, style({whiteSpace: 'nowrap'})), dom.td(v), ) msgheaderElem.appendChild(e) @@ -3453,14 +3521,17 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l loadHeaderDetails(pm) loadMoreHeaders(pm) + const msgHeaderSeparatorStyle = css('msgHeaderSeparator', {borderTop: '1px solid', borderTopColor: styles.borderColor}) + const msgModeWarningStyle = css('msgModeWarning', {backgroundColor: styles.warningBackgroundColor, padding: '0 .15em'}) + const htmlNote = 'In the HTML viewer, the following potentially dangerous functionality is disabled: submitting forms, starting a download from a link, navigating away from this page by clicking a link. If a link does not work, try explicitly opening it in a new tab.' const haveText = pm.Texts && pm.Texts.length > 0 if (!haveText && !pm.HasHTML) { dom._kids(msgcontentElem) dom._kids(msgmodeElem, dom.div(dom._class('pad'), - style({borderTop: '1px solid #ccc'}), - dom.span('No textual content', style({backgroundColor: '#ffca91', padding: '0 .15em'})), + msgHeaderSeparatorStyle, + dom.span('No textual content', msgModeWarningStyle), ), ) } else if (haveText && !pm.HasHTML) { @@ -3470,8 +3541,8 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l const text = haveText && (pm.ViewMode == api.ViewMode.ModeText || pm.ViewMode == api.ViewMode.ModeDefault && !settings.showHTML) dom._kids(msgmodeElem, dom.div(dom._class('pad'), - style({borderTop: '1px solid #ccc'}), - !haveText ? dom.span('HTML-only message', attr.title(htmlNote), style({backgroundColor: '#ffca91', padding: '0 .15em', marginRight: '.25em'})) : [], + msgHeaderSeparatorStyle, + !haveText ? dom.span('HTML-only message', attr.title(htmlNote), msgModeWarningStyle, style({marginRight: '.25em'})) : [], dom.span(dom._class('btngroup'), haveText ? textbtn=dom.clickbutton(text ? dom._class('active') : [], 'Text', clickCmd(cmdShowText, shortcuts)) : [], htmlbtn=dom.clickbutton(text || pm.ViewMode != api.ViewMode.ModeHTML ? [] : dom._class('active'), 'HTML', attr.title(htmlNote), async function click() { @@ -3963,9 +4034,9 @@ const newMsglistView = (msgElem: HTMLElement, listMailboxes: listMailboxes, setL dom._kids(msgElem, dom.div( attr.role('region'), attr.arialabel('Buttons for multiple messages'), - style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}), + css('multimsgBg', {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}), dom.div( - style({padding: '4ex', backgroundColor: 'white', borderRadius: '.25em', border: '1px solid #ccc'}), + css('multimsgBox', {backgroundColor: styles.backgroundColor, border: '1px solid', borderColor: styles.borderColor, padding: '4ex', borderRadius: '.25em'}), dom.div( style({textAlign: 'center', marginBottom: '4ex'}), ''+effselected.length+' messages selected', @@ -4972,7 +5043,7 @@ const popoverExport = (reference: HTMLElement, mailboxName: string) => { dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), - dom.div(style({display: 'flex', flexDirection: 'column', gap: '.5ex'}), + dom.div(css('exportFields', {display: 'flex', flexDirection: 'column', gap: '.5ex'}), dom.div( dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox'), @@ -5005,7 +5076,7 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe mailboxlistView.updateHidden() mbv.root.focus() } - const collapseElem = dom.span(dom._class('mailboxcollapse'), minusbox, function click(e: MouseEvent) { + const collapseElem = dom.span(dom._class('mailboxCollapse'), minusbox, function click(e: MouseEvent) { e.stopPropagation() cmdCollapse() }) @@ -5113,7 +5184,17 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe // leave event from browsers, we get events for multiple of this elements children. let drags = 0 - const root = dom.div(dom._class('mailboxitem'), + const mailboxItemStyle = css('mailboxItem', {cursor: 'pointer', borderRadius: '.15em', userSelect: 'none'}) + ensureCSS('.mailboxItem.dropping', {background: styles.highlightBackground}, true) + ensureCSS('.mailboxItem:hover', {backgroundColor: styles.mailboxHoverBackgroundColor}) + ensureCSS('.mailboxItem.active', { background: styles.mailboxActiveBackground}) + ensureCSS('.mailboxHoverOnly', {visibility: 'hidden'}) + ensureCSS('.mailboxItem:hover .mailboxHoverOnly, .mailboxItem:focus .mailboxHoverOnly', {visibility: 'visible'}) + ensureCSS('.mailboxCollapse', {visibility: 'hidden'}) + ensureCSS('.mailboxItem:hover .mailboxCollapse, .mailboxItem:focus .mailboxCollapse', {visibility: 'visible'}) + + const root = dom.div( + mailboxItemStyle, attr.tabindex('0'), async function keydown(e: KeyboardEvent) { if (e.key === 'Enter') { @@ -5179,12 +5260,12 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe await moveAskRuleset(msgID, mbSrcID, xmb, mailboxlistView.mailboxes()) } }, - dom.div(dom._class('mailbox'), - style({display: 'flex', justifyContent: 'space-between'}), - name=dom.div(style({whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'})), + dom.div( + css('mailbox', {padding: '.15em .25em', display: 'flex', justifyContent: 'space-between'}), + name=dom.div(css('mailboxName', {whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'})), dom.div( style({whiteSpace: 'nowrap'}), - actionBtn=dom.clickbutton(dom._class('mailboxhoveronly'), + actionBtn=dom.clickbutton(dom._class('mailboxHoverOnly'), '...', attr.tabindex('-1'), // Without, tab breaks because this disappears when mailbox loses focus. attr.arialabel('Mailbox actions'), @@ -5390,7 +5471,7 @@ const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNew dom._kids(root, dom.div(attr.role('region'), attr.arialabel('Mailboxes'), dom.div( - dom.h1('Mailboxes', style({display: 'inline', fontSize: 'inherit'})), + dom.h1('Mailboxes', css('mailboxesTitle', {display: 'inline', fontSize: 'inherit'})), ' ', dom.clickbutton( @@ -5401,7 +5482,7 @@ const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNew e.stopPropagation() const remove = popover(e.target! as HTMLElement, {transparent: true}, - dom.div(style({display: 'flex', flexDirection: 'column', gap: '.5ex'}), + dom.div(css('mailboxesActions', {display: 'flex', flexDirection: 'column', gap: '.5ex'}), dom.div( dom.clickbutton('Create mailbox', attr.arialabel('Create new mailbox.'), attr.title('Create new mailbox.'), style({padding: '0 .25em'}), function click(e: MouseEvent) { let fieldset: HTMLFieldSetElement @@ -5858,8 +5939,11 @@ const newSearchView = (searchbarElem: HTMLInputElement, mailboxlistView: Mailbox updateSearchbar() } + const searchTableStyle = css('searchTable', {width: '100%'}) + ensureCSS('.searchTable td', {padding: '.25em'}) + const root = dom.div( - style({position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.2)', zIndex: zindexes.compose}), + css('searchOverlay', {position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: styles.overlayBackgroundColor, zIndex: zindexes.compose}), function click(e: MouseEvent) { e.stopPropagation() searchViewClose() @@ -5871,14 +5955,14 @@ const newSearchView = (searchbarElem: HTMLInputElement, mailboxlistView: Mailbox } }, dom.search( - style({position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: 'white', boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)', borderRadius: '.15em'}), + css('searchContent', {position: 'absolute', width: '50em', padding: '.5ex', backgroundColor: styles.popupBackgroundColor, boxShadow: styles.boxShadow, border: '1px solid', borderColor: styles.popupBorderColor, color: styles.popupColor, borderRadius: '.15em'}), function click(e: MouseEvent) { e.stopPropagation() }, // This is a separate form, inside the form with the overall search field because // when updating the form based on the parsed searchbar, we first need to reset it. form=dom.form( - dom.table(dom._class('search'), style({width: '100%'}), + dom.table(searchTableStyle, dom.tr( dom.td(dom.label('Mailbox', attr.for('searchMailbox')), attr.title('Filter by mailbox, including children of the mailbox.')), dom.td( @@ -5990,7 +6074,10 @@ const newSearchView = (searchbarElem: HTMLInputElement, mailboxlistView: Mailbox updateSearchbar() }), update: () => { - v.root.style.backgroundColor = v.active === true ? '#c4ffa9' : (v.active === false ? '#ffb192' : '') + css('searchFlagTrue', {backgroundColor: styles.buttonTristateOnBackground}, true) + css('searchFlagFalse', {backgroundColor: styles.buttonTristateOffBackground}, true) + v.root.classList.toggle('searchFlagTrue', v.active===true) + v.root.classList.toggle('searchFlagFalse', v.active===false) }, } return v @@ -6097,9 +6184,9 @@ const init = async () => { let queryactivityElem: HTMLElement // We show ... when a query is active and data is forthcoming. // Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate. - const listendElem = dom.div(style({borderTop: '1px solid #ccc', color: '#666', margin: '1ex'})) - const listloadingElem = dom.div(style({textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8'}), 'loading...') - const listerrElem = dom.div(style({textAlign: 'center', padding: '.15em 0', color: '#333', border: '1px solid #ccc', margin: '1ex', backgroundColor: '#f8f8f8'})) + const listendElem = dom.div(css('msgListEnd', {borderTop: '1px solid', borderColor: styles.borderColor, color: styles.colorMilder, margin: '1ex'})) + const listloadingElem = dom.div(css('msgListLoading', {textAlign: 'center', padding: '.15em 0', color: styles.colorMild, border: '1px solid', borderColor: styles.borderColor, margin: '1ex', backgroundColor: styles.backgroundColorMild}), 'loading...') + const listerrElem = dom.div(css('msgListErr', {textAlign: 'center', padding: '.15em 0', color: styles.colorMild, border: '1px solid', borderColor: styles.borderColor, margin: '1ex', backgroundColor: styles.backgroundColorMild})) let sseID = 0 // Sent by server in initial SSE response. We use it in API calls to make the SSE endpoint return new data we need. let viewSequence = 0 // Counter for assigning viewID. @@ -6148,12 +6235,12 @@ const init = async () => { const loadSearch = (q: string) => { search = {active: true, query: q} searchbarElem.value = q - searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)' // Cleared when another view is loaded. + searchbarElem.classList.toggle('searchbarActive', true) // Cleared when another view is loaded. searchbarElemBox.style.flexGrow = '4' } const unloadSearch = () => { searchbarElem.value = '' - searchbarElem.style.background = '' + searchbarElem.classList.toggle('searchbarActive', false) searchbarElem.style.zIndex = '' searchbarElemBox.style.flexGrow = '' // Make search bar smaller again. search = {active: false, query: ''} @@ -6222,8 +6309,7 @@ const init = async () => { // msgElem can show a message, show actions on multiple messages, or be empty. let msgElem = dom.div( - style({position: 'absolute', right: 0, left: 0, top: 0, bottom: 0}), - style({backgroundColor: '#f8f8f8'}), + css('msgElem', {position: 'absolute', right: 0, left: 0, top: 0, bottom: 0, backgroundColor: styles.backgroundColorMild}), ) // Returns possible labels based, either from active mailbox (possibly from search), or all mailboxes. @@ -6281,13 +6367,14 @@ const init = async () => { let threadMode: HTMLSelectElement - let msglistElem = dom.div(dom._class('msglist'), - style({position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column'}), + const msgColumnDraggerStyle = css('msgColumnDragger', {position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: styles.popupBorderColor, left: '2.5px'}) + + let msglistElem = dom.div(css('msgList', {backgroundColor: styles.msglistBackgroundColor, position: 'absolute', left: '0', right: 0, top: 0, bottom: 0, display: 'flex', flexDirection: 'column'}), dom.div( attr.role('region'), attr.arialabel('Filter and sorting buttons for message list'), - style({display: 'flex', justifyContent: 'space-between', backgroundColor: '#f8f8f8', borderBottom: '1px solid #ccc', padding: '.25em .5em'}), + css('msgListFilterSorting', {display: 'flex', justifyContent: 'space-between', backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor, padding: '.25em .5em'}), dom.div( - dom.h1('Refine:', style({fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0}), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), + dom.h1('Refine:', css('refineTitle', {fontWeight: 'normal', fontSize: 'inherit', display: 'inline', margin: 0}), attr.title('Refine message listing with quick filters. These refinement filters are in addition to any search criteria, but the refine attachment filter overrides a search attachment criteria.')), ' ', dom.span(dom._class('btngroup'), refineUnreadBtn=dom.clickbutton(settings.refine === 'unread' ? dom._class('active') : [], @@ -6333,7 +6420,7 @@ const init = async () => { remove() } return dom.div( - dom.clickbutton(dom._class('keyword'), l, async function click() { + dom.clickbutton(styleClasses.keyword, keywordButtonStyle, l, async function click() { await selectLabel() }), ) @@ -6390,9 +6477,9 @@ const init = async () => { ), dom.div( style({height: '1ex', position: 'relative'}), - dom.div(dom._class('msgitemflags')), - dom.div(dom._class('msgitemflagsoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), - dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + dom.div(dom._class('msgItemFlags')), + dom.div(dom._class('msgItemFlagsOffset'), css('msgItemFlagsGrab', {position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(msgColumnDraggerStyle), function mousedown(e: MouseEvent) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect() @@ -6402,9 +6489,9 @@ const init = async () => { }) } ), - dom.div(dom._class('msgitemfrom')), - dom.div(dom._class('msgitemfromoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), - dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + dom.div(dom._class('msgItemFrom')), + dom.div(dom._class('msgItemFromOffset'), css('msgItemFlagsGrab', {position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(msgColumnDraggerStyle), function mousedown(e: MouseEvent) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect() @@ -6416,9 +6503,9 @@ const init = async () => { }) } ), - dom.div(dom._class('msgitemsubject')), - dom.div(dom._class('msgitemsubjectoffset'), style({position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), - dom.div(style({position: 'absolute', top: 0, bottom: 0, width: '1px', backgroundColor: '#aaa', left: '2.5px'})), + dom.div(dom._class('msgItemSubject')), + dom.div(dom._class('msgItemSubjectOffset'), css('msgItemFlagsGrab', {position: 'absolute', width: '6px', top: 0, bottom: 0, marginLeft: '-3px', cursor: 'ew-resize'}), + dom.div(msgColumnDraggerStyle), function mousedown(e: MouseEvent) { startDrag(e, (e) => { const bounds = msglistscrollElem.getBoundingClientRect() @@ -6428,11 +6515,11 @@ const init = async () => { }) } ), - dom.div(dom._class('msgitemage')), + dom.div(dom._class('msgItemAge')), ), dom.div( style({flexGrow: '1', position: 'relative'}), - msglistscrollElem=dom.div(dom._class('yscroll'), + msglistscrollElem=dom.div(yscrollStyle, attr.role('region'), attr.arialabel('Message list'), async function scroll() { if (!sseID || requestViewEnd || requestID) { @@ -6467,7 +6554,7 @@ const init = async () => { search = {active: true, query: searchbarElem.value} mailboxlistView.closeMailbox() setLocationHash() - searchbarElem.style.background = 'linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)' // Cleared when another view is loaded. + searchbarElem.classList.toggle('searchbarActive', true) // Cleared when another view is loaded. searchView.root.remove() searchbarElem.blur() document.body.focus() @@ -6560,9 +6647,12 @@ const init = async () => { 'ctrl !': cmdSettings, } + const topMailboxesStyle = css('topMailboxes', {backgroundColor: styles.mailboxesTopBackgroundColor}) + css('searchbarActive', {background: styles.mailboxActiveBackground}) // class set on searchbarElem when active. + const webmailroot = dom.div( - style({display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh'}), - dom.div(dom._class('topbar'), + css('webmailRoot', {display: 'flex', flexDirection: 'column', alignContent: 'stretch', height: '100dvh'}), + dom.div(topMailboxesStyle, style({display: 'flex'}), attr.role('region'), attr.arialabel('Top bar'), topcomposeboxElem=dom.div(dom._class('pad'), @@ -6572,7 +6662,7 @@ const init = async () => { }), ), dom.div(dom._class('pad'), - style({paddingLeft: 0, display: 'flex', flexGrow: 1}), + css('searchbarBox', {paddingLeft: 0, display: 'flex', flexGrow: 1}), searchbarElemBox=dom.search( style({display: 'flex', marginRight: '.5em'}), dom.form( @@ -6644,7 +6734,7 @@ const init = async () => { ), ), connectionElem=dom.div(), - statusElem=dom.div(style({marginLeft: '.5em', flexGrow: '1'}), attr.role('status')), + statusElem=dom.div(css('status', {marginLeft: '.5em', flexGrow: '1'}), attr.role('status')), dom.div( style({paddingLeft: '1em'}), layoutElem=dom.select( @@ -6685,21 +6775,20 @@ const init = async () => { ), ), dom.div( - style({flexGrow: '1'}), - style({position: 'relative'}), - mailboxesElem=dom.div(dom._class('mailboxesbar'), - style({position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0}), - style({display: 'flex', flexDirection: 'column', alignContent: 'stretch'}), - dom.div(dom._class('pad', 'yscrollauto'), - style({flexGrow: '1'}), - style({position: 'relative'}), + css('mailboxesListMsgBox', {flexGrow: '1', position: 'relative'}), + mailboxesElem=dom.div(topMailboxesStyle, + style({width: settings.mailboxesWidth + 'px'}), + css('mailboxesBox', {display: 'flex', flexDirection: 'column', alignContent: 'stretch', position: 'absolute', left: 0,top: 0, bottom: 0}), + dom.div(dom._class('pad'), yscrollAutoStyle, + style({flexGrow: '1', position: 'relative'}), mailboxlistView.root, ), ), mailboxessplitElem=dom.div( - style({position: 'absolute', left: 'calc('+settings.mailboxesWidth +'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), + css('mailboxesListGrab', {position: 'absolute', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), + style({left: 'calc('+settings.mailboxesWidth +'px - 2px)'}), dom.div( - style({position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa'}), + css('mailboxesListLine', {position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: styles.popupBorderColor}), ), function mousedown(e: MouseEvent) { startDrag(e, (e) => { @@ -6711,7 +6800,7 @@ const init = async () => { }) } ), - splitElem=dom.div(style({position: 'absolute', left: 'calc(' + settings.mailboxesWidth+'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid #bbb'})), + splitElem=dom.div(css('listMsgBox', {position: 'absolute', left: 'calc(' + settings.mailboxesWidth+'px + 1px)', right: 0, top: 0, bottom: 0, borderTop: '1px solid', borderTopColor: styles.borderColor})), ), ) @@ -6784,12 +6873,14 @@ const init = async () => { let left: HTMLElement, split: HTMLElement, right: HTMLElement dom._kids(splitElem, left=dom.div( - style({position: 'absolute', left: 0, width: 'calc(' + settings.leftWidthPct + '% - 1px)', top: 0, bottom: 0}), + css('layoutLeft', {position: 'absolute', left: 0, top: 0, bottom: 0}), + style({width: 'calc(' + settings.leftWidthPct + '% - 1px)'}), msglistElem, ), split=dom.div( - style({position: 'absolute', left: 'calc(' + settings.leftWidthPct + '% - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), - dom.div(style({position: 'absolute', backgroundColor: '#aaa', top: 0, bottom: 0, width: '1px', left: '2px', right: '2px'})), + css('listMsgLeftRightGrab', {position: 'absolute', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter}), + style({left: 'calc(' + settings.leftWidthPct + '% - 2px)'}), + dom.div(css('listMsgLeftRightLine', {position: 'absolute', backgroundColor: styles.popupBorderColor, top: 0, bottom: 0, width: '1px', left: '2px', right: '2px'})), function mousedown(e: MouseEvent) { startDrag(e, (e) => { const bounds = left.getBoundingClientRect() @@ -6803,7 +6894,8 @@ const init = async () => { } ), right=dom.div( - style({position: 'absolute', right: 0, left: 'calc(' + settings.leftWidthPct + '% + 1px)', top: 0, bottom: 0}), + css('layoutRight', {position: 'absolute', right: 0, top: 0, bottom: 0}), + style({left: 'calc(' + settings.leftWidthPct + '% + 1px)'}), msgElem, ), ) @@ -6811,12 +6903,14 @@ const init = async () => { let top: HTMLElement, split: HTMLElement, bottom: HTMLElement dom._kids(splitElem, top=dom.div( - style({position: 'absolute', top: 0, height: 'calc(' + settings.topHeightPct + '% - 1px)', left: 0, right: 0}), + css('layoutTop', {position: 'absolute', top: 0, left: 0, right: 0}), + style({height: 'calc(' + settings.topHeightPct + '% - 1px)'}), msglistElem, ), split=dom.div( - style({position: 'absolute', top: 'calc(' + settings.topHeightPct + '% - 2px)', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter}), - dom.div(style({position: 'absolute', backgroundColor: '#aaa', left: 0, right: 0, height: '1px', top: '2px', bottom: '2px'})), + css('listMsgTopBottomGrab', {position: 'absolute', height: '5px', left: '0', right: '0', cursor: 'ns-resize', zIndex: zindexes.splitter}), + style({top: 'calc(' + settings.topHeightPct + '% - 2px)'}), + dom.div(css('listmsgTopBottomLine', {position: 'absolute', backgroundColor: styles.popupBorderColor, left: 0, right: 0, height: '1px', top: '2px', bottom: '2px'})), function mousedown(e: MouseEvent) { startDrag(e, (e) => { const bounds = top.getBoundingClientRect() @@ -6829,7 +6923,8 @@ const init = async () => { } ), bottom=dom.div( - style({position: 'absolute', bottom: 0, top: 'calc(' + settings.topHeightPct + '% + 1px)', left: 0, right: 0}), + css('layoutBottom', {position: 'absolute', bottom: 0, left: 0, right: 0}), + style({top: 'calc(' + settings.topHeightPct + '% + 1px)'}), msgElem, ), ) @@ -6878,14 +6973,14 @@ const init = async () => { const remain = width - (flagswidth+agewidth) const fromwidth = Math.floor(frompct * remain / 100) const subjectwidth = Math.floor(remain - fromwidth) - const cssRules: [string, {[style: string]: number}][] = [ - ['.msgitemflags', {width: flagswidth}], - ['.msgitemfrom', {width: fromwidth}], - ['.msgitemsubject', {width: subjectwidth}], - ['.msgitemage', {width: agewidth}], - ['.msgitemflagsoffset', {left: flagswidth}], - ['.msgitemfromoffset', {left: flagswidth + fromwidth}], - ['.msgitemsubjectoffset', {left: flagswidth + fromwidth + subjectwidth}], + const cssRules: [string, {[style: string]: number | string}][] = [ + ['.msgItemFlags', {width: flagswidth}], + ['.msgItemFrom', {width: fromwidth, position: 'relative'}], + ['.msgItemSubject', {width: subjectwidth}], + ['.msgItemAge', {width: agewidth, 'text-align': 'right'}], + ['.msgItemFlagsOffset', {left: flagswidth}], + ['.msgItemFromOffset', {left: flagswidth + fromwidth}], + ['.msgItemSubjectOffset', {left: flagswidth + fromwidth + subjectwidth}], ] if (!rulesInserted) { cssRules.forEach((rule, i) => { stylesheet.insertRule(rule[0] + '{}', i) }) @@ -6894,7 +6989,11 @@ const init = async () => { cssRules.forEach((rule, i) => { const r = stylesheet.cssRules[i] as CSSStyleRule for (const k in rule[1]) { - r.style.setProperty(k, ''+rule[1][k]+'px') + let v = rule[1][k] + if (typeof v !== 'string') { + v = ''+v+'px' + } + r.style.setProperty(k, v) } }) lastflagswidth = flagswidth @@ -6999,7 +7098,7 @@ const init = async () => { const showNotConnected = () => { dom._kids(connectionElem, attr.role('status'), - dom.span(style({backgroundColor: '#ffa9a9', padding: '0 .15em', borderRadius: '.15em'}), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), + dom.span(css('connectionStatus', {backgroundColor: styles.warningBackgroundColor, padding: '0 .15em', borderRadius: '.15em'}), 'Not connected', attr.title('Not receiving real-time updates, including of new deliveries.')), ' ', dom.clickbutton('Reconnect', function click() { if (!eventSource && !connecting) { @@ -7393,7 +7492,7 @@ const showUnhandledError = (err: Error, lineno: number, colno: number) => { } const xerrmsg = err.toString() const box = dom.div( - style({position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(249, 191, 191, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut}), + css('unhandledErrorBox', {position: 'absolute', bottom: '1ex', left: '1ex', backgroundColor: 'rgba(255, 110, 110, .9)', maxWidth: '14em', padding: '.25em .5em', borderRadius: '.25em', fontSize: '.8em', wordBreak: 'break-all', zIndex: zindexes.shortcut}), dom.div(style({marginBottom: '.5ex'}), ''+xerrmsg), dom.clickbutton('Details', function click() { box.remove() @@ -7417,7 +7516,7 @@ Details of the error and browser: style({maxWidth: '60em'}), dom.h1('A JavaScript error occurred'), dom.pre(dom._class('mono'), - style({backgroundColor: '#f8f8f8', padding: '1ex', borderRadius: '.15em', border: '1px solid #ccc', whiteSpace: 'pre-wrap'}), + css('unhandledErrorMsg', {backgroundColor: styles.backgroundColorMild, padding: '1ex', borderRadius: '.15em', border: '1px solid', borderColor: styles.borderColor, whiteSpace: 'pre-wrap'}), msg, ), dom.br(),