diff --git a/packages/client-app/internal_packages/events/lib/event-header.cjsx b/packages/client-app/internal_packages/events/lib/event-header.cjsx index f94b4246f..8c1f7fdef 100644 --- a/packages/client-app/internal_packages/events/lib/event-header.cjsx +++ b/packages/client-app/internal_packages/events/lib/event-header.cjsx @@ -1,7 +1,9 @@ + _ = require 'underscore' path = require 'path' React = require 'react' {RetinaImg} = require 'nylas-component-kit' +icsParser = require './ics-parser' {Actions, DateUtils, Message, @@ -19,47 +21,61 @@ class EventHeader extends React.Component message: React.PropTypes.instanceOf(Message).isRequired constructor: (@props) -> + if this.props.message.events.length > 12 + eventContent = icsParser.convert( @props.message.events ); + else + return null; @state = - event: @props.message.events[0] + event: eventContent + + @isEventValid = typeof eventContent == "object"; + calendarObj = @state.event.VCALENDAR[0]; + + @eventDetails = { + "title": calendarObj.VEVENT.SUMMARY, + "start_time": moment(calendarObj.VEVENT.DTSTART + calendarObj.VTIMEZONE.DAYLIGHT.TZOFFSETTO).tz(DateUtils.timeZone), + "end_time": moment(calendarObj.VEVENT.DTEND + calendarObj.VTIMEZONE.DAYLIGHT.TZOFFSETTO).tz(DateUtils.timeZone), + "location": calendarObj.VEVENT.LOCATION, + "description": calendarObj.VEVENT.DESCRIPTION, + "status": calendarObj.VEVENT.STATUS + } _onChange: => - return unless @state.event - DatabaseStore.find(Event, @state.event.id).then (event) => - return unless event - @setState({event}) + # TODO + return componentDidMount: => # TODO: This should use observables! - @_unlisten = DatabaseStore.listen (change) => - if @state.event and change.objectClass is Event.name - updated = _.find change.objects, (o) => o.id is @state.event.id - @setState({event: updated}) if updated - @_onChange() + return componentWillReceiveProps: (nextProps) => - @setState({event:nextProps.message.events[0]}) - @_onChange() + # TODO + return componentWillUnmount: => - @_unlisten?() + #TODO + return render: => timeFormat = DateUtils.getTimeFormat({timeZone: true}) - if @state.event? + if @isEventValid?
- Event: {@state.event.title} + Event: {@eventDetails.title}
- {moment(@state.event.start*1000).tz(DateUtils.timeZone).format("dddd, MMMM Do")} + {@eventDetails.start_time.format("dddd, MMMM Do")}
- {moment(@state.event.start*1000).tz(DateUtils.timeZone).format(timeFormat)} + {@eventDetails.start_time.format("h:mm a") + " - " + @eventDetails.end_time.format("h:mm a")} +
+
+ {@eventDetails.description}
{@_renderEventActions()}
@@ -70,23 +86,11 @@ class EventHeader extends React.Component
_renderEventActions: => - me = @state.event.participantForMe() - return false unless me - - actions = [["yes", "Accept"], ["maybe", "Maybe"], ["no", "Decline"]] - -
- {actions.map ([status, label]) => - classes = "btn-rsvp " - classes += status if me.status is status -
@_rsvp(status)}> - {label} -
- } -
+ # TODO Later + return _rsvp: (status) => - me = @state.event.participantForMe() - Actions.queueTask(new EventRSVPTask(@state.event, me.email, status)) + # TODO Later + return module.exports = EventHeader diff --git a/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx b/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx index 09a3735cd..027bfb2b4 100644 --- a/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx +++ b/packages/client-app/internal_packages/thread-list/lib/thread-list-columns.cjsx @@ -92,8 +92,14 @@ c3 = new ListTabular.Column flex: 4 resolver: (thread) => attachment = false + invite = false + messages = thread.__messages || [] + hasInvite = thread.hasAttachments and messages.find (m) -> Utils.showIconForInvites(m.files) + if hasInvite + invite =
+ hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files) if hasAttachments attachment =
@@ -102,6 +108,7 @@ c3 = new ListTabular.Column {subject(thread.subject)} {getSnippet(thread)} + {invite} {attachment} @@ -142,8 +149,13 @@ cNarrow = new ListTabular.Column resolver: (thread) => pencil = false attachment = false + invite = false messages = thread.__messages || [] + hasInvite = thread.hasAttachments and messages.find (m) -> Utils.showIconForInvites(m.files) + if hasInvite + invite =
+ hasAttachments = thread.hasAttachments and messages.find (m) -> Utils.showIconForAttachments(m.files) if hasAttachments attachment =
@@ -177,6 +189,7 @@ cNarrow = new ListTabular.Column {pencil} + {invite} {attachment} !f.contentId or f.size > 12 * 1024 + showIconForInvites: (files) -> + return false unless files instanceof Array + return files.find (f) -> `f.contentType == "text/calendar"`; + extractTextFromHtml: (html, {maxLength} = {}) -> if (html ? "").trim().length is 0 then return "" if maxLength and html.length > maxLength diff --git a/packages/client-app/static/images/thread-list/icon-calendar-@1x.png b/packages/client-app/static/images/thread-list/icon-calendar-@1x.png new file mode 100644 index 000000000..58011e567 Binary files /dev/null and b/packages/client-app/static/images/thread-list/icon-calendar-@1x.png differ diff --git a/packages/client-app/static/images/thread-list/icon-calendar-@2x.png b/packages/client-app/static/images/thread-list/icon-calendar-@2x.png new file mode 100644 index 000000000..cc198c18c Binary files /dev/null and b/packages/client-app/static/images/thread-list/icon-calendar-@2x.png differ diff --git a/packages/client-sync/src/local-sync-worker/sync-tasks/fetch-messages-in-folder.imap.es6 b/packages/client-sync/src/local-sync-worker/sync-tasks/fetch-messages-in-folder.imap.es6 index df3ea9ced..3e79b3fa3 100644 --- a/packages/client-sync/src/local-sync-worker/sync-tasks/fetch-messages-in-folder.imap.es6 +++ b/packages/client-sync/src/local-sync-worker/sync-tasks/fetch-messages-in-folder.imap.es6 @@ -158,7 +158,7 @@ class FetchMessagesInFolderIMAP extends SyncTask { const desired = []; const available = []; const unseen = [struct]; - const desiredTypes = new Set(['text/plain', 'text/html']); + const desiredTypes = new Set(['text/plain', 'text/html', 'text/calendar']); // MIME structures can be REALLY FREAKING COMPLICATED. To simplify // processing, we flatten the MIME structure by walking it depth-first, // throwing away all multipart headers with the exception of @@ -177,6 +177,12 @@ class FetchMessagesInFolderIMAP extends SyncTask { part.shift(); const alternativeParts = this._getDesiredMIMEParts(part); if (alternativeParts.length > 0) { + // We need the ICS body to capture the invite information + alternativeParts.forEach( function(alternativePart) { + if( alternativePart.mimeType == 'text/calendar') { + desired.push(alternativePart); + } + }); // With reference to RFC2046, we keep only the last supported part. desired.push(alternativeParts[alternativeParts.length - 1]); } diff --git a/packages/client-sync/src/models/message.js b/packages/client-sync/src/models/message.js index 012ff09e0..615869a47 100644 --- a/packages/client-sync/src/models/message.js +++ b/packages/client-sync/src/models/message.js @@ -42,6 +42,7 @@ module.exports = (sequelize, Sequelize) => { })); }, }, + events: Sequelize.TEXT, subject: Sequelize.STRING(500), snippet: Sequelize.STRING(255), date: Sequelize.DATE, diff --git a/packages/isomorphic-core/src/message-utils.es6 b/packages/isomorphic-core/src/message-utils.es6 index 4f33e1279..415c85797 100644 --- a/packages/isomorphic-core/src/message-utils.es6 +++ b/packages/isomorphic-core/src/message-utils.es6 @@ -171,7 +171,9 @@ function bodyFromParts(imapMessage, desiredParts) { // // This may seem kind of weird, but some MUAs _do_ send out whack stuff // like an HTML body followed by a plaintext footer. - if (mimeType === 'text/plain') { + if ( mimeType === 'text/calendar' ) { + + } else if (mimeType === 'text/plain') { body += htmlifyPlaintext(decoded); } else { body += decoded; @@ -184,6 +186,26 @@ function bodyFromParts(imapMessage, desiredParts) { return body; } +// Parse events from text/calendar part of the email +function parseEvents(imapMessage, desiredParts) { + let body = ''; + for (const {id, mimeType, transferEncoding, charset} of desiredParts) { + let decoded = ''; + if ((/quot(ed)?[-/]print(ed|able)?/gi).test(transferEncoding)) { + decoded = mimelib.decodeQuotedPrintable(imapMessage.parts[id], charset); + } else if ((/base64/gi).test(transferEncoding)) { + decoded = mimelib.decodeBase64(imapMessage.parts[id], charset); + } else { + decoded = encoding.convert(imapMessage.parts[id], 'utf-8', charset).toString('utf-8'); + } + + if ( mimeType === 'text/calendar' && (/base64/gi).test(transferEncoding) ) { + body += decoded; + } + } + return body; +} + // Since we only fetch the MIME structure and specific desired MIME parts from // IMAP, we unfortunately can't use an existing library like mailparser to parse // the message, and have to do fun stuff like deal with character sets and @@ -210,6 +232,7 @@ async function parseFromImap(imapMessage, desiredParts, {db, accountId, folder}) replyTo: parseContacts(parsedHeaders['reply-to']), accountId: accountId, body: bodyFromParts(imapMessage, desiredParts), + events: parseEvents(imapMessage, desiredParts), snippet: null, unread: !attributes.flags.includes('\\Seen'), starred: attributes.flags.includes('\\Flagged'), diff --git a/packages/isomorphic-core/src/migrations/20171017123924-alter-messages-add-events.js b/packages/isomorphic-core/src/migrations/20171017123924-alter-messages-add-events.js new file mode 100644 index 000000000..a346fb379 --- /dev/null +++ b/packages/isomorphic-core/src/migrations/20171017123924-alter-messages-add-events.js @@ -0,0 +1,10 @@ +/* eslint no-unused-vars: 0 */ + +module.exports = { + up: function up(queryInterface, Sequelize) { + return Sequelize.query("ALTER TABLE messages ADD events TEXT;"); + }, + down: function down(queryInterface, Sequelize) { + return true; + }, +};