diff --git a/app/internal_packages/events/lib/event-header.tsx b/app/internal_packages/events/lib/event-header.tsx index ce3359969..9d553c622 100644 --- a/app/internal_packages/events/lib/event-header.tsx +++ b/app/internal_packages/events/lib/event-header.tsx @@ -1,139 +1,239 @@ import { RetinaImg } from 'mailspring-component-kit'; + import React from 'react'; +import fs from 'fs'; import { + Rx, Actions, + AttachmentStore, + File, localized, - PropTypes, DateUtils, + CalendarUtils, + ICSParticipantStatus, Message, Event, EventRSVPTask, DatabaseStore, } from 'mailspring-exports'; +import ICAL from 'ical.js'; + const moment = require('moment-timezone'); -class EventHeader extends React.Component< - { - message: Message; - }, - { event: Event } -> { +interface EventHeaderProps { + message: Message; + file: File; +} + +interface EventHeaderState { + icsOriginalData?: string; + icsMethod?: 'reply' | 'request'; + icsEvent?: ICAL.Event; + inflight?: ICSParticipantStatus; +} + +/* +The EventHeader allows you to RSVP to a calendar invite embedded in an email. It also +looks to see if a matching event is present on your calendar. In most cases the event +will also be on your calendar, and that version is synced while the email attachment +version gets stale. + +We try to show the RSVP status of the event on your calendar if it's present. If not, +we fall back to storing the RSVP status in message metadata (so the "Accept" button is +"sticky", even though we just fire off a RSVP message via email and never hear back.) +*/ +export class EventHeader extends React.Component { static displayName = 'EventHeader'; - static propTypes = { message: PropTypes.instanceOf(Message).isRequired }; + state = { + icsEvent: undefined, + icsMethod: undefined, + icsOriginalData: undefined, + inflight: undefined, + }; - _unlisten: () => void; + _mounted: boolean = false; + _subscription: Rx.IDisposable; - constructor(props) { - super(props); - this.state = { event: this.props.message.events[0] }; - } - - _onChange() { - if (!this.state.event) { - return; + componentWillUnmount() { + this._mounted = false; + if (this._subscription) { + this._subscription.dispose(); } - DatabaseStore.find(Event, this.state.event.id).then(event => { - if (!event) { - return; - } - this.setState({ event }); - }); } componentDidMount() { - // TODO: This should use observables! - this._unlisten = DatabaseStore.listen(change => { - if (this.state.event && change.objectClass === Event.name) { - const updated = change.objects.find(o => o.id === this.state.event.id); - if (updated) { - this.setState({ event: updated }); - } - } + const { file, message } = this.props; + this._mounted = true; + + fs.readFile(AttachmentStore.pathForFile(file), async (err, data) => { + if (err || !this._mounted) return; + + const icsData = ICAL.parse(data.toString()); + const icsRoot = new ICAL.Component(icsData); + const icsEvent = new ICAL.Event(icsRoot.getFirstSubcomponent('vevent')); + + this.setState({ + icsEvent: icsEvent, + icsMethod: (icsRoot.getFirstPropertyValue('method') || 'request').toLowerCase(), + icsOriginalData: data.toString(), + }); + + this._subscription = Rx.Observable.fromQuery( + DatabaseStore.findBy(Event, { + icsuid: icsEvent.uid, + accountId: message.accountId, + }) + ).subscribe(calEvent => { + if (!this._mounted || !calEvent) return; + this.setState({ + icsEvent: CalendarUtils.eventFromICSString(calEvent.ics), + }); + }); }); - this._onChange(); } - componentWillReceiveProps(nextProps) { - this.setState({ event: nextProps.message.events[0] }); - this._onChange(); - } - - componentWillUnmount() { - if (this._unlisten) { - this._unlisten(); + componentDidUpdate(prevProps, prevState) { + if (prevState.inflight) { + this.setState({ inflight: undefined }); } } render() { - const timeFormat = DateUtils.getTimeFormat({ timeZone: true }); - if (this.state.event != null) { - return ( -
-
- - {localized('Event')}: - {this.state.event.title} -
-
-
-
- {moment(this.state.event.start * 1000) - .tz(DateUtils.timeZone) - .format(localized('dddd, MMMM Do'))} -
-
-
- {moment(this.state.event.start * 1000) - .tz(DateUtils.timeZone) - .format(timeFormat)} -
- {this._renderEventActions()} -
-
-
-
- ); + const { icsEvent, icsMethod } = this.state; + if (!icsEvent || !icsEvent.startDate) { + return null; + } + + const startMoment = moment(icsEvent.startDate.toJSDate()).tz(DateUtils.timeZone); + const endMoment = moment(icsEvent.endDate.toJSDate()).tz(DateUtils.timeZone); + + const daySeconds = 24 * 60 * 60 * 1000; + let day = ''; + let time = ''; + + if (endMoment.diff(startMoment) < daySeconds) { + day = startMoment.format('dddd, MMMM Do'); + time = `${startMoment.format( + DateUtils.getTimeFormat({ timeZone: false }) + )} - ${endMoment.format(DateUtils.getTimeFormat({ timeZone: true }))}`; } else { - return
; + day = `${startMoment.format('dddd, MMMM Do')} - ${endMoment.format('MMMM Do')}`; + if (endMoment.diff(startMoment) % daySeconds === 0) { + time = localized('All Day'); + } else { + time = startMoment.format(DateUtils.getTimeFormat({ timeZone: true })); + } } - } - - _renderEventActions() { - const me = this.state.event.participantForMe(); - if (!me) { - return false; - } - - const actions = [ - ['yes', localized('Accept')], - ['maybe', localized('Maybe')], - ['no', localized('Decline')], - ]; return ( -
- {actions.map(([status, label]) => { - let classes = 'btn-rsvp '; - if (me.status === status) { - classes += status; - } - return ( -
this._rsvp(status)}> - {label} +
+
+ + {localized('Event')}: + {icsEvent.summary} +
+
+ {icsMethod === 'request' ? this._renderRSVP() : this._renderSenderResponse()} +
+
{day}
+
+
{time}
- ); - })} +
{icsEvent.location}
+
+
); } - _rsvp = status => { - const me = this.state.event.participantForMe(); - Actions.queueTask(new EventRSVPTask(this.state.event, me.email, status)); + _renderSenderResponse() { + const { icsEvent } = this.state; + + const from = this.props.message.from[0].email; + const sender = CalendarUtils.cleanParticipants(icsEvent).find(p => p.email === from); + if (!sender) return false; + + const verb: { [key: string]: string } = { + DECLINED: localized('declined'), + ACCEPTED: localized('accepted'), + TENTATIVE: localized('tentatively accepted'), + DELEGATED: localized('delegated'), + COMPLETED: localized('completed'), + }[sender.status]; + + return
{localized(`%1$@ has %2$@ this event`, from, verb)}
; + } + + _renderRSVP() { + const { icsEvent, inflight } = this.state; + const me = CalendarUtils.selfParticipant(icsEvent, this.props.message.accountId); + if (!me) return false; + + let status = me.status; + + const icsTimeProperty = icsEvent.component.getFirstPropertyValue('dtstamp'); + const icsTime = icsTimeProperty ? icsTimeProperty.toJSDate() : new Date(0); + + const metadata = this.props.message.metadataForPluginId('event-rsvp'); + if (metadata && new Date(metadata.time) > icsTime) { + status = metadata.status; + } + + const actions: [ICSParticipantStatus, string][] = [ + ['ACCEPTED', localized('Accept')], + ['TENTATIVE', localized('Maybe')], + ['DECLINED', localized('Decline')], + ]; + + return ( +
+ {actions.map(([actionStatus, actionLabel]) => ( +
this._onRSVP(actionStatus)} + > + {actionStatus === status || actionStatus !== inflight ? ( + actionLabel + ) : ( + + )} +
+ ))} +
+ ); + } + + _onRSVP = (status: ICSParticipantStatus) => { + const { icsEvent, icsOriginalData, inflight } = this.state; + if (inflight) return; // prevent double clicks + + const organizerEmail = CalendarUtils.emailFromParticipantURI(icsEvent.organizer); + if (!organizerEmail) { + AppEnv.showErrorDialog( + localized( + "Sorry, this event does not have an organizer or the organizer's address is not a valid email address: {}", + icsEvent.organizer + ) + ); + } + + this.setState({ inflight: status }); + + Actions.queueTask( + EventRSVPTask.forReplying({ + accountId: this.props.message.accountId, + messageId: this.props.message.id, + icsOriginalData, + icsRSVPStatus: status, + to: organizerEmail, + }) + ); }; } diff --git a/app/internal_packages/events/lib/main.tsx b/app/internal_packages/events/lib/main.tsx index 1be17a7f9..cc8413e02 100644 --- a/app/internal_packages/events/lib/main.tsx +++ b/app/internal_packages/events/lib/main.tsx @@ -1,10 +1,48 @@ -import { ComponentRegistry } from 'mailspring-exports';; -import EventHeader from './event-header'; +import * as React from 'react'; +import { + Message, + File, + MessageViewExtension, + ExtensionRegistry, + ComponentRegistry, +} from 'mailspring-exports'; +import { EventHeader } from './event-header'; + +function bestICSAttachment(files: File[]) { + return ( + files.find(f => f.filename.endsWith('.ics')) || + files.find(f => f.contentType === 'text/calendar') || + files.find(f => f.filename.endsWith('.vcs')) + ); +} + +const EventHeaderContainer: React.FunctionComponent<{ message: Message }> = ({ message }) => { + const icsFile = bestICSAttachment(message.files); + return icsFile ? : null; +}; + +EventHeaderContainer.displayName = 'EventHeaderContainer'; + +class HideICSAttachmentExtension extends MessageViewExtension { + static filterMessageFiles({ message, files }: { message: Message; files: File[] }): File[] { + const best = bestICSAttachment(message.files); + if (!best) return files; + + // Many automatic invite emails attach the ICS file more than once using different mimetypes + // to ensure it's recognized everywhere, so we remove all the attachments with the exact + // file size and an ics content type. + return files.filter( + f => !(f.size === best.size && ['application/ics', 'text/calendar'].includes(f.contentType)) + ); + } +} export function activate() { - ComponentRegistry.register(EventHeader, { role: 'message:BodyHeader' }); + ExtensionRegistry.MessageView.register(HideICSAttachmentExtension); + ComponentRegistry.register(EventHeaderContainer, { role: 'message:BodyHeader' }); } export function deactivate() { - ComponentRegistry.unregister(EventHeader); + ExtensionRegistry.MessageView.unregister(HideICSAttachmentExtension); + ComponentRegistry.unregister(EventHeaderContainer); } diff --git a/app/internal_packages/events/styles/events.less b/app/internal_packages/events/styles/events.less index d4a28b432..e2554eb4a 100644 --- a/app/internal_packages/events/styles/events.less +++ b/app/internal_packages/events/styles/events.less @@ -54,37 +54,34 @@ } .event-actions { - display: inline-block; + display: flex; float: right; - z-index: 4; text-align: center; + position: relative; .btn-rsvp { - float: left; - padding: @spacing-three-quarters @spacing-standard * 1.75 @spacing-three-quarters - @spacing-standard * 1.75; - line-height: 10px; - color: @text-color; - border-radius: 3px; - background: @background-primary; - box-shadow: @standard-shadow; - margin: 0 7.5px 0 7.5px; + margin-left: 10.5px; + min-width: 85px; + + &:hover { + background: mix(@background-primary, @text-color, 95%); + } &:active { - background: transparent; + background: mix(@background-primary, @text-color, 92%); } - &.no { + &.DECLINED { background: @color-error; color: @white; } - &.yes { + &.ACCEPTED { background: @color-success; color: @white; } - &.maybe { + &.TENTATIVE { background: @gray-light; color: @white; } diff --git a/app/internal_packages/message-list/lib/message-item.tsx b/app/internal_packages/message-list/lib/message-item.tsx index fd5d0624f..f20ff0649 100644 --- a/app/internal_packages/message-list/lib/message-item.tsx +++ b/app/internal_packages/message-list/lib/message-item.tsx @@ -1,6 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Thread, Message, localized, Utils, Actions, AttachmentStore } from 'mailspring-exports'; +import { + Thread, + Message, + localized, + Utils, + Actions, + AttachmentStore, + MessageStore, +} from 'mailspring-exports'; import { RetinaImg, InjectedComponentSet, InjectedComponent } from 'mailspring-component-kit'; import MessageParticipants from './message-participants'; @@ -125,10 +133,18 @@ export default class MessageItem extends React.Component !f.contentId || !(body || '').includes(`cid:${f.contentId}`) ); + for (const extension of MessageStore.extensions()) { + if (!extension.filterMessageFiles) continue; + attachedFiles = extension.filterMessageFiles({ + message: this.props.message, + files: attachedFiles, + }); + } + return (
{files.length > 1 ? this._renderDownloadAllButton() : null} diff --git a/app/src/calendar-utils.ts b/app/src/calendar-utils.ts new file mode 100644 index 000000000..a019e79db --- /dev/null +++ b/app/src/calendar-utils.ts @@ -0,0 +1,57 @@ +import ICAL from 'ical.js'; +import { AccountStore } from 'mailspring-exports'; + +export type ICSParticipantStatus = + | 'NEEDS-ACTION' + | 'ACCEPTED' + | 'DECLINED' + | 'TENTATIVE' + | 'DELEGATED' + | 'COMPLETED' + | 'IN-PROCESS'; + +export interface ICSParticipant { + email: string | null; + role: 'CHAIR' | 'REQ-PARTICIPANT' | 'OPT-PARTICIPANT' | 'NON-PARTICIPANT'; + status: ICSParticipantStatus; + component: ICAL.Component; +} + +export function eventFromICSString(ics: string) { + const jcalData = ICAL.parse(ics); + const comp = new ICAL.Component(jcalData); + return new ICAL.Event(comp.name === 'vevent' ? comp : comp.getFirstSubcomponent('vevent')); +} + +export function emailFromParticipantURI(uri: string) { + if (!uri) { + return null; + } + if (uri.toLowerCase().startsWith('mailto:')) { + return uri.toLowerCase().replace('mailto:', ''); + } + return null; +} + +export function cleanParticipants(icsEvent: ICAL.Event): ICSParticipant[] { + return icsEvent.attendees.map(a => ({ + component: a, + status: a.getParameter('partstat'), + role: a.getParameter('role'), + email: a + .getValues() + .map(emailFromParticipantURI) + .find(v => !!v), + })); +} + +export function selfParticipant( + icsEvent: ICAL.Event, + accountId: string +): ICSParticipant | undefined { + const me = cleanParticipants(icsEvent).find(a => { + const acct = AccountStore.accountForEmail(a.email); + return acct && acct.id === accountId; + }); + return me; +} diff --git a/app/src/extensions/message-view-extension.ts b/app/src/extensions/message-view-extension.ts index 22693ba08..8d8ac2f61 100644 --- a/app/src/extensions/message-view-extension.ts +++ b/app/src/extensions/message-view-extension.ts @@ -1,3 +1,6 @@ +import { Message } from '../flux/models/message'; +import { File } from '../flux/models/file'; + /* Public: To create MessageViewExtension that customize message viewing, you should create objects that implement the interface defined at {MessageViewExtension}. @@ -28,7 +31,7 @@ export class MessageViewExtension { Public: Modify the body of the message provided. Note that you're provided the entire message object, but you can only change `message.body`. */ - static formatMessageBody({ message }) {} + static formatMessageBody({ message }: { message: Message }) {} /* Public: Modify the rendered message body using the DOM. @@ -36,4 +39,11 @@ export class MessageViewExtension { into the DOM. */ static renderedMessageBodyIntoDocument({ document, message, iframe }) {} + + /* + Public: Filter the list of displayed attachments. + */ + static filterMessageFiles({ message, files }: { message: Message; files: File[] }): File[] { + return files; + } } diff --git a/app/src/flux/models/event.ts b/app/src/flux/models/event.ts index dac8657ea..76c4796f4 100644 --- a/app/src/flux/models/event.ts +++ b/app/src/flux/models/event.ts @@ -20,6 +20,12 @@ export class Event extends Model { modelKey: 'ics', }), + icsuid: Attributes.String({ + queryable: true, + jsonKey: 'icsuid', + modelKey: 'icsuid', + }), + // The calculated Unix start time. See the implementation for how we // treat each type of "when" attribute. recurrenceStart: Attributes.Number({ diff --git a/app/src/flux/stores/message-store.ts b/app/src/flux/stores/message-store.ts index 3ee4be981..400c68e12 100644 --- a/app/src/flux/stores/message-store.ts +++ b/app/src/flux/stores/message-store.ts @@ -9,6 +9,7 @@ import FocusedContentStore from './focused-content-store'; import * as ExtensionRegistry from '../../registries/extension-registry'; import electron from 'electron'; import DatabaseChangeRecord from './database-change-record'; +import { MessageViewExtension } from 'mailspring-exports'; const FolderNamesHiddenByDefault = ['spam', 'trash']; @@ -78,7 +79,7 @@ class _MessageStore extends MailspringStore { */ // Public: Returns the extensions registered with the MessageStore. - extensions() { + extensions(): typeof MessageViewExtension[] { return ExtensionRegistry.MessageView.extensions(); } diff --git a/app/src/flux/tasks/event-rsvp-task.ts b/app/src/flux/tasks/event-rsvp-task.ts index 81e3d7251..432ea6f85 100644 --- a/app/src/flux/tasks/event-rsvp-task.ts +++ b/app/src/flux/tasks/event-rsvp-task.ts @@ -1,25 +1,99 @@ import { Task } from './task'; -import { Event } from '../models/event'; +import { AttributeValues } from '../models/model'; +import Attributes from '../attributes'; +import ICAL from 'ical.js'; +import { + localized, + ICSParticipantStatus, + SyncbackMetadataTask, + CalendarUtils, + DatabaseStore, + Message, + Actions, +} from 'mailspring-exports'; export class EventRSVPTask extends Task { - event: Event; - RSVPEmail: string; - RSVPResponse: string; + ics: string; + icsRSVPStatus: ICSParticipantStatus; + subject: string; + messageId: string; + organizerEmail: string; - constructor(event: Event, RSVPEmail: string, RSVPResponse: string) { - super({}); - this.event = event; - this.RSVPEmail = RSVPEmail; - this.RSVPResponse = RSVPResponse; + static attributes = Object.assign({}, Task.attributes, { + ics: Attributes.String({ + modelKey: 'ics', + }), + icsRSVPStatus: Attributes.String({ + modelKey: 'icsRSVPStatus', + }), + to: Attributes.String({ + modelKey: 'to', + }), + subject: Attributes.String({ + modelKey: 'subject', + }), + messageId: Attributes.String({ + modelKey: 'messageId', + }), + }); + + constructor(data: AttributeValues = {}) { + super(data); } - performLocal() {} + static forReplying({ + accountId, + to, + messageId, + icsOriginalData, + icsRSVPStatus, + }: { + to: string; + accountId: string; + messageId?: string; + icsOriginalData: string; + icsRSVPStatus: ICSParticipantStatus; + }) { + const jcalData = ICAL.parse(icsOriginalData); + const comp = new ICAL.Component(jcalData); + const event = new ICAL.Event(comp.getFirstSubcomponent('vevent')); + const me = CalendarUtils.selfParticipant(event, accountId); - onOtherError() { - return Promise.resolve(); + me.component.setParameter('partstat', icsRSVPStatus); + comp.updatePropertyWithValue('method', 'REPLY'); + + const icsReplyData = comp.toString(); + + return new EventRSVPTask({ + to, + subject: `${icsRSVPStatus[0].toUpperCase()}${icsRSVPStatus.substr(1).toLowerCase()}: ${ + event.summary + }`, + accountId, + messageId, + ics: icsReplyData, + icsRSVPStatus, + }); } - onTimeoutError() { - return Promise.resolve(); + label() { + return localized('Sending RSVP'); + } + + async onSuccess() { + if (this.messageId && this.icsRSVPStatus) { + const msg = await DatabaseStore.find(Message, this.messageId); + if (!msg) return; + Actions.queueTask( + SyncbackMetadataTask.forSaving({ + model: msg, + pluginId: 'event-rsvp', + value: { + status: this.icsRSVPStatus, + time: Date.now(), + }, + }) + ); + } } } diff --git a/app/src/global/mailspring-exports.d.ts b/app/src/global/mailspring-exports.d.ts index 83e188988..e88fff8d9 100644 --- a/app/src/global/mailspring-exports.d.ts +++ b/app/src/global/mailspring-exports.d.ts @@ -9,7 +9,7 @@ export const isRTL: isRTL; // Actions - export type Actions = typeof import('../flux/actions').default; + export type Actions = typeof import('../flux/actions'); export const Actions: Actions; // API Endpoints @@ -172,6 +172,11 @@ export const DOMUtils: DOMUtils; export type DateUtils = typeof import('../date-utils').default; export const DateUtils: DateUtils; + + export type CalendarUtils = typeof import('../calendar-utils'); + export const CalendarUtils: CalendarUtils; + export {ICSParticipantStatus, ICSParticipant} from '../calendar-utils' + export type FsUtils = typeof import('../fs-utils'); export const FsUtils: FsUtils; export type CanvasUtils = typeof import('../canvas-utils'); diff --git a/app/src/global/mailspring-exports.js b/app/src/global/mailspring-exports.js index 3d640a6a7..e238e3a70 100644 --- a/app/src/global/mailspring-exports.js +++ b/app/src/global/mailspring-exports.js @@ -170,6 +170,7 @@ lazyLoad(`ComponentRegistry`, 'registries/component-registry'); lazyLoad(`Utils`, 'flux/models/utils'); lazyLoad(`DOMUtils`, 'dom-utils'); lazyLoad(`DateUtils`, 'date-utils'); +lazyLoad(`CalendarUtils`, 'calendar-utils'); lazyLoad(`FsUtils`, 'fs-utils'); lazyLoad(`CanvasUtils`, 'canvas-utils'); lazyLoad(`RegExpUtils`, 'regexp-utils');