mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-05 10:56:41 +08:00
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:
parent
3d60cedb8f
commit
450dfbef42
7 changed files with 128 additions and 40 deletions
|
@ -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) {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue