RSVP to attached calendar events and view RSVP responses from a message

This commit is contained in:
Ben Gotow 2019-05-27 23:48:20 -05:00
parent 31479d25cc
commit 11e2c404f1
11 changed files with 442 additions and 137 deletions

View file

@ -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<EventHeaderProps, EventHeaderState> {
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>(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>(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 (
<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">{localized('Event')}: </span>
<span className="event-title">{this.state.event.title}</span>
</div>
<div className="event-body">
<div className="event-date">
<div className="event-day">
{moment(this.state.event.start * 1000)
.tz(DateUtils.timeZone)
.format(localized('dddd, MMMM Do'))}
</div>
<div>
<div className="event-time">
{moment(this.state.event.start * 1000)
.tz(DateUtils.timeZone)
.format(timeFormat)}
</div>
{this._renderEventActions()}
</div>
</div>
</div>
</div>
);
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 <div />;
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 (
<div className="event-actions">
{actions.map(([status, label]) => {
let classes = 'btn-rsvp ';
if (me.status === status) {
classes += status;
}
return (
<div key={status} className={classes} onClick={() => this._rsvp(status)}>
{label}
<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">{localized('Event')}: </span>
<span className="event-title">{icsEvent.summary}</span>
</div>
<div className="event-body">
{icsMethod === 'request' ? this._renderRSVP() : this._renderSenderResponse()}
<div className="event-date">
<div className="event-day">{day}</div>
<div>
<div className="event-time">{time}</div>
</div>
);
})}
<div className="event-location">{icsEvent.location}</div>
</div>
</div>
</div>
);
}
_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 <div className="event-actions">{localized(`%1$@ has %2$@ this event`, from, verb)}</div>;
}
_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 (
<div className="event-actions">
{actions.map(([actionStatus, actionLabel]) => (
<div
key={actionStatus}
className={`btn btn-large btn-rsvp ${status === actionStatus ? actionStatus : ''}`}
onClick={() => this._onRSVP(actionStatus)}
>
{actionStatus === status || actionStatus !== inflight ? (
actionLabel
) : (
<RetinaImg
width={18}
name="sending-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
)}
</div>
))}
</div>
);
}
_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,
})
);
};
}

View file

@ -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 ? <EventHeader key={icsFile.id} message={message} file={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);
}

View file

@ -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;
}

View file

@ -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<MessageItemProps, Messa
_renderAttachments() {
const { files = [], body, id } = this.props.message;
const { filePreviewPaths, downloads } = this.state;
const attachedFiles = files.filter(
let attachedFiles = files.filter(
f => !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 (
<div>
{files.length > 1 ? this._renderDownloadAllButton() : null}

57
app/src/calendar-utils.ts Normal file
View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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({

View file

@ -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();
}

View file

@ -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<typeof EventRSVPTask.attributes> = {}) {
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>(Message, this.messageId);
if (!msg) return;
Actions.queueTask(
SyncbackMetadataTask.forSaving({
model: msg,
pluginId: 'event-rsvp',
value: {
status: this.icsRSVPStatus,
time: Date.now(),
},
})
);
}
}
}

View file

@ -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');

View file

@ -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');