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
1
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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} </div>
|
||||
<div className="snippet">{getSnippet(thread)} </div>
|
||||
<div style={flex: 1, flexShrink: 1}></div>
|
||||
<MailLabelSet thread={thread} />
|
||||
</div>
|
||||
|
|
|
@ -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), {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}/>
|
||||
)
|
||||
|
|
|
@ -13,7 +13,6 @@ class SearchMailboxPerspective extends MailboxPerspective {
|
|||
if (!_.isString(this.searchQuery)) {
|
||||
throw new Error("SearchMailboxPerspective: Expected a `string` search query")
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
emptyMessage() {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -117,7 +117,7 @@ class SnoozePopover extends Component {
|
|||
<DateInput
|
||||
className="snooze-input"
|
||||
dateFormat={DATE_FORMAT_LONG}
|
||||
onSubmitDate={this.onSelectCustomDate}
|
||||
onDateSubmitted={this.onSelectCustomDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
.snooze-button {
|
||||
order: -104;
|
||||
order: -103;
|
||||
}
|
||||
|
||||
.snooze-popover {
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
});
|
||||
|
|
81
spec/components/date-picker-popover-spec.jsx
Normal 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)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
110
src/components/date-picker-popover.jsx
Normal 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
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
|
|
17
src/extensions/account-sidebar-extension.es6
Normal file
|
@ -0,0 +1,17 @@
|
|||
|
||||
class AccountSidebarExtension {
|
||||
|
||||
/**
|
||||
* @param accountIds
|
||||
* @return {
|
||||
* id,
|
||||
* name,
|
||||
* iconName,
|
||||
* perspective: {MailboxPerspective},
|
||||
* }
|
||||
*/
|
||||
static sidebarItem() {}
|
||||
|
||||
}
|
||||
|
||||
export default AccountSidebarExtension
|
10
src/extensions/thread-list-extension.es6
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
class ThreadListExtension {
|
||||
|
||||
static cssClassNamesForThreadListItem() {}
|
||||
|
||||
static cssClassNamesForThreadListIcon() {}
|
||||
|
||||
}
|
||||
|
||||
export default ThreadListExtension
|
|
@ -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:')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
2
src/pro
|
@ -1 +1 @@
|
|||
Subproject commit ad9ff8f680f72bdbe2f30a613215a95f515eb891
|
||||
Subproject commit 701ccb72d1bc9b7d9f0a31e829be2fc3fe52a964
|
|
@ -1,6 +1,8 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.nylas-date-input {
|
||||
text-align: center;
|
||||
|
||||
.date-interpretation {
|
||||
color: @text-color-subtle;
|
||||
font-size: @font-size-small;
|
||||
|
|
17
static/components/date-picker-popover.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
BIN
static/images/composer/icon-composer-reminders@1x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/composer/icon-composer-reminders@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/empty-state/ic-emptystate-reminders@1x.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
static/images/empty-state/ic-emptystate-reminders@2x.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
static/images/source-list/reminders@1x.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
static/images/source-list/reminders@2x.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
static/images/thread-list/ic-timestamp-reminder@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/thread-list/ic-timestamp-snooze@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/thread-list/icon-reminder@1x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/thread-list/icon-reminder@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/images/thread-list/in-label-bell@1x.png
Normal file
After Width: | Height: | Size: 245 KiB |
BIN
static/images/thread-list/in-label-bell@2x.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/images/toolbar/ic-toolbar-native-reminder@1x.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/images/toolbar/ic-toolbar-native-reminder@2x.png
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -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";
|
||||
|
|