mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 23:36:21 +08:00
RSVP to attached calendar events and view RSVP responses from a message
This commit is contained in:
parent
31479d25cc
commit
11e2c404f1
|
@ -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,
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
57
app/src/calendar-utils.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
7
app/src/global/mailspring-exports.d.ts
vendored
7
app/src/global/mailspring-exports.d.ts
vendored
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Reference in a new issue