Some UX improvements (#2425)

* Some UX improvements (plus .gitignore tweak)

Made a few UX improvements:
- The send later delay can be skipped by clicking the new `Send now instead` button (localization required)
- If there is a range selection in the subject line then a context menu pops up on right click (for those that are allergic to keyboard shortcuts 😂)
- Message participant tweaks:
  - you can now open the context menu on the name or email, not just the email
  - you don't need to expand the participants to open the context menu
  - name is shown over the email for `Email "Name else Email"` context menu item
  - added `Copy "Email"` to context menu when nothing is selected/highlighted and just `Copy` when something is
- **for devs:** pretty console messages now consider your device theme and use an appropriate colour

* Prettier adjustments

* Revert `.gitignore` change

* Fix & actual prettier-ing

- Fix email context menu (now email and name can be alt-clicked)
- Sorted auto format (sorry didn't realise it was off)

* Use localised date/time format

Use a localised date format rather than forcing the Americanised format. This does loose the ordinal in `fullTimeString(Date)`

* Use weekday(Mon-Sun), for recent emails

- Changed recent emails to:
  - display weekday and time (e.g Mon, 10:15)
  - the recent emails to be 5 days, rather than 2
- Removed dead code
- Removed unnecessary import

* Remove unnecessary log

* Add key to prevent console error

Added a key to prevent a `Each child in an array or iterator should have a unique "key" prop` error in the console

* Add a key to "more" span

Co-authored-by: Ben Gotow <ben@foundry376.com>
This commit is contained in:
Glenn 2022-09-13 17:24:39 +01:00 committed by GitHub
parent 3d60cedb8f
commit 450dfbef42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 128 additions and 40 deletions

View file

@ -40,6 +40,7 @@ interface MessageListState {
minified: boolean; minified: boolean;
} }
const { Menu, MenuItem } = require('@electron/remote');
const PREF_REPLY_TYPE = 'core.sending.defaultReplyType'; const PREF_REPLY_TYPE = 'core.sending.defaultReplyType';
const PREF_RESTRICT_WIDTH = 'core.reading.restrictMaxWidth'; const PREF_RESTRICT_WIDTH = 'core.reading.restrictMaxWidth';
const PREF_DESCENDING_ORDER = 'core.reading.descendingOrderMessageList'; const PREF_DESCENDING_ORDER = 'core.reading.descendingOrderMessageList';
@ -351,7 +352,9 @@ class MessageList extends React.Component<Record<string, unknown>, MessageListSt
<div className="message-subject-wrap"> <div className="message-subject-wrap">
<MailImportantIcon thread={this.state.currentThread} /> <MailImportantIcon thread={this.state.currentThread} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<span className="message-subject">{subject}</span> <span className="message-subject" onContextMenu={() => _onSubjectContextMenu()}>
{subject}
</span>
<MailLabelSet <MailLabelSet
removable removable
includeCurrentCategories includeCurrentCategories
@ -369,6 +372,14 @@ class MessageList extends React.Component<Record<string, unknown>, MessageListSt
/> />
</div> </div>
); );
function _onSubjectContextMenu() {
if (window.getSelection()?.type == 'Range') {
const menu = new Menu();
menu.append(new MenuItem({ role: 'copy' }));
menu.popup({});
}
}
} }
_renderMinifiedBundle(bundle) { _renderMinifiedBundle(bundle) {

View file

@ -3,7 +3,6 @@ import classnames from 'classnames';
import React from 'react'; import React from 'react';
import { localized, Actions, Contact } from 'mailspring-exports'; import { localized, Actions, Contact } from 'mailspring-exports';
const { Menu, MenuItem } = require('@electron/remote'); const { Menu, MenuItem } = require('@electron/remote');
const MAX_COLLAPSED = 5; const MAX_COLLAPSED = 5;
@ -34,18 +33,25 @@ export default class MessageParticipants extends React.Component<MessageParticip
} }
_shortNames(contacts = [], max = MAX_COLLAPSED) { _shortNames(contacts = [], max = MAX_COLLAPSED) {
let names = contacts.map(c => let names = contacts.map((c, i) => (
c.displayName({ <span key={`contact-${i}`}>
{i > 0 && ', '}
<span onContextMenu={() => this._onContactContextMenu(c)}>
{c.displayName({
includeAccountLabel: true, includeAccountLabel: true,
compact: !AppEnv.config.get('core.reading.detailedNames'), compact: !AppEnv.config.get('core.reading.detailedNames'),
}) })}
); </span>
</span>
));
if (names.length > max) { if (names.length > max) {
const extra = names.length - max; const extra = names.length - max;
names = names.slice(0, max); names = names.slice(0, max);
names.push(`and ${extra} more`); names.push(<span key="contact-more">and ${extra} more</span>);
} }
return names.join(', ');
return names;
} }
_onSelectText = e => { _onSelectText = e => {
@ -63,10 +69,17 @@ export default class MessageParticipants extends React.Component<MessageParticip
_onContactContextMenu = contact => { _onContactContextMenu = contact => {
const menu = new Menu(); const menu = new Menu();
menu.append(new MenuItem({ role: 'copy' })); menu.append(
window.getSelection()?.type == 'Range'
? new MenuItem({ role: 'copy' })
: new MenuItem({
label: `${localized(`Copy`)} "${contact.email}"`,
click: () => navigator.clipboard.writeText(contact.email),
})
);
menu.append( menu.append(
new MenuItem({ new MenuItem({
label: `${localized(`Email`)} ${contact.email}`, label: `${localized(`Email`)} ${contact.name ?? contact.email}`,
click: () => Actions.composeNewDraftToRecipient(contact), click: () => Actions.composeNewDraftToRecipient(contact),
}) })
); );
@ -83,7 +96,11 @@ export default class MessageParticipants extends React.Component<MessageParticip
if (c.name && c.name.length > 0 && c.name !== c.email) { if (c.name && c.name.length > 0 && c.name !== c.email) {
return ( return (
<div key={`${c.email}-${i}`} className="participant selectable"> <div key={`${c.email}-${i}`} className="participant selectable">
<div className="participant-primary" onClick={this._onSelectText}> <div
className="participant-primary"
onClick={this._onSelectText}
onContextMenu={() => this._onContactContextMenu(c)}
>
{c.fullName()} {c.fullName()}
</div> </div>
<div className="participant-secondary"> <div className="participant-secondary">

View file

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { localized, UndoRedoStore, SyncbackMetadataTask } from 'mailspring-exports'; import { localized, UndoRedoStore, SyncbackMetadataTask, DatabaseStore, Message, Actions } from 'mailspring-exports';
import { RetinaImg } from 'mailspring-component-kit'; import { RetinaImg } from 'mailspring-component-kit';
import { CSSTransitionGroup } from 'react-transition-group'; import { CSSTransitionGroup } from 'react-transition-group';
import { PLUGIN_ID } from '../../../internal_packages/send-later/lib/send-later-constants';
function isUndoSend(block) { function isUndoSend(block) {
return ( return (
@ -11,6 +12,29 @@ function isUndoSend(block) {
); );
} }
async function sendMessageNow(block) {
if (isUndoSend(block)) {
const message = await DatabaseStore.find<Message>(Message, (block.tasks[0] as SyncbackMetadataTask).modelId),
newExpiry = Math.floor(Date.now() / 1000);
Actions.queueTask(
SyncbackMetadataTask.forSaving({
model: message,
pluginId: PLUGIN_ID,
value: {
expiration: newExpiry,
},
})
);
block.tasks[0].value.expiration = newExpiry;
return true;
}
return false;
}
function getUndoSendExpiration(block) { function getUndoSendExpiration(block) {
return block.tasks[0].value.expiration * 1000; return block.tasks[0].value.expiration * 1000;
} }
@ -75,6 +99,10 @@ const UndoSendContent = ({ block, onMouseEnter, onMouseLeave }) => {
<div className="content" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> <div className="content" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Countdown expiration={getUndoSendExpiration(block)} /> <Countdown expiration={getUndoSendExpiration(block)} />
<div className="message">{localized('Sending soon...')}</div> <div className="message">{localized('Sending soon...')}</div>
<div className="action" onClick={async () => { await sendMessageNow(block) && onMouseLeave() }}>
<RetinaImg name="icon-composer-send.png" mode={RetinaImg.Mode.ContentIsMask} />
<span className="send-action-text">{localized('Send now instead')}</span>
</div>
<div className="action" onClick={() => AppEnv.commands.dispatch('core:undo')}> <div className="action" onClick={() => AppEnv.commands.dispatch('core:undo')}>
<RetinaImg name="undo-icon@2x.png" mode={RetinaImg.Mode.ContentIsMask} /> <RetinaImg name="undo-icon@2x.png" mode={RetinaImg.Mode.ContentIsMask} />
<span className="undo-action-text">{localized('Undo')}</span> <span className="undo-action-text">{localized('Undo')}</span>

View file

@ -46,10 +46,13 @@
img { img {
background-color: @background-primary; background-color: @background-primary;
} }
&:hover { &:hover {
background: fade(@black, 30%); background: fade(@black, 30%);
border: 1px solid fade(@background-primary, 30%); border: 1px solid fade(@background-primary, 30%);
} }
.send-action-text,
.undo-action-text { .undo-action-text {
margin-left: 5px; margin-left: 5px;
color: @background-primary; color: @background-primary;

View file

@ -617,6 +617,7 @@
"Send message": "Send message", "Send message": "Send message",
"Send more than one message using the same %@ or subject line to compare open rates and reply rates.": "Send more than one message using the same %@ or subject line to compare open rates and reply rates.", "Send more than one message using the same %@ or subject line to compare open rates and reply rates.": "Send more than one message using the same %@ or subject line to compare open rates and reply rates.",
"Send new messages from:": "Send new messages from:", "Send new messages from:": "Send new messages from:",
"Send now instead": "Send now instead",
"Send on your own schedule": "Send on your own schedule", "Send on your own schedule": "Send on your own schedule",
"Sender Name": "Sender Name", "Sender Name": "Sender Name",
"Sending": "Sending", "Sending": "Sending",

View file

@ -389,27 +389,40 @@ const DateUtils = {
* *
* The returned date/time format depends on how long ago the timestamp is. * The returned date/time format depends on how long ago the timestamp is.
*/ */
shortTimeString(datetime) { shortTimeString(datetime: Date) {
const now = moment(); const now = moment();
const diff = now.diff(datetime, 'days', true); const diff = now.diff(datetime, 'days', true);
const isSameDay = now.isSame(datetime, 'days'); const isSameDay = now.isSame(datetime, 'days');
let format = null; const opts: Intl.DateTimeFormatOptions = {
hour12: !AppEnv.config.get('core.workspace.use24HourClock'),
};
if (diff <= 1 && isSameDay) { if (diff <= 1 && isSameDay) {
// Time if less than 1 day old // Time if less than 1 day old
format = DateUtils.getTimeFormat(null); opts.hour = 'numeric';
} else if (diff < 2 && !isSameDay) { opts.minute = '2-digit';
// Month and day with time if up to 2 days ago } else if (diff < 5 && !isSameDay) {
format = `MMM D, ${DateUtils.getTimeFormat(null)}`; // Weekday with time if up to 2 days ago
} else if (diff >= 2 && diff < 365) { //opts.month = 'short';
//opts.day = 'numeric';
opts.weekday = 'short';
opts.hour = 'numeric';
opts.minute = '2-digit';
} else {
if (diff < 365) {
// Month and day up to 1 year old // Month and day up to 1 year old
format = 'MMM D'; opts.month = 'short';
opts.day = 'numeric';
} else { } else {
// Month, day and year if over a year old // Month, day and year if over a year old
format = 'MMM D YYYY'; opts.year = 'numeric';
opts.month = 'short';
opts.day = 'numeric';
}
return datetime.toLocaleDateString(navigator.language, opts);
} }
return moment(datetime).format(format); return datetime.toLocaleTimeString(navigator.language, opts);
}, },
/** /**
@ -418,11 +431,14 @@ const DateUtils = {
* @param {Date} datetime - Timestamp * @param {Date} datetime - Timestamp
* @return {String} Formated date/time * @return {String} Formated date/time
*/ */
mediumTimeString(datetime) { mediumTimeString(datetime: Date) {
let format = 'MMMM D, YYYY, '; return datetime.toLocaleTimeString(navigator.language, {
format += DateUtils.getTimeFormat({ seconds: false, upperCase: true, timeZone: false }); hour12: !AppEnv.config.get('core.workspace.use24HourClock'),
year: 'numeric',
return moment(datetime).format(format); month: 'long',
day: 'numeric',
second: undefined,
});
}, },
/** /**
@ -431,13 +447,20 @@ const DateUtils = {
* @param {Date} datetime - Timestamp * @param {Date} datetime - Timestamp
* @return {String} Formated date/time * @return {String} Formated date/time
*/ */
fullTimeString(datetime) { fullTimeString(datetime: Date) {
let format = 'dddd, MMMM Do YYYY, '; // ISSUE: this does drop ordinal. There is this:
format += DateUtils.getTimeFormat({ seconds: true, upperCase: true, timeZone: true }); // -> new Intl.PluralRules(LOCALE, { type: "ordinal" }).select(dateTime.getDay())
// which may work with the below regex, though localisation is required
// `(?<!\d)${dateTime.getDay()}(?!\d)` replace `$1${localise(ordinal)}`
return moment(datetime) return datetime.toLocaleTimeString(navigator.language, {
.tz(tz) hour12: !AppEnv.config.get('core.workspace.use24HourClock'),
.format(format); year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
second: undefined,
});
}, },
}; };

View file

@ -156,9 +156,14 @@ class DatabaseStore extends MailspringStore {
} }
_prettyConsoleLog(qa) { _prettyConsoleLog(qa) {
const darkTheme =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches,
primaryColor = darkTheme ? 'white' : 'black',
purpleColor = darkTheme ? 'pink' : 'purple';
let q = qa.replace(/%/g, '%%'); let q = qa.replace(/%/g, '%%');
q = `color:black |||%c ${q}`; q = `color:${primaryColor} |||%c ${q}`;
q = q.replace(/`(\w+)`/g, '||| color:purple |||%c$&||| color:black |||%c'); q = q.replace(/`(\w+)`/g, `||| color:${purpleColor} |||%c$&||| color:${primaryColor} |||%c`);
const colorRules = { const colorRules = {
'color:green': [ 'color:green': [
@ -184,7 +189,7 @@ class DatabaseStore extends MailspringStore {
for (const keyword of colorRules[style]) { for (const keyword of colorRules[style]) {
q = q.replace( q = q.replace(
new RegExp(`\\b${keyword}\\b`, 'g'), new RegExp(`\\b${keyword}\\b`, 'g'),
`||| ${style} |||%c${keyword}||| color:black |||%c` `||| ${style} |||%c${keyword}||| color:${primaryColor} |||%c`
); );
} }
} }