feat(reminders): Add send reminders functionality

Summary: Add reminders plugin which lets you set reminder if you don't get a reply for a message within a specified time in the future

Test Plan: TODO

Reviewers: halla, bengotow, evan

Reviewed By: halla, bengotow, evan

Differential Revision: https://phab.nylas.com/D3356
This commit is contained in:
Juan Tejada 2016-10-13 09:43:20 -07:00
parent f4a5478358
commit f2e7ea4c4c
48 changed files with 478 additions and 99 deletions

1
.gitignore vendored
View file

@ -38,4 +38,5 @@ internal_packages/composer-scheduler
internal_packages/link-tracking
internal_packages/open-tracking
internal_packages/send-later
internal_packages/send-reminders
internal_packages/thread-sharing

View file

@ -4,6 +4,7 @@ _ = require 'underscore'
DestroyCategoryTask,
CategoryStore,
Category,
ExtensionRegistry,
RegExpUtils} = require 'nylas-exports'
SidebarItem = require './sidebar-item'
SidebarActions = require './sidebar-actions'
@ -42,8 +43,15 @@ class SidebarSection
draftsItem = SidebarItem.forDrafts([account.id])
snoozedItem = SidebarItem.forSnoozed([account.id])
extensionItems = ExtensionRegistry.AccountSidebar.extensions()
.filter((ext) => ext.sidebarItem?)
.map((ext) => ext.sidebarItem([account.id]))
.map(({id, name, iconName, perspective}) =>
SidebarItem.forPerspective(id, perspective, {name, iconName})
)
# Order correctly: Inbox, Unread, Starred, rest... , Drafts
items.splice(1, 0, unreadItem, starredItem, snoozedItem)
items.splice(1, 0, unreadItem, starredItem, snoozedItem, extensionItems...)
items.push(draftsItem)
return {
@ -96,8 +104,26 @@ class SidebarSection
children: accounts.map (acc) -> SidebarItem.forSnoozed([acc.id], name: acc.label)
)
extensionItems = ExtensionRegistry.AccountSidebar.extensions()
.filter((ext) => ext.sidebarItem?)
.map((ext) =>
{id, name, iconName, perspective} = ext.sidebarItem(accountIds)
return SidebarItem.forPerspective(id, perspective, {
name,
iconName,
children: accounts.map((acc) =>
subItem = ext.sidebarItem([acc.id])
return SidebarItem.forPerspective(
subItem.id + "-#{acc.id}",
subItem.perspective,
{name: acc.label, iconName: subItem.iconName}
)
)
})
)
# Order correctly: Inbox, Unread, Starred, rest... , Drafts
items.splice(1, 0, unreadItem, starredItem, snoozedItem)
items.splice(1, 0, unreadItem, starredItem, snoozedItem, extensionItems...)
items.push(draftsItem)
return {

View file

@ -111,6 +111,7 @@ class MarkdownEditor extends React.Component
{".btn-templates { display:none; }"}
{".btn-scheduler { display:none; }"}
{".btn-translate { display:none; }"}
{".btn-send-reminder { display:none; }"}
</style>
<div
ref="container"

View file

@ -53,7 +53,7 @@ class DraftListStore extends NylasStore
# Generate a new result set that includes additional information on
# the draft objects. This is similar to what we do in the thread-list,
# where we set thread.metadata to the message array.
# where we set thread.__messages to the message array.
resultSetWithTasks = new MutableQueryResultSet(resultSet)
mailboxPerspective.accountIds.forEach (aid) =>

View file

@ -195,6 +195,7 @@ class MessageList extends React.Component
<span className="message-subject">{subject}</span>
<MailLabelSet
removable={true}
messages={@state.messages}
thread={@state.currentThread}
includeCurrentCategories={true}
/>

View file

@ -7,6 +7,7 @@ moment = require 'moment'
RetinaImg,
MailLabelSet,
MailImportantIcon,
InjectedComponent,
InjectedComponentSet} = require 'nylas-component-kit'
{Thread, FocusedPerspectiveStore, Utils, DateUtils} = require 'nylas-exports'
@ -19,13 +20,13 @@ ThreadListStore = require './thread-list-store'
ThreadListIcon = require './thread-list-icon'
# Get and format either last sent or last received timestamp depending on thread-list being viewed
TimestampComponentForPerspective = (thread) ->
ThreadListTimestamp = ({thread}) ->
if FocusedPerspectiveStore.current().isSent()
rawTimestamp = thread.lastMessageSentTimestamp
else
rawTimestamp = thread.lastMessageReceivedTimestamp
timestamp = DateUtils.shortTimeString(rawTimestamp)
<span className="timestamp">{timestamp}</span>
return <span className="timestamp">{timestamp}</span>
subject = (subj) ->
if (subj ? "").trim().length is 0
@ -42,6 +43,13 @@ subject = (subj) ->
else
return subj
getSnippet = (thread) ->
messages = thread.__messages || []
if (messages.length is 0)
return thread.snippet
return messages[messages.length - 1].snippet
c1 = new ListTabular.Column
name: "★"
@ -51,21 +59,23 @@ c1 = new ListTabular.Column
<MailImportantIcon
key="mail-important-icon"
thread={thread}
showIfAvailableForAnyAccount={true} />
showIfAvailableForAnyAccount={true}
/>
<InjectedComponentSet
key="injected-component-set"
inline={true}
containersRequired={false}
matching={role: "ThreadListIcon"}
className="thread-injected-icons"
exposedProps={thread: thread}/>
exposedProps={thread: thread}
/>
]
c2 = new ListTabular.Column
name: "Participants"
width: 200
resolver: (thread) =>
hasDraft = _.find (thread.metadata ? []), (m) -> m.draft
hasDraft = (thread.__messages || []).find((m) => m.draft)
if hasDraft
<div style={display: 'flex'}>
<ThreadListParticipants thread={thread} />
@ -81,23 +91,23 @@ c3 = new ListTabular.Column
flex: 4
resolver: (thread) =>
attachment = false
metadata = (thread.metadata ? [])
messages = (thread.__messages || [])
hasAttachments = thread.hasAttachments and metadata.find (m) -> Utils.showIconForAttachments(m.files)
hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files)
if hasAttachments
attachment = <div className="thread-icon thread-icon-attachment"></div>
<span className="details">
<MailLabelSet thread={thread} />
<span className="subject">{subject(thread.subject)}</span>
<span className="snippet">{thread.snippet}</span>
<span className="snippet">{getSnippet(thread)}</span>
{attachment}
</span>
c4 = new ListTabular.Column
name: "Date"
resolver: (thread) =>
TimestampComponentForPerspective(thread)
return <ThreadListTimestamp thread={thread} />
c5 = new ListTabular.Column
name: "HoverActions"
@ -114,7 +124,8 @@ c5 = new ListTabular.Column
]}
matching={role: "ThreadListQuickAction"}
className="thread-injected-quick-actions"
exposedProps={thread: thread}/>
exposedProps={thread: thread}
/>
</div>
cNarrow = new ListTabular.Column
@ -123,13 +134,13 @@ cNarrow = new ListTabular.Column
resolver: (thread) =>
pencil = false
attachment = false
metadata = (thread.metadata ? [])
messages = (thread.__messages || [])
hasAttachments = thread.hasAttachments and metadata.find (m) -> Utils.showIconForAttachments(m.files)
hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files)
if hasAttachments
attachment = <div className="thread-icon thread-icon-attachment"></div>
hasDraft = _.find metadata, (m) -> m.draft
hasDraft = messages.find((m) => m.draft)
if hasDraft
pencil = <RetinaImg name="icon-draft-pencil.png" className="draft-icon" mode={RetinaImg.Mode.ContentPreserve} />
@ -159,11 +170,19 @@ cNarrow = new ListTabular.Column
{pencil}
<span style={flex:1}></span>
{attachment}
{TimestampComponentForPerspective(thread)}
<InjectedComponent
key="thread-injected-timestamp"
className="thread-injected-timestamp"
inline={true}
containersRequired={false}
fallback={ThreadListTimestamp}
exposedProps={thread: thread}
matching={role: "ThreadListTimestamp"}
/>
</div>
<div className="subject">{subject(thread.subject)}</div>
<div className="snippet-and-labels">
<div className="snippet">{thread.snippet}&nbsp;</div>
<div className="snippet">{getSnippet(thread)}&nbsp;</div>
<div style={flex: 1, flexShrink: 1}></div>
<MailLabelSet thread={thread} />
</div>

View file

@ -9,7 +9,7 @@ Rx = require 'rx-lite'
_flatMapJoiningMessages = ($threadsResultSet) =>
# DatabaseView leverages `QuerySubscription` for threads /and/ for the
# messages on each thread, which are passed to out as `thread.metadata`.
# messages on each thread, which are passed to out as `thread.__messages`.
$messagesResultSets = {}
@ -52,13 +52,13 @@ _flatMapJoiningMessages = ($threadsResultSet) =>
Rx.Observable.combineLatest(sets)
.flatMapLatest ([threadsResultSet, messagesResultSets...]) =>
threadsWithMetadata = {}
threadsWithMessages = {}
threadsResultSet.models().map (thread, idx) ->
thread = new thread.constructor(thread)
thread.metadata = messagesResultSets[idx]?.models()
threadsWithMetadata[thread.id] = thread
thread.__messages = messagesResultSets[idx]?.models().filter((m) => !m.isHidden())
threadsWithMessages[thread.id] = thread
Rx.Observable.from([QueryResultSet.setByApplyingModels(threadsResultSet, threadsWithMetadata)])
Rx.Observable.from([QueryResultSet.setByApplyingModels(threadsResultSet, threadsWithMessages)])
_observableForThreadMessages = (id, initialModels) ->
subscription = new QuerySubscription(DatabaseStore.findAll(Message, threadId: id), {

View file

@ -4,6 +4,7 @@ React = require 'react'
Actions,
Thread,
ChangeStarredTask,
ExtensionRegistry,
AccountStore} = require 'nylas-exports'
class ThreadListIcon extends React.Component
@ -11,10 +12,20 @@ class ThreadListIcon extends React.Component
@propTypes:
thread: React.PropTypes.object
_iconType: =>
_extensionsIconClassNames: =>
return ExtensionRegistry.ThreadList.extensions()
.filter((ext) => ext.cssClassNamesForThreadListIcon?)
.reduce(((prev, ext) => prev + ' ' + ext.cssClassNamesForThreadListIcon(@props.thread)), '')
.trim()
_iconClassNames: =>
if !@props.thread
return 'thread-icon-star-on-hover'
extensionIconClassNames = @_extensionsIconClassNames()
if extensionIconClassNames.length > 0
return extensionIconClassNames
if @props.thread.starred
return 'thread-icon-star'
@ -33,7 +44,7 @@ class ThreadListIcon extends React.Component
return 'thread-icon-none thread-icon-star-on-hover'
_nonDraftMessages: =>
msgs = @props.thread.metadata
msgs = @props.thread.__messages
return [] unless msgs and msgs instanceof Array
msgs = _.filter msgs, (m) -> m.serverId and not m.draft
return msgs
@ -43,7 +54,7 @@ class ThreadListIcon extends React.Component
true
render: =>
<div className="thread-icon #{@_iconType()}"
<div className="thread-icon #{@_iconClassNames()}"
title="Star"
onClick={@_onToggleStar}></div>

View file

@ -53,15 +53,16 @@ class ThreadListParticipants extends React.Component
short += ", "
accumulate(short, unread)
if @props.thread.metadata and @props.thread.metadata.length > 1
accumulate(" (#{@props.thread.metadata.length})")
messages = (@props.thread.__messages ? [])
if messages.length > 1
accumulate(" (#{messages.length})")
flush()
return spans
getTokensFromMetadata: =>
messages = @props.thread.metadata
getTokensFromMessages: =>
messages = @props.thread.__messages
tokens = []
field = 'from'
@ -94,8 +95,8 @@ class ThreadListParticipants extends React.Component
contacts.map (contact) -> { contact: contact, unread: false }
getTokens: =>
if @props.thread.metadata instanceof Array
list = @getTokensFromMetadata()
if @props.thread.__messages instanceof Array
list = @getTokensFromMessages()
else
list = @getTokensFromParticipants()

View file

@ -1,7 +1,7 @@
_ = require 'underscore'
React = require 'react'
ReactDOM = require 'react-dom'
classNames = require 'classnames'
classnames = require 'classnames'
{MultiselectList,
FocusContainer,
@ -18,6 +18,7 @@ classNames = require 'classnames'
WorkspaceStore,
AccountStore,
CategoryStore,
ExtensionRegistry,
FocusedContentStore,
FocusedPerspectiveStore} = require 'nylas-exports'
@ -109,9 +110,15 @@ class ThreadList extends React.Component
</FluxContainer>
_threadPropsProvider: (item) ->
classes = classnames({
'unread': item.unread
})
classes += ExtensionRegistry.ThreadList.extensions()
.filter((ext) => ext.cssClassNamesForThreadListItem?)
.reduce(((prev, ext) => prev + ' ' + ext.cssClassNamesForThreadListItem(item)), ' ')
props =
className: classNames
'unread': item.unread
className: classes
props.shouldEnableSwipe = =>
perspective = FocusedPerspectiveStore.current()

View file

@ -20,7 +20,7 @@ describe "ThreadListParticipants", ->
ben = new Contact(email: 'ben@nylas.com', name: 'ben')
ben.unread = true
thread = new Thread()
thread.metadata = [new Message(from: [ben], unread:true)]
thread.__messages = [new Message(from: [ben], unread:true)]
@participants = ReactTestUtils.renderIntoDocument(
<ThreadListParticipants thread={thread}/>
@ -171,7 +171,7 @@ describe "ThreadListParticipants", ->
for scenario in scenarios
thread = new Thread()
thread.metadata = scenario.in
thread.__messages = scenario.in
participants = ReactTestUtils.renderIntoDocument(
<ThreadListParticipants thread={thread}/>
)
@ -191,9 +191,9 @@ describe "ThreadListParticipants", ->
@michael = new Contact(email: 'michael@nylas.com', name: 'michael')
@kavya = new Contact(email: 'kavya@nylas.com', name: 'kavya')
getTokens = (threadMetadata) ->
getTokens = (threadMessages) ->
thread = new Thread()
thread.metadata = threadMetadata
thread.__messages = threadMessages
participants = ReactTestUtils.renderIntoDocument(
<ThreadListParticipants thread={thread}/>
)

View file

@ -13,7 +13,6 @@ class SearchMailboxPerspective extends MailboxPerspective {
if (!_.isString(this.searchQuery)) {
throw new Error("SearchMailboxPerspective: Expected a `string` search query")
}
return this
}
emptyMessage() {

View file

@ -11,35 +11,30 @@ class SnoozeButton extends Component {
className: PropTypes.string,
threads: PropTypes.array,
direction: PropTypes.string,
renderImage: PropTypes.bool,
shouldRenderIconImg: PropTypes.bool,
getBoundingClientRect: PropTypes.func,
};
static defaultProps = {
className: 'btn btn-toolbar',
direction: 'down',
renderImage: true,
shouldRenderIconImg: true,
getBoundingClientRect: (inst) => ReactDOM.findDOMNode(inst).getBoundingClientRect(),
};
onClick = (event) => {
event.stopPropagation()
const buttonRect = this.getBoundingClientRect()
const {threads, direction, getBoundingClientRect} = this.props
const buttonRect = getBoundingClientRect(this)
Actions.openPopover(
<SnoozePopover
threads={this.props.threads}
threads={threads}
closePopover={Actions.closePopover}
/>,
{originRect: buttonRect, direction: this.props.direction}
{originRect: buttonRect, direction: direction}
)
};
getBoundingClientRect = () => {
if (this.props.getBoundingClientRect) {
return this.props.getBoundingClientRect()
}
return ReactDOM.findDOMNode(this).getBoundingClientRect()
};
render() {
if (!FocusedPerspectiveStore.current().isInbox()) {
return <span />;
@ -51,11 +46,12 @@ class SnoozeButton extends Component {
className={`snooze-button ${this.props.className}`}
onClick={this.onClick}
>
{this.props.renderImage ?
{this.props.shouldRenderIconImg ?
<RetinaImg
name="toolbar-snooze.png"
mode={RetinaImg.Mode.ContentIsMask}
/> : null
/> :
null
}
</button>
);
@ -89,9 +85,9 @@ export class QuickActionSnooze extends Component {
return (
<SnoozeButton
direction="left"
renderImage={false}
threads={[this.props.thread]}
className="btn action action-snooze"
shouldRenderIconImg={false}
getBoundingClientRect={this.getBoundingClientRect}
/>
);

View file

@ -117,7 +117,7 @@ class SnoozePopover extends Component {
<DateInput
className="snooze-input"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomDate}
onDateSubmitted={this.onSelectCustomDate}
/>
</div>
);

View file

@ -7,7 +7,7 @@
}
.snooze-button {
order: -104;
order: -103;
}
.snooze-popover {

View file

@ -22,8 +22,8 @@ const makeInput = (props = {}) => {
describe('DateInput', function dateInput() {
describe('onInputKeyDown', () => {
it('should submit the input if Enter or Escape pressed', () => {
const onSubmitDate = jasmine.createSpy('onSubmitDate')
const component = makeInput({onSubmitDate: onSubmitDate})
const onDateSubmitted = jasmine.createSpy('onDateSubmitted')
const component = makeInput({onDateSubmitted: onDateSubmitted})
const inputNode = ReactDOM.findDOMNode(component).querySelector('input')
const stopPropagation = jasmine.createSpy('stopPropagation')
const keys = ['Enter', 'Return']
@ -34,10 +34,10 @@ describe('DateInput', function dateInput() {
keys.forEach((key) => {
Simulate.keyDown(inputNode, {key, stopPropagation})
expect(stopPropagation).toHaveBeenCalled()
expect(onSubmitDate).toHaveBeenCalledWith('someday', 'tomorrow')
expect(onDateSubmitted).toHaveBeenCalledWith('someday', 'tomorrow')
expect(component.setState).toHaveBeenCalledWith({inputDate: null})
stopPropagation.reset()
onSubmitDate.reset()
onDateSubmitted.reset()
component.setState.reset()
})
});

View file

@ -0,0 +1,81 @@
import React from 'react';
import {mount} from 'enzyme'
import {DateUtils} from 'nylas-exports'
import {DatePickerPopover} from 'nylas-component-kit'
const makePopover = (props = {}) => {
return mount(
<DatePickerPopover
dateOptions={{}}
header={<span className="header">my header</span>}
onSelectDate={() => {}}
{...props}
/>
);
};
describe('DatePickerPopover', function sendLaterPopover() {
beforeEach(() => {
spyOn(DateUtils, 'format').andReturn('formatted')
});
describe('selectDate', () => {
it('calls props.onSelectDate', () => {
const onSelectDate = jasmine.createSpy('onSelectDate')
const popover = makePopover({onSelectDate})
popover.instance().selectDate({utc: () => 'utc'}, 'Custom')
expect(onSelectDate).toHaveBeenCalledWith('formatted', 'Custom')
});
});
describe('onSelectMenuOption', () => {
});
describe('onSelectCustomOption', () => {
it('selects date', () => {
const popover = makePopover()
const instance = popover.instance()
spyOn(instance, 'selectDate')
instance.onSelectCustomOption('date', 'abc')
expect(instance.selectDate).toHaveBeenCalledWith('date', 'Custom')
});
it('throws error if date is invalid', () => {
spyOn(NylasEnv, 'showErrorDialog')
const popover = makePopover()
popover.instance().onSelectCustomOption(null, 'abc')
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
});
});
describe('render', () => {
it('renders the provided dateOptions', () => {
const popover = makePopover({
dateOptions: {
'label 1-': () => {},
'label 2-': () => {},
},
})
const items = popover.find('.item')
expect(items.at(0).text()).toEqual('label 1-formatted')
expect(items.at(1).text()).toEqual('label 2-formatted')
});
it('renders header components', () => {
const popover = makePopover()
expect(popover.find('.header').text()).toEqual('my header')
})
it('renders footer components', () => {
const popover = makePopover({
footer: <span key="footer" className="footer">footer</span>,
})
expect(popover.find('.footer').text()).toEqual('footer')
expect(popover.find('.date-input-section').isEmpty()).toBe(false)
});
});
});

View file

@ -10,23 +10,30 @@ class DateInput extends Component {
static propTypes = {
className: PropTypes.string,
dateFormat: PropTypes.string.isRequired,
onSubmitDate: PropTypes.func,
onDateInterpreted: PropTypes.func,
onDateSubmitted: PropTypes.func,
};
static defaultProps = {
onSubmitDate: () => {},
onDateInterpreted: () => {},
onDateSubmitted: () => {},
};
constructor(props) {
super(props)
this.unmounted = false
this._mounted = false
this.state = {
inputDate: null,
inputValue: '',
}
}
componentDidMount() {
this._mounted = true
}
componentWillUnmount() {
this.unmounted = true
this._mounted = false
}
onInputKeyDown = (event) => {
@ -35,39 +42,46 @@ class DateInput extends Component {
// This prevents onInputChange from being fired
event.stopPropagation();
const date = DateUtils.futureDateFromString(value);
this.props.onSubmitDate(date, value);
// this.props.onSubmitDate may have unmounted this component
if (!this.unmounted) {
this.setState({inputDate: null})
}
this.props.onDateSubmitted(date, value);
}
};
onInputChange = (event) => {
this.setState({inputDate: DateUtils.futureDateFromString(event.target.value)});
const {target: {value}} = event
const nextDate = DateUtils.futureDateFromString(value)
if (nextDate) {
this.props.onDateInterpreted(nextDate.clone(), value)
}
this.setState({inputDate: nextDate, inputValue: value});
};
clearInput() {
setImmediate(() => {
if (!this._mounted) { return }
this.setState({inputValue: '', inputDate: null})
})
}
render() {
let dateInterpretation;
if (this.state.inputDate) {
dateInterpretation = (
<span className="date-interpretation">
{DateUtils.format(this.state.inputDate, this.props.dateFormat)}
</span>
);
}
const {className} = this.props
const {inputDate, inputValue} = this.state
const classes = classnames({
"nylas-date-input": true,
[className]: className != null,
})
const dateInterpretation = inputDate ?
<span className="date-interpretation">
{DateUtils.format(this.state.inputDate, this.props.dateFormat)}
</span> :
<span />;
return (
<div className={classes}>
<input
tabIndex="1"
type="text"
value={inputValue}
placeholder="Or, 'next Monday at 2PM'"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}

View file

@ -0,0 +1,110 @@
import React, {Component, PropTypes} from 'react'
import {Actions, DateUtils} from 'nylas-exports'
import DateInput from './date-input'
import Menu from './menu'
const {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} = DateUtils
class DatePickerPopover extends Component {
static displayName = 'DatePickerPopover'
static propTypes = {
className: PropTypes.string,
footer: PropTypes.node,
onSelectDate: PropTypes.func,
header: PropTypes.node.isRequired,
dateOptions: PropTypes.object.isRequired,
shouldSelectDateWhenInterpreted: PropTypes.bool,
}
static defaultProps = {
shouldSelectDateWhenInterpreted: false,
}
onEscape() {
Actions.closePopover()
}
onSelectMenuOption = (optionKey) => {
const {dateOptions} = this.props
const date = dateOptions[optionKey]();
this.refs.dateInput.clearInput()
this.selectDate(date, optionKey);
};
onCustomDateInterpreted = (date) => {
const {shouldSelectDateWhenInterpreted} = this.props
if (date && shouldSelectDateWhenInterpreted) {
this.refs.menu.clearSelection()
this.selectDate(date, "Custom");
}
}
onCustomDateSelected = (date, inputValue) => {
if (date) {
this.refs.menu.clearSelection()
this.selectDate(date, "Custom");
} else {
NylasEnv.showErrorDialog(`Sorry, we can't interpret ${inputValue} as a valid date.`);
}
};
selectDate = (date, dateLabel) => {
const formatted = DateUtils.format(date.utc());
this.props.onSelectDate(formatted, dateLabel);
};
renderMenuOption = (optionKey) => {
const {dateOptions} = this.props
const date = dateOptions[optionKey]();
const formatted = DateUtils.format(date, DATE_FORMAT_SHORT);
return (
<div className="date-picker-popover-option">
{optionKey}
<span className="time">{formatted}</span>
</div>
);
}
render() {
const {className, header, footer, dateOptions} = this.props
let footerComponents = [
<div key="divider" className="divider" />,
<DateInput
ref="dateInput"
key="custom-section"
className="section date-input-section"
dateFormat={DATE_FORMAT_LONG}
onDateSubmitted={this.onCustomDateSelected}
onDateInterpreted={this.onCustomDateInterpreted}
/>,
]
if (footer) {
if (Array.isArray(footer)) {
footerComponents = footerComponents.concat(footer)
} else {
footerComponents = footerComponents.concat([footer])
}
}
return (
<div className={`date-picker-popover ${className}`}>
<Menu
ref="menu"
items={Object.keys(dateOptions)}
itemKey={item => item}
itemContent={this.renderMenuOption}
defaultSelectedIndex={-1}
headerComponents={header}
footerComponents={footerComponents}
onEscape={this.onEscape}
onSelect={this.onSelectMenuOption}
/>
</div>
);
}
}
export default DatePickerPopover

View file

@ -6,10 +6,10 @@ function ListensToObservable(ComposedComponent, {getObservable, getStateFromObse
static containerRequired = ComposedComponent.containerRequired;
constructor() {
super()
this.state = getStateFromObservable()
this.observable = getObservable()
constructor(props) {
super(props)
this.state = getStateFromObservable(null, {props})
this.observable = getObservable(props)
}
componentDidMount() {
@ -24,7 +24,7 @@ function ListensToObservable(ComposedComponent, {getObservable, getStateFromObse
onObservableChanged = (data) => {
if (this.unmounted) return;
this.setState(getStateFromObservable(data))
this.setState(getStateFromObservable(data, {props: this.props}))
};
render() {

View file

@ -15,6 +15,7 @@ export default class MailLabelSet extends React.Component {
static propTypes = {
thread: React.PropTypes.object.isRequired,
messages: React.PropTypes.array,
includeCurrentCategories: React.PropTypes.bool,
removable: React.PropTypes.bool,
};
@ -28,7 +29,7 @@ export default class MailLabelSet extends React.Component {
}
render() {
const {thread, includeCurrentCategories} = this.props;
const {thread, messages, includeCurrentCategories} = this.props;
const labels = [];
if (AccountStore.accountForId(thread.accountId).usesLabels()) {
@ -73,7 +74,7 @@ export default class MailLabelSet extends React.Component {
containersRequired={false}
matching={{role: "Thread:MailLabel"}}
className="thread-injected-mail-labels"
exposedProps={{thread: thread}}
exposedProps={{thread, messages}}
>
{labels}
</InjectedComponentSet>

View file

@ -12,17 +12,20 @@ MenuItem's props allow you to display dividers as well as standard items.
Section: Component Kit
###
class MenuItem extends React.Component
@displayName = 'MenuItem'
###
Public: React `props` supported by MenuItem:
- `index`: {Number} of the index of the current menu item
- `divider` (optional) Pass a {Boolean} to render the menu item as a section divider.
- `key` (optional) Pass a {String} to be the React key to optimize rendering lists of items.
- `selected` (optional) Pass a {Boolean} to specify whether the item is selected.
- `checked` (optional) Pass a {Boolean} to specify whether the item is checked.
###
@propTypes:
index: React.PropTypes.number.isRequired
divider: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.bool])
selected: React.PropTypes.bool
checked: React.PropTypes.bool
@ -140,8 +143,8 @@ class Menu extends React.Component
###
@propTypes:
className: React.PropTypes.string,
footerComponents: React.PropTypes.arrayOf(React.PropTypes.element),
headerComponents: React.PropTypes.arrayOf(React.PropTypes.element),
footerComponents: React.PropTypes.node,
headerComponents: React.PropTypes.node,
itemContext: React.PropTypes.object,
itemContent: React.PropTypes.func.isRequired,
itemKey: React.PropTypes.func.isRequired,
@ -159,6 +162,7 @@ class Menu extends React.Component
onEscape: ->
constructor: (@props) ->
@_mounted = false
@state =
selectedIndex: @props.defaultSelectedIndex ? 0
@ -167,6 +171,18 @@ class Menu extends React.Component
getSelectedItem: =>
@props.items[@state.selectedIndex]
# TODO this is a hack, refactor
clearSelection: =>
setImmediate(=>
return if @_mounted is false
@setState({selectedIndex: -1})
)
componentDidMount: =>
@_mounted = true
componentWillUnmount: =>
@_mounted = false
componentWillReceiveProps: (newProps) =>
# Attempt to preserve selection across props.items changes by
@ -194,12 +210,11 @@ class Menu extends React.Component
container.scrollTop += adjustment
render: =>
hc = @props.headerComponents ? []
if hc.length is 0 then hc = <span></span>
fc = @props.footerComponents ? []
if fc.length is 0 then fc = <span></span>
hc = @props.headerComponents ? <span />
fc = @props.footerComponents ? <span />
className = if @props.className then @props.className else ''
<div onKeyDown={@_onKeyDown}
className={"menu " + @props.className}
className={"menu #{className}"}
tabIndex="-1">
<div className="header-container">
{hc}
@ -236,7 +251,9 @@ class Menu extends React.Component
onMouseDown = (event) =>
event.preventDefault()
@props.onSelect(item) if @props.onSelect
@setState({selectedIndex: i}, =>
@props.onSelect(item) if @props.onSelect
)
key = @props.itemKey(item)
if not key
@ -246,6 +263,7 @@ class Menu extends React.Component
seenItemKeys[key] = item
<MenuItem
index={i}
key={key}
onMouseDown={onMouseDown}
checked={@props.itemChecked?(item)}

View file

@ -83,6 +83,8 @@ const DateUtils = {
// Localized format: ddd, MMM D, YYYY h:mmA
DATE_FORMAT_LONG: 'llll',
DATE_FORMAT_LONG_NO_YEAR: moment.localeData().longDateFormat('llll').replace(yearRegex, ''),
// Localized format: MMM D, h:mmA
DATE_FORMAT_SHORT: moment.localeData().longDateFormat('lll').replace(yearRegex, ''),
@ -106,6 +108,10 @@ const DateUtils = {
return now.add(minutes, 'minutes');
},
hoursFromNow(hours, now = moment()) {
return now.add(hours, 'hours');
},
in1Hour() {
return DateUtils.minutesFromNow(60);
},
@ -137,10 +143,18 @@ const DateUtils = {
return morning(now.day(Days.ThisWeekend(now.day())))
},
weeksFromNow(weeks, now = moment()) {
return now.add(weeks, 'weeks');
},
nextWeek(now = moment()) {
return morning(now.day(Days.NextMonday(now.day())))
},
monthsFromNow(months, now = moment()) {
return now.add(months, 'months');
},
nextMonth(now = moment()) {
return morning(now.add(1, 'month').date(1))
},

View file

@ -54,3 +54,7 @@ Registry.include(Listener);
export const Composer = new Registry('Composer');
export const MessageView = new Registry('MessageView');
export const ThreadList = new Registry('ThreadList');
export const AccountSidebar = new Registry('AccountSidebar');

View file

@ -0,0 +1,17 @@
class AccountSidebarExtension {
/**
* @param accountIds
* @return {
* id,
* name,
* iconName,
* perspective: {MailboxPerspective},
* }
*/
static sidebarItem() {}
}
export default AccountSidebarExtension

View file

@ -0,0 +1,10 @@
class ThreadListExtension {
static cssClassNamesForThreadListItem() {}
static cssClassNamesForThreadListIcon() {}
}
export default ThreadListExtension

View file

@ -394,4 +394,12 @@ Message(date DESC) WHERE draft = 1`,
const re = /(?:<signature>.*<\/signature>)|(?:<.+?>)|\s/gmi;
return this.body.replace(re, "").length === 0;
}
isHidden() {
return (
this.to.length === 1 && this.from.length === 1 &&
this.to[0].email === this.from[0].email &&
(this.snippet || "").startsWith('Nylas N1 Reminder:')
)
}
}

View file

@ -125,14 +125,14 @@ class MessageStore extends NylasStore
itemIndex = _.findIndex @_items, (msg) -> msg.id is item.id or msg.clientId is item.clientId
if change.type is 'persist' and itemIndex is -1
@_items = [].concat(@_items, [item])
@_items = [].concat(@_items, [item]).filter((m) => !m.isHidden())
@_items = @_sortItemsForDisplay(@_items)
@_expandItemsToDefault()
@trigger()
return
if change.type is 'unpersist' and itemIndex isnt -1
@_items = [].concat(@_items)
@_items = [].concat(@_items).filter((m) => !m.isHidden())
@_items.splice(itemIndex, 1)
@_expandItemsToDefault()
@trigger()
@ -250,7 +250,8 @@ class MessageStore extends NylasStore
loaded = true
@_items = @_sortItemsForDisplay(items)
@_items = items.filter((m) => !m.isHidden())
@_items = @_sortItemsForDisplay(@_items)
# If no items were returned, attempt to load messages via the API. If items
# are returned, this will trigger a refresh here.

View file

@ -8,6 +8,15 @@ class NylasComponentKit
get: ->
NylasComponentKit.default(require "../components/#{path}")
# We load immediately when the component won't be loaded until the user
# performs an action. For example, opening a popover. In this case, the
# popover would take a long time to open the first time the user tries to open
# the popover
@loadImmediately = (prop, path) ->
exported = NylasComponentKit.default(require "../components/#{path}")
Object.defineProperty @prototype, prop,
get: -> exported
@loadFrom = (prop, path) ->
Object.defineProperty @prototype, prop,
get: ->
@ -28,6 +37,7 @@ class NylasComponentKit
@load "Switch", 'switch'
@loadDeprecated "Popover", 'popover', instead: 'Actions.openPopover'
@load "FixedPopover", 'fixed-popover'
@loadImmediately "DatePickerPopover", 'date-picker-popover'
@load "Modal", 'modal'
@load "Flexbox", 'flexbox'
@load "RetinaImg", 'retina-img'

@ -1 +1 @@
Subproject commit ad9ff8f680f72bdbe2f30a613215a95f515eb891
Subproject commit 701ccb72d1bc9b7d9f0a31e829be2fc3fe52a964

View file

@ -1,6 +1,8 @@
@import "ui-variables";
.nylas-date-input {
text-align: center;
.date-interpretation {
color: @text-color-subtle;
font-size: @font-size-small;

View file

@ -0,0 +1,17 @@
@import "ui-variables";
.date-picker-popover {
.menu .item {
.time {
display: none;
float: right;
padding-right: @padding-base-horizontal;
}
&.selected,
&:hover {
.time {
display: inline-block;
}
}
}
}

View file

@ -25,6 +25,7 @@
.menu {
z-index:1;
position: relative;
width: 250px;
.content-container {
background: none;
}
@ -50,6 +51,14 @@
}
}
.section {
padding: @padding-base-vertical * 1.5 @padding-base-horizontal;
}
.divider {
border-top: 1px solid @border-color-divider;
}
input[type=text] {
border: 1px solid darken(@background-secondary, 10%);
border-radius: 3px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -30,6 +30,7 @@
@import "components/editable-list";
@import "components/outline-view";
@import "components/fixed-popover";
@import "components/date-picker-popover";
@import "components/modal";
@import "components/date-input";
@import "components/nylas-calendar";