RSVP tile for events on messages

Summary: RSVP tile now appears for messages with attached events.

Test Plan: Tested manually. Will add unit tests

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D1797
This commit is contained in:
EthanBlackburn 2015-07-28 18:21:30 -07:00
parent 37c904e875
commit 6609fe272c
19 changed files with 654 additions and 6 deletions

View file

@ -60,6 +60,7 @@ Exports =
DraftCountStore: require '../src/flux/stores/draft-count-store'
DraftStoreExtension: require '../src/flux/stores/draft-store-extension'
MessageStore: require '../src/flux/stores/message-store'
EventStore: require '../src/flux/stores/event-store'
MessageStoreExtension: require '../src/flux/stores/message-store-extension'
ContactStore: require '../src/flux/stores/contact-store'
MetadataStore: require '../src/flux/stores/metadata-store'

View file

@ -0,0 +1,102 @@
_ = require 'underscore'
path = require 'path'
React = require 'react'
{RetinaImg} = require 'nylas-component-kit'
{Actions,
Utils,
ComponentRegistry,
EventStore,
NamespaceStore} = require 'nylas-exports'
moment = require 'moment-timezone'
class EventComponent extends React.Component
@displayName: 'EventComponent'
@propTypes:
event: React.PropTypes.object.isRequired
constructor: (@props) ->
@state = @_getStateFromStores()
_onChange: =>
@setState(@_getStateFromStores())
_getStateFromStores: ->
e = EventStore.getEvent(@props.event.id)
e ?= @props.event
componentWillMount: ->
@unsub = EventStore.listen(@_onChange)
componentWillUnmount: ->
@unsub()
_myStatus: =>
myEmail = NamespaceStore.current()?.me().email
for p in @state.participants
if p['email'] == myEmail
return p['status']
return null
render: =>
<div className="event-wrapper">
<div className="event-header">
<RetinaImg name="icon-RSVP-calendar-mini@2x.png"
mode={RetinaImg.Mode.ContentPreserve}/>
<span className="event-title-text">Event: </span><span className="event-title">{@state.title}</span>
</div>
<div className="event-body">
<div className="event-date">
<div className="event-day">
{moment(@state.when['start_time']*1000).tz(Utils.timeZone).format("dddd, MMMM Do")}
</div>
<div>
<div className="event-time">
{moment(@state.when['start_time']*1000).tz(Utils.timeZone).format("H:mm a z")}
</div>
{@_renderEventActions()}
</div>
</div>
</div>
</div>
_renderEventActions: =>
<div className="event-actions">
{@_renderAcceptButton()}
{@_renderMaybeButton()}
{@_renderDeclineButton()}
</div>
_renderAcceptButton: ->
classes = "btn-rsvp"
if @_myStatus() == "yes"
classes += " yes"
<div className=classes onClick={@_onClickAccept}>
Accept
</div>
_renderDeclineButton: ->
classes = "btn-rsvp"
if @_myStatus() == "no"
classes += " no"
<div className=classes onClick={@_onClickDecline}>
Decline
</div>
_renderMaybeButton: ->
classes = "btn-rsvp"
if @_myStatus() == "maybe"
classes += " maybe"
<div className=classes onClick={@_onClickMaybe}>
Maybe
</div>
_onClickAccept: => Actions.RSVPEvent(@state, "yes")
_onClickDecline: => Actions.RSVPEvent(@state, "no")
_onClickMaybe: => Actions.RSVPEvent(@state, "maybe")
module.exports = EventComponent

View file

@ -0,0 +1,13 @@
{ComponentRegistry, WorkspaceStore} = require 'nylas-exports'
module.exports =
activate: (@state={}) ->
EventComponent = require "./event-component"
ComponentRegistry.register EventComponent,
role: 'Event'
deactivate: ->
ComponentRegistry.unregister EventComponent
serialize: -> @state

View file

@ -0,0 +1,13 @@
{
"name": "events",
"version": "0.1.0",
"main": "./lib/main",
"description": "View events on message",
"license": "Proprietary",
"private": true,
"engines": {
"atom": "*"
},
"dependencies": {
}
}

View file

@ -0,0 +1,89 @@
@import "ui-variables";
@import "ui-mixins";
.event-wrapper {
cursor: default;
position: relative;
font-size: @font-size-small;
margin-top: @spacing-standard;
box-shadow: inset 0 0 1px 1px rgba(0,0,0,0.09);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
display: inline-block;
border-radius: @border-radius-base;
.event-header{
border-bottom: 1px solid lighten(@border-color-divider, 6%);
padding: 10px;
img{
margin-right: 20px;
}
.event-title-text{
color: @text-color-very-subtle;
}
.event-title{
color: @text-color;
}
}
.event-body{
padding: @padding-small-horizontal;
.event-date{
display: inline-block;
width: 100%;
.event-day{
display: block;
font-size: @font-size-large;
color: @text-color-alert;
}
.event-time{
display: inline-block;
font-size: @font-size-h3;
font-weight: @font-weight-blond;
}
}
.event-actions {
display: inline-block;
float: right;
z-index: 4;
text-align: center;
.btn-rsvp {
float: left;
padding: @spacing-three-quarters @spacing-sub-double @spacing-three-quarters @spacing-sub-double;
line-height: 10px;
color: @text-color;
border-radius: 3px;
background: @background-primary;
box-shadow: @standard-shadow;
margin: 0 7.5px 0 7.5px;
&:active {background: transparent;}
&.no{
background: @error-color;
color: @white;
}
&.yes{
background: @success-color;
color: @white;
}
&.maybe{
background: @gray-light;
color: @white;
}
}
}
}
}

View file

@ -8,6 +8,7 @@ MessageControls = require './message-controls'
{Utils,
Actions,
MessageUtils,
NamespaceStore,
MessageStore,
QuotedHTMLParser,
ComponentRegistry,
@ -79,6 +80,7 @@ class MessageItem extends React.Component
{@_formatBody()}
</EmailFrame>
<a className={@_quotedTextClasses()} onClick={@_toggleQuotedText}></a>
{@_renderEvents()}
{@_renderAttachments()}
</div>
</div>
@ -149,6 +151,13 @@ class MessageItem extends React.Component
else
<div></div>
_renderEvents: =>
events = @_eventComponents()
if events.length > 0 and Utils.looksLikeGmailInvite(@props.message)
<div className="events-area">{events}</div>
else
<div></div>
_quotedTextClasses: => classNames
"quoted-text-control": true
'no-quoted-text': not QuotedHTMLParser.hasQuotedHTML(@props.message.body)
@ -257,6 +266,16 @@ class MessageItem extends React.Component
return otherAttachments.concat(imageAttachments)
_eventComponents: =>
events = @props.message.events.map (e) =>
<InjectedComponent
className="event-wrap"
matching={role:"Event"}
exposedProps={event:e}
key={e.id}/>
return events
_isRealFile: (file) ->
hasCIDInBody = file.contentId? and @props.message.body?.indexOf(file.contentId) > 0
return not hasCIDInBody

View file

@ -22,7 +22,7 @@
@text-color-inverse: white;
@text-color-inverse-subtle: fadeout(@text-color-inverse, 20%);
@text-color-inverse-very-subtle: fadeout(@text-color-inverse, 50%);
@text-color-heading: #FFF;
@text-color-heading: #FFF;
@border-primary-bg: lighten(@background-primary, 10%);
@border-secondary-bg: lighten(@background-secondary, 10%);

View file

@ -0,0 +1,88 @@
Event = require "../../src/flux/models/event"
NamespaceStore = require "../../src/flux/stores/namespace-store"
json_event =
{
"object": "event",
"id": "4ee4xbnx7pxdb9g7c2f8ncyto",
"calendar_id": "ci0k1wfyv533ccgox4t7uri4h",
"namespace_id": "14e5bn96uizyuhidhcw5rfrb0",
"description": null,
"location": null,
"participants": [
{
"email": "example@gmail.com",
"name": "Ben Bitdiddle",
"status": "yes"
}
],
"read_only": false,
"title": "Meeting with Ben Bitdiddle",
"when": {
"object": "timespan",
"end_time": 1408123800,
"start_time": 1408120200
},
"busy": true,
"status": "confirmed",
}
when_1 =
end_time: 1408123800
start_time: 1408120200
participant_1 =
name: "Ethan Blackburn"
status: "yes"
email: "ethan@nylas.com"
participant_2 =
name: "Other Person"
status: "maybe"
email: "other@person.com"
participant_3 =
name: "Another Person"
status: "no"
email: "another@person.com"
event_1 =
title: "Dolores"
description: "Hanging at the park"
location: "Dolores Park"
when: when_1
start: 1408120200
end: 1408123800
participants: [participant_1, participant_2, participant_3]
describe "Event", ->
it "can be built via the constructor", ->
e1 = new Event event_1
expect(e1.title).toBe "Dolores"
expect(e1.description).toBe "Hanging at the park"
expect(e1.location).toBe "Dolores Park"
expect(e1.when.start_time).toBe 1408120200
expect(e1.when.end_time).toBe 1408123800
expect(e1.start).toBe 1408120200
expect(e1.end).toBe 1408123800
expect(e1.participants[0].name).toBe "Ethan Blackburn"
expect(e1.participants[0].email).toBe "ethan@nylas.com"
expect(e1.participants[0].status).toBe "yes"
expect(e1.participants[1].name).toBe "Other Person"
expect(e1.participants[1].email).toBe "other@person.com"
expect(e1.participants[1].status).toBe "maybe"
expect(e1.participants[2].name).toBe "Another Person"
expect(e1.participants[2].email).toBe "another@person.com"
expect(e1.participants[2].status).toBe "no"
it "accepts a JSON response", ->
e1 = (new Event).fromJSON(json_event)
expect(e1.title).toBe "Meeting with Ben Bitdiddle"
expect(e1.description).toBe ''
expect(e1.location).toBe ''
expect(e1.start).toBe 1408120200
expect(e1.end).toBe 1408123800
expect(e1.participants[0].name).toBe "Ben Bitdiddle"
expect(e1.participants[0].email).toBe "example@gmail.com"
expect(e1.participants[0].status).toBe "yes"

View file

@ -0,0 +1,65 @@
_ = require 'underscore'
proxyquire = require 'proxyquire'
Event = require '../../src/flux/models/event'
EventStore = require '../../src/flux/stores/event-store'
DatabaseStore = require '../../src/flux/stores/database-store'
NamespaceStore = require '../../src/flux/stores/namespace-store'
describe "EventStore", ->
beforeEach ->
atom.testOrganizationUnit = "folder"
EventStore._eventCache = {}
EventStore._namespaceId = null
EventStore._lastNamespaceId = null
NamespaceStore._current =
id: "nsid"
afterEach ->
atom.testOrganizationUnit = null
it "initializes the cache from the DB", ->
spyOn(DatabaseStore, "findAll").andCallFake -> Promise.resolve([])
EventStore.constructor()
advanceClock(30)
expect(Object.keys(EventStore._eventCache).length).toBe 0
expect(DatabaseStore.findAll).toHaveBeenCalled()
describe "when the Namespace updates from null to valid", ->
beforeEach ->
spyOn(EventStore, "_refreshCache")
NamespaceStore.trigger()
it "triggers a database fetch", ->
expect(EventStore._refreshCache.calls.length).toBe 1
describe "when the Namespace updates but the ID doesn't change", ->
it "does nothing", ->
spyOn(EventStore, "_refreshCache")
EventStore._eventCache = {1: '', 2: '', 3: ''}
EventStore._namespaceId = "nsid"
EventStore._lastNamespaceId = "nsid"
NamespaceStore._current =
id: "nsid"
NamespaceStore.trigger()
expect(EventStore._eventCache).toEqual {1: '', 2: '', 3: ''}
expect(EventStore._refreshCache).not.toHaveBeenCalled()
describe "getEvent", ->
beforeEach ->
@e1 = new Event(id: 1, title:'Test1', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e2 = new Event(id: 2, title:'Test2', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e3 = new Event(id: 3, title:'Test3', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
@e4 = new Event(id: 4, title:'Test4', start: '', end: '', location: '', participants: [{"name":"Guy", "email":"tester@nylas.com", "status":"noreply"}])
EventStore._eventCache = {}
for e in [@e1, @e2, @e3, @e4]
EventStore._eventCache[e.id] = e
it "returns event object based on id", ->
first = EventStore.getEvent(1)
expect(first.title).toBe 'Test1'
second = EventStore.getEvent(2)
expect(second.title).toBe 'Test2'
third = EventStore.getEvent(3)
expect(third.title).toBe 'Test3'
fourth = EventStore.getEvent(4)
expect(fourth.title).toBe 'Test4'

View file

@ -0,0 +1,91 @@
NylasAPI = require '../../src/flux/nylas-api'
Actions = require '../../src/flux/actions'
{APIError} = require '../../src/flux/errors'
EventRSVPTask = require '../../src/flux/tasks/event-rsvp'
DatabaseStore = require '../../src/flux/stores/database-store'
Event = require '../../src/flux/models/event'
NamespaceStore = require '../../src/flux/stores/namespace-store'
_ = require 'underscore'
describe "EventRSVPTask", ->
beforeEach ->
spyOn(DatabaseStore, 'find').andCallFake => Promise.resolve(@event)
spyOn(DatabaseStore, 'persistModel').andCallFake -> Promise.resolve()
@myId = 'nsid'
@myName = "Ben Tester"
@myEmail = "tester@nylas.com"
@event = new Event
id: '12233AEDF5'
title: 'Meeting with Ben Bitdiddle'
description: ''
location: ''
when:
end_time: 1408123800
start_time: 1408120200
start: 1408120200
end: 1408123800
participants: [
{"name": "Ben Bitdiddle",
"email": "ben@bitdiddle.com",
"status": "yes"},
{"name": @myName,
"email": @myEmail,
"status": 'noreply'}
]
@task = new EventRSVPTask(@event, "no")
describe "performLocal", ->
it "should mark our status as no", ->
@task.performLocal()
advanceClock()
expect(@event.participants[1].status).toBe "no"
it "should trigger an action to persist the change", ->
@task.performLocal()
advanceClock()
expect(DatabaseStore.persistModel).toHaveBeenCalled()
describe "performRemote", ->
it "should make the POST request to the message endpoint", ->
spyOn(NylasAPI, 'makeRequest').andCallFake => new Promise (resolve,reject) ->
@task.performRemote()
options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.path).toBe("/n/#{@myId}/send-rsvp")
expect(options.method).toBe('POST')
expect(options.body.event_id).toBe(@event.id)
expect(options.body.status).toBe("no")
describe "when the remote API request fails", ->
beforeEach ->
spyOn(NylasAPI, 'makeRequest').andCallFake -> Promise.reject(new APIError(body: '', statusCode: 400))
it "should not be marked with the status", ->
@event = new Event
id: '12233AEDF5'
title: 'Meeting with Ben Bitdiddle'
description: ''
location: ''
when:
end_time: 1408123800
start_time: 1408120200
start: 1408120200
end: 1408123800
participants: [
{"name": "Ben Bitdiddle",
"email": "ben@bitdiddle.com",
"status": "yes"},
{"name": @myName,
"email": @myEmail,
"status": 'noreply'}
]
@task = new EventRSVPTask(@event, "no")
@task.performLocal()
@task.performRemote()
advanceClock()
expect(@event.participants[1].status).toBe "noreply"
it "should trigger an action to persist the change", ->
@task.performLocal()
@task.performRemote()
advanceClock()
expect(DatabaseStore.persistModel).toHaveBeenCalled()

View file

@ -360,6 +360,13 @@ class Actions
@searchWeightsChanged: ActionScopeWindow
@searchBlurred: ActionScopeWindow
###
Public: Submits the user's response to an RSVP event.
*Scope: Window*
###
@RSVPEvent: ActionScopeWindow
###
Public: Fire to display an in-window notification to the user in the app's standard
notification interface.

View file

@ -1,18 +1,35 @@
Model = require './model'
Contact = require './contact'
Attributes = require '../attributes'
_ = require 'underscore'
class Event extends Model
@attributes: _.extend {}, Model.attributes,
'id': Attributes.String
queryable: true
modelKey: 'id'
jsonKey: 'id'
'namespaceId': Attributes.String
modelKey: 'namespaceId'
jsonKey: 'namespaceId'
'title': Attributes.String
modelKey: 'title'
jsonKey: 'title'
'description': Attributes.String
modelKey: 'description'
jsonKey: 'description'
'location': Attributes.String
modelKey: 'location'
jsonKey: 'location'
'participants': Attributes.Object
modelKey: 'participants'
jsonKey: 'participants'
'when': Attributes.Object
modelKey: 'when'

View file

@ -5,6 +5,7 @@ File = require './file'
Label = require './label'
Folder = require './folder'
Model = require './model'
Event = require './event'
Contact = require './contact'
Attributes = require '../attributes'
NamespaceStore = require '../stores/namespace-store'
@ -106,6 +107,10 @@ class Message extends Model
queryable: true
modelKey: 'unread'
'events': Attributes.Collection
modelKey: 'events'
itemClass: Event
'starred': Attributes.Boolean
queryable: true
modelKey: 'starred'
@ -164,6 +169,7 @@ class Message extends Model
@bcc ||= []
@replyTo ||= []
@files ||= []
@events ||= []
@
toJSON: (options) ->

View file

@ -65,6 +65,12 @@ Utils =
return ext in extensions and size > 512 and size < 1024*1024*10
looksLikeGmailInvite: (message={}) ->
if 'PRODID:-//Google Inc//Google Calendar' in message.body
return true
# Escapes potentially dangerous html characters
# This code is lifted from Angular.js
# See their specs here:
@ -114,6 +120,7 @@ Utils =
DestroyDraftTask = require '../tasks/destroy-draft'
FileUploadTask = require '../tasks/file-upload-task'
EventRSVP = require '../tasks/event-rsvp'
ChangeLabelsTask = require '../tasks/change-labels-task'
ChangeFolderTask = require '../tasks/change-folder-task'
MarkMessageReadTask = require '../tasks/mark-message-read'
@ -144,6 +151,7 @@ Utils =
'SyncbackDraftTask': SyncbackDraftTask
'DestroyDraftTask': DestroyDraftTask
'FileUploadTask': FileUploadTask
'EventRSVP': EventRSVP
}
Utils._modelClassMap

View file

@ -2,6 +2,7 @@ Reflux = require 'reflux'
Actions = require '../actions'
Contact = require '../models/contact'
Utils = require '../models/utils'
NylasStore = require 'nylas-store'
RegExpUtils = require '../../regexp-utils'
DatabaseStore = require './database-store'
NamespaceStore = require './namespace-store'
@ -30,11 +31,7 @@ _onContactsChanged: ->
Section: Stores
###
class ContactStore
@include: CoffeeHelpers.includeModule
@include Publisher
@include Listener
class ContactStore extends NylasStore
constructor: ->
@_contactCache = []

View file

@ -0,0 +1,82 @@
Reflux = require 'reflux'
Actions = require '../actions'
Event = require '../models/event'
Utils = require '../models/utils'
NylasStore = require 'nylas-store'
DatabaseStore = require './database-store'
NamespaceStore = require './namespace-store'
_ = require 'underscore'
EventRSVP = require '../tasks/event-rsvp'
{Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers'
###
Public: EventStore maintains
## Listening for Changes
The EventStore monitors the {DatabaseStore} for changes to {Event} models
and triggers when events have changed, allowing your stores and components
to refresh data based on the EventStore.
```coffee
@unsubscribe = EventStore.listen(@_onEventsChanged, @)
_onEventsChanged: ->
# refresh your event results
```
Section: Stores
###
class EventStore extends NylasStore
constructor: ->
@_eventCache = {}
@_namespaceId = null
@listenTo DatabaseStore, @_onDatabaseChanged
@listenTo NamespaceStore, @_onNamespaceChanged
# From Views
@listenTo Actions.RSVPEvent, @_onRSVPEvent
@_refreshCache()
_onRSVPEvent: (calendar_event, RSVPStatus) ->
task = new EventRSVP(calendar_event, RSVPStatus)
Actions.queueTask(task)
__refreshCache: =>
new Promise (resolve, reject) =>
DatabaseStore.findAll(Event)
.then (events=[]) =>
@_eventCache[e.id] = e for e in events
@trigger()
resolve()
.catch (err) ->
console.warn("Request for Events failed. #{err}")
_refreshCache: _.debounce(EventStore::__refreshCache, 20)
_onDatabaseChanged: (change) =>
return unless change?.objectClass is Event.name
for e in change.objects
@_eventCache[e.id] = e
_resetCache: =>
@_eventCache = {}
@trigger(@)
getEvent: (id) =>
@_eventCache[id]
_onNamespaceChanged: =>
return if @_namespaceId is NamespaceStore.current()?.id
@_namespaceId = NamespaceStore.current()?.id
if @_namespaceId
@_refreshCache()
else
@_resetCache()
module.exports = new EventStore()

View file

@ -0,0 +1,49 @@
Task = require './task'
Event = require '../models/event'
{APIError} = require '../errors'
Utils = require '../models/utils'
DatabaseStore = require '../stores/database-store'
NamespaceStore = require '../stores/namespace-store'
Actions = require '../actions'
NylasAPI = require '../nylas-api'
module.exports =
class EventRSVPTask extends Task
constructor: (calendar_event, @RSVPResponse) ->
@myEmail = NamespaceStore.current()?.me().email.toLowerCase().trim()
@event = calendar_event
super
performLocal: ->
DatabaseStore.find(Event, @event.id).then (e) =>
e ?= @event
@_previousParticipantsState = Utils.deepClone(e.participants)
participants = []
for p in e.participants
if p['email'] == @myEmail
p['status'] = @RSVPResponse
participants.push p
e.participants = participants
@event = e
DatabaseStore.persistModel(e)
performRemote: ->
new Promise (resolve, reject) =>
NylasAPI.makeRequest
path: "/n/#{NamespaceStore.current()?.id}/send-rsvp"
method: "POST"
body: {
event_id: @event.id,
status: @RSVPResponse
}
returnsModel: true
.then =>
return Promise.resolve(Task.Status.Finished)
.catch APIError, (err) =>
##TODO: event already accepted/declined/etc
@event.participants = @_previousParticipantsState
DatabaseStore.persistModel(@event).then(resolve).catch(reject)
onOtherError: -> Promise.resolve()
onTimeoutError: -> Promise.resolve()
onOfflineError: -> Promise.resolve()

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

View file

@ -69,6 +69,7 @@
@text-color-inverse: @white;
@text-color-inverse-subtle: fadeout(@text-color-inverse, 20%);
@text-color-inverse-very-subtle: fadeout(@text-color-inverse, 50%);
@text-color-alert: #e64d65;
@text-color-heading: @nylas-gray;