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

View file

@ -3,7 +3,6 @@ import classnames from 'classnames';
import React from 'react';
import { localized, Actions, Contact } from 'mailspring-exports';
const { Menu, MenuItem } = require('@electron/remote');
const MAX_COLLAPSED = 5;
@ -34,18 +33,25 @@ export default class MessageParticipants extends React.Component<MessageParticip
}
_shortNames(contacts = [], max = MAX_COLLAPSED) {
let names = contacts.map(c =>
c.displayName({
includeAccountLabel: true,
compact: !AppEnv.config.get('core.reading.detailedNames'),
})
);
let names = contacts.map((c, i) => (
<span key={`contact-${i}`}>
{i > 0 && ', '}
<span onContextMenu={() => this._onContactContextMenu(c)}>
{c.displayName({
includeAccountLabel: true,
compact: !AppEnv.config.get('core.reading.detailedNames'),
})}
</span>
</span>
));
if (names.length > max) {
const extra = names.length - 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 => {
@ -63,10 +69,17 @@ export default class MessageParticipants extends React.Component<MessageParticip
_onContactContextMenu = contact => {
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(
new MenuItem({
label: `${localized(`Email`)} ${contact.email}`,
label: `${localized(`Email`)} ${contact.name ?? contact.email}`,
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) {
return (
<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()}
</div>
<div className="participant-secondary">

View file

@ -1,7 +1,8 @@
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 { CSSTransitionGroup } from 'react-transition-group';
import { PLUGIN_ID } from '../../../internal_packages/send-later/lib/send-later-constants';
function isUndoSend(block) {
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) {
return block.tasks[0].value.expiration * 1000;
}
@ -75,6 +99,10 @@ const UndoSendContent = ({ block, onMouseEnter, onMouseLeave }) => {
<div className="content" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Countdown expiration={getUndoSendExpiration(block)} />
<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')}>
<RetinaImg name="undo-icon@2x.png" mode={RetinaImg.Mode.ContentIsMask} />
<span className="undo-action-text">{localized('Undo')}</span>

View file

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

View file

@ -617,6 +617,7 @@
"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 new messages from:": "Send new messages from:",
"Send now instead": "Send now instead",
"Send on your own schedule": "Send on your own schedule",
"Sender Name": "Sender Name",
"Sending": "Sending",

View file

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

View file

@ -156,9 +156,14 @@ class DatabaseStore extends MailspringStore {
}
_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, '%%');
q = `color:black |||%c ${q}`;
q = q.replace(/`(\w+)`/g, '||| color:purple |||%c$&||| color:black |||%c');
q = `color:${primaryColor} |||%c ${q}`;
q = q.replace(/`(\w+)`/g, `||| color:${purpleColor} |||%c$&||| color:${primaryColor} |||%c`);
const colorRules = {
'color:green': [
@ -184,7 +189,7 @@ class DatabaseStore extends MailspringStore {
for (const keyword of colorRules[style]) {
q = q.replace(
new RegExp(`\\b${keyword}\\b`, 'g'),
`||| ${style} |||%c${keyword}||| color:black |||%c`
`||| ${style} |||%c${keyword}||| color:${primaryColor} |||%c`
);
}
}