diff --git a/app/internal_packages/main-calendar/README.md b/app/internal_packages/main-calendar/README.md
new file mode 100644
index 000000000..fe0aa095c
--- /dev/null
+++ b/app/internal_packages/main-calendar/README.md
@@ -0,0 +1 @@
+# composer package
diff --git a/app/internal_packages/main-calendar/lib/calendar-wrapper.jsx b/app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
new file mode 100644
index 000000000..a643e975a
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
@@ -0,0 +1,103 @@
+import { Actions, DestroyModelTask } from 'mailspring-exports';
+import React from 'react';
+import { remote } from 'electron';
+
+import { KeyCommandsRegion } from 'mailspring-component-kit';
+import CalendarDataSource from './core/calendar-data-source';
+import CalendarEventPopover from './core/calendar-event-popover';
+import NylasCalendar from './core/nylas-calendar';
+
+export default class CalendarWrapper extends React.Component {
+ static displayName = 'CalendarWrapper';
+ static containerRequired = false;
+
+ constructor(props) {
+ super(props);
+ this._dataSource = new CalendarDataSource();
+ this.state = { selectedEvents: [] };
+ }
+
+ _openEventPopover(eventModel) {
+ const eventEl = document.getElementById(eventModel.id);
+ if (!eventEl) {
+ return;
+ }
+ const eventRect = eventEl.getBoundingClientRect();
+
+ Actions.openPopover( , {
+ originRect: eventRect,
+ direction: 'right',
+ fallbackDirection: 'left',
+ });
+ }
+
+ _onEventClick = (e, event) => {
+ let next = [].concat(this.state.selectedEvents);
+
+ if (e.shiftKey || e.metaKey) {
+ const idx = next.findIndex(({ id }) => event.id === id);
+ if (idx === -1) {
+ next.push(event);
+ } else {
+ next.splice(idx, 1);
+ }
+ } else {
+ next = [event];
+ }
+
+ this.setState({
+ selectedEvents: next,
+ });
+ };
+
+ _onEventDoubleClick = eventModel => {
+ this._openEventPopover(eventModel);
+ };
+
+ _onEventFocused = eventModel => {
+ this._openEventPopover(eventModel);
+ };
+
+ _onDeleteSelectedEvents = () => {
+ if (this.state.selectedEvents.length === 0) {
+ return;
+ }
+ const response = remote.dialog.showMessageBox(remote.getCurrentWindow(), {
+ type: 'warning',
+ buttons: ['Delete', 'Cancel'],
+ message: 'Delete or decline these events?',
+ detail: `Are you sure you want to delete or decline invitations for the selected event(s)?`,
+ });
+ if (response === 0) {
+ // response is button array index
+ for (const event of this.state.selectedEvents) {
+ const task = new DestroyModelTask({
+ id: event.id,
+ modelName: event.constructor.name,
+ endpoint: '/events',
+ accountId: event.accountId,
+ });
+ Actions.queueTask(task);
+ }
+ }
+ };
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-constants.es6 b/app/internal_packages/main-calendar/lib/core/calendar-constants.es6
new file mode 100644
index 000000000..d82021ee4
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-constants.es6
@@ -0,0 +1,4 @@
+export const DAY_VIEW = 'day';
+export const WEEK_VIEW = 'week';
+export const MONTH_VIEW = 'month';
+export const YEAR_VIEW = 'year';
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-data-source.es6 b/app/internal_packages/main-calendar/lib/core/calendar-data-source.es6
new file mode 100644
index 000000000..5eee54683
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-data-source.es6
@@ -0,0 +1,57 @@
+import Rx from 'rx-lite';
+import { Event, Matcher, DatabaseStore } from 'mailspring-exports';
+import IcalExpander from 'ical-expander';
+
+export default class CalendarDataSource {
+ buildObservable({ startTime, endTime, disabledCalendars }) {
+ const end = Event.attributes.recurrenceEnd;
+ const start = Event.attributes.recurrenceStart;
+
+ let matcher = new Matcher.Or([
+ new Matcher.And([start.lte(endTime), end.gte(startTime)]),
+ new Matcher.And([start.lte(endTime), start.gte(startTime)]),
+ new Matcher.And([end.gte(startTime), end.lte(endTime)]),
+ new Matcher.And([end.gte(endTime), start.lte(startTime)]),
+ ]);
+
+ if (disabledCalendars && disabledCalendars.length) {
+ new Matcher.And([matcher, Event.attributes.calendarId.notIn(disabledCalendars)]);
+ }
+
+ const query = DatabaseStore.findAll(Event).where(matcher);
+ this.observable = Rx.Observable.fromQuery(query).flatMapLatest(results => {
+ const events = [];
+ results.forEach(result => {
+ const icalExpander = new IcalExpander({ ics: result.ics, maxIterations: 100 });
+ const expanded = icalExpander.between(new Date(startTime * 1000), new Date(endTime * 1000));
+
+ [...expanded.events, ...expanded.occurrences].forEach((e, idx) => {
+ const start = e.startDate.toJSDate().getTime() / 1000;
+ const end = e.endDate.toJSDate().getTime() / 1000;
+ const item = e.item || e;
+ events.push({
+ start,
+ end,
+ id: `${result.id}-e${idx}`,
+ calendarId: result.calendarId,
+ title: item.summary,
+ displayTitle: item.summary,
+ description: item.description,
+ isAllDay: end - start >= 86400 - 1,
+ organizer: item.organizer ? { email: item.organizer } : null,
+ attendees: item.attendees.map(a => ({
+ ...a.jCal[1],
+ email: a.getFirstValue(),
+ })),
+ });
+ });
+ });
+ return Rx.Observable.from([{ events }]);
+ });
+ return this.observable;
+ }
+
+ subscribe(callback) {
+ return this.observable.subscribe(callback);
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-event-container.jsx b/app/internal_packages/main-calendar/lib/core/calendar-event-container.jsx
new file mode 100644
index 000000000..ba2f41d7e
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-event-container.jsx
@@ -0,0 +1,113 @@
+import moment from 'moment';
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+
+export default class CalendarEventContainer extends React.Component {
+ static displayName = 'CalendarEventContainer';
+
+ static propTypes = {
+ onCalendarMouseUp: PropTypes.func,
+ onCalendarMouseDown: PropTypes.func,
+ onCalendarMouseMove: PropTypes.func,
+ };
+
+ constructor() {
+ super();
+ this._DOMCache = {};
+ }
+
+ componentDidMount() {
+ window.addEventListener('mouseup', this._onWindowMouseUp);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('mouseup', this._onWindowMouseUp);
+ }
+
+ _onCalendarMouseUp = event => {
+ this._DOMCache = {};
+ if (!this._mouseIsDown) {
+ return;
+ }
+ this._mouseIsDown = false;
+ this._runPropsHandler('onCalendarMouseUp', event);
+ };
+
+ _onCalendarMouseDown = event => {
+ this._DOMCache = {};
+ this._mouseIsDown = true;
+ this._runPropsHandler('onCalendarMouseDown', event);
+ };
+
+ _onCalendarMouseMove = event => {
+ this._runPropsHandler('onCalendarMouseMove', event);
+ };
+
+ _runPropsHandler(name, event) {
+ const propsFn = this.props[name];
+ if (!propsFn) {
+ return;
+ }
+ const { time, x, y, width, height } = this._dataFromMouseEvent(event);
+ try {
+ propsFn({ event, time, x, y, width, height, mouseIsDown: this._mouseIsDown });
+ } catch (error) {
+ AppEnv.reportError(error);
+ }
+ }
+
+ _dataFromMouseEvent(event) {
+ let x = null;
+ let y = null;
+ let width = null;
+ let height = null;
+ let time = null;
+ if (!event.target || !event.target.closest) {
+ return { x, y, width, height, time };
+ }
+ const eventColumn = this._DOMCache.eventColumn || event.target.closest('.event-column');
+ const gridWrap =
+ this._DOMCache.gridWrap ||
+ event.target.closest('.event-grid-wrap .scroll-region-content-inner');
+ const calWrap = this._DOMCache.calWrap || event.target.closest('.calendar-area-wrap');
+ if (!gridWrap || !eventColumn) {
+ return { x, y, width, height, time };
+ }
+
+ const rect = this._DOMCache.rect || gridWrap.getBoundingClientRect();
+ const calWrapRect = this._DOMCache.calWrapRect || calWrap.getBoundingClientRect();
+
+ this._DOMCache = { rect, eventColumn, gridWrap, calWrap };
+
+ y = gridWrap.scrollTop + event.clientY - rect.top;
+ x = calWrap.scrollLeft + event.clientX - calWrapRect.left;
+ width = gridWrap.scrollWidth;
+ height = gridWrap.scrollHeight;
+ const percentDay = y / height;
+ const diff = +eventColumn.dataset.end - +eventColumn.dataset.start;
+ time = moment(diff * percentDay + +eventColumn.dataset.start);
+ return { x, y, width, height, time };
+ }
+
+ _onWindowMouseUp = event => {
+ if (ReactDOM.findDOMNode(this).contains(event.target)) {
+ return;
+ }
+ this._onCalendarMouseUp(event);
+ };
+
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx b/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx
new file mode 100644
index 000000000..5ee97d186
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx
@@ -0,0 +1,282 @@
+import moment from 'moment';
+import {
+ React,
+ PropTypes,
+ Actions,
+ DatabaseStore,
+ DateUtils,
+ SyncbackEventTask,
+} from 'mailspring-exports';
+import {
+ DatePicker,
+ RetinaImg,
+ ScrollRegion,
+ TabGroupRegion,
+ TimePicker,
+} from 'mailspring-component-kit';
+import EventAttendeesInput from './event-attendees-input';
+
+export default class CalendarEventPopover extends React.Component {
+ static propTypes = {
+ event: PropTypes.object.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+ const { description, start, end, location, attendees } = this.props.event;
+
+ this.state = { description, start, end, location };
+ this.state.title = this.props.event.displayTitle;
+ this.state.editing = false;
+ this.state.attendees = attendees || [];
+ }
+
+ componentWillReceiveProps = nextProps => {
+ const { description, start, end, location, attendees } = nextProps.event;
+ this.setState({ description, start, end, location });
+ this.setState({
+ attendees: attendees || [],
+ title: nextProps.event.displayTitle,
+ });
+ };
+
+ onEdit = () => {
+ this.setState({ editing: true });
+ };
+
+ getStartMoment = () => moment(this.state.start * 1000);
+ getEndMoment = () => moment(this.state.end * 1000);
+
+ saveEdits = () => {
+ const event = this.props.event.clone();
+ const keys = ['title', 'description', 'location', 'attendees'];
+ for (const key of keys) {
+ event[key] = this.state[key];
+ }
+
+ // TODO, this component shouldn't save the event here, we should expose an
+ // `onEditEvent` or similar callback
+ // TODO: How will this affect the event if the when object was originally
+ // a datespan, with start_date and end_date attributes?
+ event.when.start_time = this.state.start;
+ event.when.end_time = this.state.end;
+
+ DatabaseStore.inTransaction(t => {
+ this.setState({ editing: false }); // TODO: where's the best place to put this?
+ return t.persistModel(event);
+ }).then(() => {
+ const task = new SyncbackEventTask(event.id);
+ Actions.queueTask(task);
+ });
+ };
+
+ extractNotesFromDescription(node) {
+ const els = node.querySelectorAll('meta[itemprop=description]');
+ let notes = null;
+ if (els.length) {
+ notes = Array.from(els)
+ .map(el => el.content)
+ .join('\n');
+ } else {
+ notes = node.innerText;
+ }
+ while (true) {
+ const nextNotes = notes.replace('\n\n', '\n');
+ if (nextNotes === notes) {
+ break;
+ }
+ notes = nextNotes;
+ }
+ return notes;
+ }
+
+ // If on the hour, formats as "3 PM", else formats as "3:15 PM"
+ formatTime(momentTime) {
+ const min = momentTime.minutes();
+ if (min === 0) {
+ return momentTime.format('h A');
+ }
+ return momentTime.format('h:mm A');
+ }
+
+ updateAttendees = attendees => {
+ this.setState({ attendees });
+ };
+
+ updateField = (key, value) => {
+ const updates = {};
+ updates[key] = value;
+ this.setState(updates);
+ };
+
+ _onChangeDay = newTimestamp => {
+ const newDay = moment(newTimestamp);
+ const start = this.getStartMoment();
+ const end = this.getEndMoment();
+ start.year(newDay.year());
+ end.year(newDay.year());
+ start.dayOfYear(newDay.dayOfYear());
+ end.dayOfYear(newDay.dayOfYear());
+ this.setState({ start: start.unix(), end: end.unix() });
+ };
+
+ _onChangeStartTime = newTimestamp => {
+ const newStart = moment(newTimestamp);
+ let newEnd = this.getEndMoment();
+ if (newEnd.isSameOrBefore(newStart)) {
+ const leftInDay = moment(newStart)
+ .endOf('day')
+ .diff(newStart);
+ const move = Math.min(leftInDay, moment.duration(1, 'hour').asMilliseconds());
+ newEnd = moment(newStart).add(move, 'ms');
+ }
+ this.setState({ start: newStart.unix(), end: newEnd.unix() });
+ };
+
+ _onChangeEndTime = newTimestamp => {
+ const newEnd = moment(newTimestamp);
+ let newStart = this.getStartMoment();
+ if (newStart.isSameOrAfter(newEnd)) {
+ const sinceDay = moment(newEnd).diff(newEnd.startOf('day'));
+ const move = Math.min(sinceDay, moment.duration(1, 'hour').asMilliseconds());
+ newStart = moment(newEnd).subtract(move, 'ms');
+ }
+ this.setState({ end: newEnd.unix(), start: newStart.unix() });
+ };
+
+ renderTime() {
+ const startMoment = this.getStartMoment();
+ const endMoment = this.getEndMoment();
+ const date = startMoment.format('dddd, MMMM D'); // e.g. Tuesday, February 22
+ const timeRange = `${this.formatTime(startMoment)} - ${this.formatTime(endMoment)}`;
+ return (
+
+ {date}
+
+ {timeRange}
+
+ );
+ }
+
+ renderEditableTime() {
+ const startVal = this.state.start * 1000;
+ const endVal = this.state.end * 1000;
+ return (
+
+
+
+
+ to
+
+
+ {moment()
+ .tz(DateUtils.timeZone)
+ .format('z')}
+
+ on
+
+
+
+ );
+ }
+
+ renderEditable = () => {
+ const { title, description, start, end, location, attendees } = this.state;
+
+ const fragment = document.createDocumentFragment();
+ const descriptionRoot = document.createElement('root');
+ fragment.appendChild(descriptionRoot);
+ descriptionRoot.innerHTML = description;
+
+ const notes = this.extractNotesFromDescription(descriptionRoot);
+
+ return (
+
+
+
+ {
+ this.updateField('title', e.target.value);
+ }}
+ />
+
+ {
+ this.updateField('location', e.target.value);
+ }}
+ />
+ {this.renderEditableTime(start, end)}
+
+
Invitees:
+
{
+ this.updateField('attendees', val);
+ }}
+ />
+
+
+
Notes:
+
{
+ this.updateField('description', e.target.value);
+ }}
+ />
+
+ Save
+ Actions.closePopover()}>Cancel
+
+
+ );
+ };
+
+ render() {
+ if (this.state.editing) {
+ return this.renderEditable();
+ }
+ const { title, description, location, attendees } = this.state;
+
+ const fragment = document.createDocumentFragment();
+ const descriptionRoot = document.createElement('root');
+ fragment.appendChild(descriptionRoot);
+ descriptionRoot.innerHTML = description;
+
+ const notes = this.extractNotesFromDescription(descriptionRoot);
+
+ return (
+
+
+
{location}
+
{this.renderTime()}
+
+ Invitees:
+ {attendees.map((a, idx) =>
{a.cn}
)}
+
+
+
+
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-event.jsx b/app/internal_packages/main-calendar/lib/core/calendar-event.jsx
new file mode 100644
index 000000000..23977a89f
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-event.jsx
@@ -0,0 +1,126 @@
+import { React, ReactDOM, PropTypes } from 'mailspring-exports';
+import { InjectedComponentSet } from 'mailspring-component-kit';
+import { calcColor } from './calendar-helpers';
+
+export default class CalendarEvent extends React.Component {
+ static displayName = 'CalendarEvent';
+
+ static propTypes = {
+ event: PropTypes.object.isRequired,
+ order: PropTypes.number,
+ selected: PropTypes.bool,
+ scopeEnd: PropTypes.number.isRequired,
+ scopeStart: PropTypes.number.isRequired,
+ direction: PropTypes.oneOf(['horizontal', 'vertical']),
+ fixedSize: PropTypes.number,
+ focused: PropTypes.bool,
+ concurrentEvents: PropTypes.number,
+ onClick: PropTypes.func,
+ onDoubleClick: PropTypes.func,
+ onFocused: PropTypes.func,
+ };
+
+ static defaultProps = {
+ order: 1,
+ direction: 'vertical',
+ fixedSize: -1,
+ concurrentEvents: 1,
+ onClick: () => {},
+ onDoubleClick: () => {},
+ onFocused: () => {},
+ };
+
+ componentDidMount() {
+ this._scrollFocusedEventIntoView();
+ }
+
+ componentDidUpdate() {
+ this._scrollFocusedEventIntoView();
+ }
+
+ _scrollFocusedEventIntoView() {
+ const { focused } = this.props;
+ if (!focused) {
+ return;
+ }
+ const eventNode = ReactDOM.findDOMNode(this);
+ if (!eventNode) {
+ return;
+ }
+ const { event, onFocused } = this.props;
+ eventNode.scrollIntoViewIfNeeded(true);
+ onFocused(event);
+ }
+
+ _getDimensions() {
+ const scopeLen = this.props.scopeEnd - this.props.scopeStart;
+ const duration = this.props.event.end - this.props.event.start;
+
+ let top = Math.max((this.props.event.start - this.props.scopeStart) / scopeLen, 0);
+ let height = Math.min((duration - this._overflowBefore()) / scopeLen, 1);
+
+ let width = 1;
+ let left;
+ if (this.props.fixedSize === -1) {
+ width = 1 / this.props.concurrentEvents;
+ left = width * (this.props.order - 1);
+ width = `${width * 100}%`;
+ left = `${left * 100}%`;
+ } else {
+ width = this.props.fixedSize;
+ left = this.props.fixedSize * (this.props.order - 1);
+ }
+
+ top = `${top * 100}%`;
+ height = `${height * 100}%`;
+
+ return { left, width, height, top };
+ }
+
+ _getStyles() {
+ let styles = {};
+ if (this.props.direction === 'vertical') {
+ styles = this._getDimensions();
+ } else if (this.props.direction === 'horizontal') {
+ const d = this._getDimensions();
+ styles = {
+ left: d.top,
+ width: d.height,
+ height: d.width,
+ top: d.left,
+ };
+ }
+ styles.backgroundColor = calcColor(this.props.event.calendarId);
+ return styles;
+ }
+
+ _overflowBefore() {
+ return Math.max(this.props.scopeStart - this.props.event.start, 0);
+ }
+
+ render() {
+ const { direction, event, onClick, onDoubleClick, selected } = this.props;
+
+ return (
+ onClick(e, event)}
+ onDoubleClick={() => onDoubleClick(event)}
+ >
+
+ {event.displayTitle}
+
+
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-helpers.jsx b/app/internal_packages/main-calendar/lib/core/calendar-helpers.jsx
new file mode 100644
index 000000000..0a07ecb88
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-helpers.jsx
@@ -0,0 +1,10 @@
+import { Utils } from 'mailspring-exports';
+
+export function calcColor(calendarId) {
+ let bgColor = AppEnv.config.get(`calendar.colors.${calendarId}`);
+ if (!bgColor) {
+ const hue = Utils.hueForString(calendarId);
+ bgColor = `hsla(${hue}, 50%, 45%, 0.35)`;
+ }
+ return bgColor;
+}
diff --git a/app/internal_packages/main-calendar/lib/core/calendar-toggles.jsx b/app/internal_packages/main-calendar/lib/core/calendar-toggles.jsx
new file mode 100644
index 000000000..d270906f5
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/calendar-toggles.jsx
@@ -0,0 +1,69 @@
+/* eslint jsx-a11y/label-has-for: 0 */
+import _ from 'underscore';
+import classnames from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { calcColor } from './calendar-helpers';
+
+const DISABLED_CALENDARS = 'nylas.disabledCalendars';
+
+function renderCalendarToggles(calendars, disabledCalendars) {
+ return calendars.map(calendar => {
+ const calendarId = calendar.id;
+ const onClick = () => {
+ const cals = AppEnv.config.get(DISABLED_CALENDARS) || [];
+ if (cals.includes(calendarId)) {
+ cals.splice(cals.indexOf(calendarId), 1);
+ } else {
+ cals.push(calendarId);
+ }
+ AppEnv.config.set(DISABLED_CALENDARS, cals);
+ };
+
+ const checked = !disabledCalendars.includes(calendar.id);
+ const checkboxClass = classnames({
+ 'colored-checkbox': true,
+ checked: checked,
+ });
+ const bgColor = checked ? calcColor(calendar.id) : 'transparent';
+ return (
+
+ );
+ });
+}
+
+export default function CalendarToggles(props) {
+ const calsByAccountId = _.groupBy(props.calendars, 'accountId');
+ const accountSections = [];
+ for (const accountId of Object.keys(calsByAccountId)) {
+ const calendars = calsByAccountId[accountId];
+ const account = props.accounts.find(a => a.id === accountId);
+ if (!account || !calendars || calendars.length === 0) {
+ continue;
+ }
+ accountSections.push(
+
+
{account.label}
+ {renderCalendarToggles(calendars, props.disabledCalendars)}
+
+ );
+ }
+ return {accountSections}
;
+}
+
+CalendarToggles.propTypes = {
+ accounts: PropTypes.array,
+ calendars: PropTypes.array,
+ disabledCalendars: PropTypes.array,
+};
diff --git a/app/internal_packages/main-calendar/lib/core/current-time-indicator.jsx b/app/internal_packages/main-calendar/lib/core/current-time-indicator.jsx
new file mode 100644
index 000000000..985b577c9
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/current-time-indicator.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import Moment from 'moment';
+import classNames from 'classnames';
+
+export default class CurrentTimeIndicator extends React.Component {
+ static propTypes = {
+ gridHeight: PropTypes.number,
+ numColumns: PropTypes.number,
+ todayColumnIdx: PropTypes.number,
+ visible: PropTypes.bool,
+ };
+
+ constructor(props) {
+ super(props);
+ this._movementTimer = null;
+ this.state = this.getStateFromTime();
+ }
+
+ componentDidMount() {
+ // update our displayed time once a minute
+ this._movementTimer = setInterval(() => {
+ this.setState(this.getStateFromTime());
+ }, 60 * 1000);
+ ReactDOM.findDOMNode(this).scrollIntoViewIfNeeded(true);
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this._movementTimer);
+ this._movementTimer = null;
+ }
+
+ getStateFromTime() {
+ const now = Moment();
+ return {
+ msecIntoDay:
+ now.millisecond() + (now.second() + (now.minute() + now.hour() * 60) * 60) * 1000,
+ };
+ }
+
+ render() {
+ const { gridHeight, numColumns, todayColumnIdx, visible } = this.props;
+ const msecsPerDay = 24 * 60 * 60 * 1000;
+ const { msecIntoDay } = this.state;
+
+ const todayMarker =
+ todayColumnIdx !== -1 ? (
+
+ ) : null;
+
+ return (
+
+ {todayMarker}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx b/app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx
new file mode 100644
index 000000000..aa0cb994b
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx
@@ -0,0 +1,180 @@
+import _ from 'underscore';
+import { remote, clipboard } from 'electron';
+import { React, PropTypes, Utils, Contact, ContactStore, RegExpUtils } from 'mailspring-exports';
+import { TokenizingTextField, Menu, InjectedComponentSet } from 'mailspring-component-kit';
+
+const TokenRenderer = props => {
+ const { email, cn } = props.token;
+ let chipText = email;
+ if (cn && cn.length > 0 && cn !== email) {
+ chipText = cn;
+ }
+ return (
+
+
+ {chipText}
+
+ );
+};
+
+TokenRenderer.propTypes = {
+ token: PropTypes.object,
+};
+
+export default class EventAttendeesInput extends React.Component {
+ static displayName = 'EventAttendeesInput';
+
+ static propTypes = {
+ attendees: PropTypes.array.isRequired,
+ change: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ onEmptied: PropTypes.func,
+ onFocus: PropTypes.func,
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ // Public. Can be called by any component that has a ref to this one to
+ // focus the input field.
+ focus = () => {
+ this.refs.textField.focus();
+ };
+
+ _completionNode = p => {
+ return ;
+ };
+
+ _tokensForString = (string, options = {}) => {
+ // If the input is a string, parse out email addresses and build
+ // an array of contact objects. For each email address wrapped in
+ // parentheses, look for a preceding name, if one exists.
+ if (string.length === 0) {
+ return Promise.resolve([]);
+ }
+
+ return ContactStore.parseContactsInString(string, options).then(contacts => {
+ if (contacts.length > 0) {
+ return Promise.resolve(contacts);
+ }
+ // If no contacts are returned, treat the entire string as a single
+ // (malformed) contact object.
+ return [new Contact({ email: string, name: null })];
+ });
+ };
+
+ _remove = values => {
+ const updates = _.reject(
+ this.props.attendees,
+ p => values.includes(p.email) || values.map(o => o.email).includes(p.email)
+ );
+ this.props.change(updates);
+ };
+
+ _edit = (token, replacementString) => {
+ const tokenIndex = this.props.attendees.indexOf(token);
+
+ this._tokensForString(replacementString).then(replacements => {
+ const updates = this.props.attendees.slice(0);
+ updates.splice(tokenIndex, 1, ...replacements);
+ this.props.change(updates);
+ });
+ };
+
+ _add = (values, options = {}) => {
+ // If the input is a string, parse out email addresses and build
+ // an array of contact objects. For each email address wrapped in
+ // parentheses, look for a preceding name, if one exists.
+ let tokensPromise = null;
+ if (typeof values === 'string') {
+ tokensPromise = this._tokensForString(values, options);
+ } else {
+ tokensPromise = Promise.resolve(values);
+ }
+
+ tokensPromise.then(tokens => {
+ // Safety check: remove anything from the incoming tokens that isn't
+ // a Contact. We should never receive anything else in the tokens array.
+ const contactTokens = tokens.filter(value => value instanceof Contact);
+ let updates = this.props.attendees.slice(0);
+
+ for (const token of contactTokens) {
+ // add the participant to field. _.union ensures that the token will
+ // only appear once, in case it already exists in the attendees.
+ updates = _.union(updates, [token]);
+ }
+
+ this.props.change(updates);
+ });
+ };
+
+ _onShowContextMenu = participant => {
+ // Warning: Menu is already initialized as Menu.cjsx!
+ const MenuClass = remote.Menu;
+ const MenuItem = remote.MenuItem;
+
+ const menu = new MenuClass();
+ menu.append(
+ new MenuItem({
+ label: `Copy ${participant.email}`,
+ click: () => clipboard.writeText(participant.email),
+ })
+ );
+ menu.append(
+ new MenuItem({
+ type: 'separator',
+ })
+ );
+ menu.append(
+ new MenuItem({
+ label: 'Remove',
+ click: () => this._remove([participant]),
+ })
+ );
+ menu.popup(remote.getCurrentWindow());
+ };
+
+ _onInputTrySubmit = (inputValue, completions = [], selectedItem) => {
+ if (RegExpUtils.emailRegex().test(inputValue)) {
+ return inputValue; // no token default to raw value.
+ }
+ return selectedItem || completions[0]; // first completion if any
+ };
+
+ _shouldBreakOnKeydown = event => {
+ const val = event.target.value.trim();
+ if (RegExpUtils.emailRegex().test(val) && event.key === ' ') {
+ return true;
+ }
+ return [',', ';'].includes(event.key);
+ };
+
+ render() {
+ return (
+ p.email}
+ tokenIsValid={p => ContactStore.isValidContact(p)}
+ tokenRenderer={TokenRenderer}
+ onRequestCompletions={input => ContactStore.searchContacts(input)}
+ shouldBreakOnKeydown={this._shouldBreakOnKeydown}
+ onInputTrySubmit={this._onInputTrySubmit}
+ completionNode={this._completionNode}
+ onAdd={this._add}
+ onRemove={this._remove}
+ onEdit={this._edit}
+ onEmptied={this.props.onEmptied}
+ onFocus={this.props.onFocus}
+ onTokenAction={this._onShowContextMenu}
+ />
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/event-grid-background.jsx b/app/internal_packages/main-calendar/lib/core/event-grid-background.jsx
new file mode 100644
index 000000000..3561b17c0
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/event-grid-background.jsx
@@ -0,0 +1,87 @@
+import { React, ReactDOM, PropTypes, Utils } from 'mailspring-exports';
+
+export default class EventGridBackground extends React.Component {
+ static displayName = 'EventGridBackground';
+
+ static propTypes = {
+ height: PropTypes.number,
+ numColumns: PropTypes.number,
+ tickGenerator: PropTypes.func,
+ intervalHeight: PropTypes.number,
+ };
+
+ constructor() {
+ super();
+ this._lastHoverRect = {};
+ }
+
+ componentDidMount() {
+ this._renderEventGridBackground();
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ componentDidUpdate() {
+ this._renderEventGridBackground();
+ }
+
+ _renderEventGridBackground() {
+ const canvas = ReactDOM.findDOMNode(this.refs.canvas);
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const height = this.props.height;
+ canvas.height = height;
+
+ const doStroke = (type, strokeStyle) => {
+ ctx.strokeStyle = strokeStyle;
+ ctx.beginPath();
+ for (const { yPos } of this.props.tickGenerator({ type })) {
+ ctx.moveTo(0, yPos);
+ ctx.lineTo(canvas.width, yPos);
+ }
+ ctx.stroke();
+ };
+
+ doStroke('minor', '#f1f1f1'); // Minor Ticks
+ doStroke('major', '#e0e0e0'); // Major ticks
+ }
+
+ mouseMove({ x, y, width }) {
+ if (!width || x == null || y == null) {
+ return;
+ }
+ const lr = this._lastHoverRect;
+ const xInt = width / this.props.numColumns;
+ const yInt = this.props.intervalHeight;
+ const r = {
+ x: Math.floor(x / xInt) * xInt + 1,
+ y: Math.floor(y / yInt) * yInt + 1,
+ width: xInt - 2,
+ height: yInt - 2,
+ };
+ if (lr.x === r.x && lr.y === r.y && lr.width === r.width) {
+ return;
+ }
+ this._lastHoverRect = r;
+ const cursor = ReactDOM.findDOMNode(this.refs.cursor);
+ cursor.style.left = `${r.x}px`;
+ cursor.style.top = `${r.y}px`;
+ cursor.style.width = `${r.width}px`;
+ cursor.style.height = `${r.height}px`;
+ }
+
+ render() {
+ const styles = {
+ width: '100%',
+ height: this.props.height,
+ };
+ return (
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/event-search-bar.jsx b/app/internal_packages/main-calendar/lib/core/event-search-bar.jsx
new file mode 100644
index 000000000..5dd36d1e7
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/event-search-bar.jsx
@@ -0,0 +1,88 @@
+import React, { Component } from 'react';
+import { Event, DatabaseStore } from 'mailspring-exports';
+import { SearchBar } from 'mailspring-component-kit';
+import PropTypes from 'prop-types';
+
+class EventSearchBar extends Component {
+ static displayName = 'EventSearchBar';
+
+ static propTypes = {
+ disabledCalendars: PropTypes.array,
+ onSelectEvent: PropTypes.func,
+ };
+
+ static defaultProps = {
+ disabledCalendars: [],
+ onSelectEvent: () => {},
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ query: '',
+ suggestions: [],
+ };
+ }
+
+ onSearchQueryChanged = query => {
+ const { disabledCalendars } = this.props;
+ this.setState({ query });
+ if (query.length <= 1) {
+ this.onClearSearchSuggestions();
+ return;
+ }
+ let dbQuery = DatabaseStore.findAll(Event).distinct(); // eslint-disable-line
+ if (disabledCalendars.length > 0) {
+ dbQuery = dbQuery.where(Event.attributes.calendarId.notIn(disabledCalendars));
+ }
+ dbQuery = dbQuery
+ .search(query)
+ .limit(10)
+ .then(events => {
+ this.setState({ suggestions: events });
+ });
+ };
+
+ onClearSearchQuery = () => {
+ this.setState({ query: '', suggestions: [] });
+ };
+
+ onClearSearchSuggestions = () => {
+ this.setState({ suggestions: [] });
+ };
+
+ onSelectEvent = event => {
+ this.onClearSearchQuery();
+ setImmediate(() => {
+ const { onSelectEvent } = this.props;
+ onSelectEvent(event);
+ });
+ };
+
+ renderEvent(event) {
+ return event.title;
+ }
+
+ render() {
+ const { query, suggestions } = this.state;
+
+ // TODO BG
+ return ;
+
+ return (
+ event.id}
+ suggestionRenderer={this.renderEvent}
+ onSearchQueryChanged={this.onSearchQueryChanged}
+ onSelectSuggestion={this.onSelectEvent}
+ onClearSearchQuery={this.onClearSearchQuery}
+ onClearSearchSuggestions={this.onClearSearchSuggestions}
+ />
+ );
+ }
+}
+
+export default EventSearchBar;
diff --git a/app/internal_packages/main-calendar/lib/core/footer-controls.jsx b/app/internal_packages/main-calendar/lib/core/footer-controls.jsx
new file mode 100644
index 000000000..c865b3c7e
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/footer-controls.jsx
@@ -0,0 +1,31 @@
+import { React, PropTypes, Utils } from 'mailspring-exports';
+
+export default class FooterControls extends React.Component {
+ static displayName = 'FooterControls';
+
+ static propTypes = {
+ footerComponents: PropTypes.node,
+ };
+
+ static defaultProps = {
+ footerComponents: false,
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ render() {
+ if (!this.props.footerComponents) {
+ return false;
+ }
+ return (
+
+
+
+
+ {this.props.footerComponents}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/header-controls.jsx b/app/internal_packages/main-calendar/lib/core/header-controls.jsx
new file mode 100644
index 000000000..dbd6d9438
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/header-controls.jsx
@@ -0,0 +1,56 @@
+import { React, PropTypes, Utils } from 'mailspring-exports';
+import { RetinaImg } from 'mailspring-component-kit';
+
+export default class HeaderControls extends React.Component {
+ static displayName = 'HeaderControls';
+
+ static propTypes = {
+ title: PropTypes.string,
+ headerComponents: PropTypes.node,
+ nextAction: PropTypes.func,
+ prevAction: PropTypes.func,
+ };
+
+ static defaultProps = {
+ headerComonents: false,
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ _renderNextAction() {
+ if (!this.props.nextAction) {
+ return false;
+ }
+ return (
+
+
+
+ );
+ }
+
+ _renderPrevAction() {
+ if (!this.props.prevAction) {
+ return false;
+ }
+ return (
+
+
+
+ );
+ }
+
+ render() {
+ return (
+
+
+ {this._renderPrevAction()}
+ {this.props.title}
+ {this._renderNextAction()}
+
+ {this.props.headerComponents}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/mini-month-view.jsx b/app/internal_packages/main-calendar/lib/core/mini-month-view.jsx
new file mode 100644
index 000000000..7dc953570
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/mini-month-view.jsx
@@ -0,0 +1,134 @@
+import _ from 'underscore';
+import React from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import classnames from 'classnames';
+
+export default class MiniMonthView extends React.Component {
+ static displayName = 'MiniMonthView';
+
+ static propTypes = {
+ value: PropTypes.number,
+ onChange: PropTypes.func,
+ };
+
+ static defaultProps = {
+ value: moment().valueOf(),
+ onChange: () => {},
+ };
+
+ constructor(props) {
+ super(props);
+ this.today = moment();
+ this.state = this._stateFromProps(props);
+ }
+
+ componentWillReceiveProps(newProps) {
+ this.setState(this._stateFromProps(newProps));
+ }
+
+ _stateFromProps(props) {
+ const m = props.value ? moment(props.value) : moment();
+ return {
+ shownYear: m.year(),
+ shownMonth: m.month(),
+ };
+ }
+
+ _shownMonthMoment() {
+ return moment([this.state.shownYear, this.state.shownMonth]);
+ }
+
+ _changeMonth = by => {
+ const newMonth = this.state.shownMonth + by;
+ const newMoment = this._shownMonthMoment().month(newMonth);
+ this.setState({
+ shownYear: newMoment.year(),
+ shownMonth: newMoment.month(),
+ });
+ };
+
+ _renderLegend() {
+ const weekdayGen = moment([2016]);
+ const legendEls = [];
+ for (let i = 0; i < 7; i++) {
+ const dayStr = weekdayGen.weekday(i).format('dd'); // Locale aware!
+ legendEls.push(
+
+ {dayStr}
+
+ );
+ }
+ return {legendEls}
;
+ }
+
+ _onClickDay = event => {
+ if (!event.target.dataset.timestamp) {
+ return;
+ }
+ const newVal = moment(parseInt(event.target.dataset.timestamp, 10)).valueOf();
+ this.props.onChange(newVal);
+ };
+
+ _isSameDay(m1, m2) {
+ return m1.dayOfYear() === m2.dayOfYear() && m1.year() === m2.year();
+ }
+
+ _renderDays() {
+ const dayIter = this._shownMonthMoment().date(1);
+ const startWeek = dayIter.week();
+ const curMonth = this.state.shownMonth;
+ const endWeek = moment(dayIter)
+ .date(dayIter.daysInMonth())
+ .week();
+ const weekEls = [];
+ const valDay = moment(this.props.value);
+ for (let week = startWeek; week <= endWeek; week++) {
+ dayIter.week(week); // Locale aware!
+ const dayEls = [];
+ for (let weekday = 0; weekday < 7; weekday++) {
+ dayIter.weekday(weekday); // Locale aware!
+ const dayStr = dayIter.format('D');
+ const className = classnames({
+ day: true,
+ today: this._isSameDay(dayIter, this.today),
+ 'cur-day': this._isSameDay(dayIter, valDay),
+ 'cur-month': dayIter.month() === curMonth,
+ });
+ dayEls.push(
+
+ {dayStr}
+
+ );
+ }
+ weekEls.push(
+
+ {dayEls}
+
+ );
+ }
+ return (
+
+ {weekEls}
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
+ ‹
+
+
{this._shownMonthMoment().format('MMMM YYYY')}
+
+ ›
+
+
+ {this._renderLegend()}
+ {this._renderDays()}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/month-view.jsx b/app/internal_packages/main-calendar/lib/core/month-view.jsx
new file mode 100644
index 000000000..49b6d3db1
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/month-view.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class MonthView extends React.Component {
+ static displayName = 'MonthView';
+
+ static propTypes = {
+ changeView: PropTypes.func,
+ };
+
+ _onClick = () => {
+ this.props.changeView('WeekView');
+ };
+
+ render() {
+ return Change to week ;
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx b/app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx
new file mode 100644
index 000000000..cc1765ff6
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx
@@ -0,0 +1,206 @@
+import moment from 'moment';
+import { Rx, React, PropTypes, DatabaseStore, AccountStore, Calendar } from 'mailspring-exports';
+import { ScrollRegion, ResizableRegion } from 'mailspring-component-kit';
+import WeekView from './week-view';
+import MonthView from './month-view';
+import EventSearchBar from './event-search-bar';
+import CalendarToggles from './calendar-toggles';
+import CalendarDataSource from './calendar-data-source';
+import { WEEK_VIEW, MONTH_VIEW } from './calendar-constants';
+import MiniMonthView from './mini-month-view';
+
+const DISABLED_CALENDARS = 'nylas.disabledCalendars';
+
+/*
+ * Nylas Calendar
+ */
+export default class NylasCalendar extends React.Component {
+ static displayName = 'NylasCalendar';
+
+ static propTypes = {
+ /*
+ * The data source that powers all of the views of the NylasCalendar
+ */
+ dataSource: PropTypes.instanceOf(CalendarDataSource).isRequired,
+
+ currentMoment: PropTypes.instanceOf(moment),
+
+ /*
+ * Any extra info you want to display on the top banner of calendar
+ * components
+ */
+ bannerComponents: PropTypes.shape({
+ day: PropTypes.node,
+ week: PropTypes.node,
+ month: PropTypes.node,
+ year: PropTypes.node,
+ }),
+
+ /*
+ * Any extra header components for each of the supported View types of
+ * the NylasCalendar
+ */
+ headerComponents: PropTypes.shape({
+ day: PropTypes.node,
+ week: PropTypes.node,
+ month: PropTypes.node,
+ year: PropTypes.node,
+ }),
+
+ /*
+ * Any extra footer components for each of the supported View types of
+ * the NylasCalendar
+ */
+ footerComponents: PropTypes.shape({
+ day: PropTypes.node,
+ week: PropTypes.node,
+ month: PropTypes.node,
+ year: PropTypes.node,
+ }),
+
+ /*
+ * The following are a set of supported interaction handlers.
+ *
+ * These are passed a custom set of arguments in a single object that
+ * includes the `currentView` as well as things like the `time` at the
+ * click coordinate.
+ */
+ onCalendarMouseUp: PropTypes.func,
+ onCalendarMouseDown: PropTypes.func,
+ onCalendarMouseMove: PropTypes.func,
+
+ onEventClick: PropTypes.func,
+ onEventDoubleClick: PropTypes.func,
+ onEventFocused: PropTypes.func,
+
+ selectedEvents: PropTypes.arrayOf(PropTypes.object),
+ };
+
+ static defaultProps = {
+ bannerComponents: { day: false, week: false, month: false, year: false },
+ headerComponents: { day: false, week: false, month: false, year: false },
+ footerComponents: { day: false, week: false, month: false, year: false },
+ selectedEvents: [],
+ };
+
+ static containerStyles = {
+ height: '100%',
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ calendars: [],
+ focusedEvent: null,
+ currentView: WEEK_VIEW,
+ currentMoment: props.currentMoment || this._now(),
+ disabledCalendars: AppEnv.config.get(DISABLED_CALENDARS) || [],
+ };
+ }
+
+ componentWillMount() {
+ this._disposable = this._subscribeToCalendars();
+ }
+
+ componentWillUnmount() {
+ this._disposable.dispose();
+ }
+
+ _subscribeToCalendars() {
+ const calQuery = DatabaseStore.findAll(Calendar);
+ const calQueryObs = Rx.Observable.fromQuery(calQuery);
+ const accQueryObs = Rx.Observable.fromStore(AccountStore);
+ const configObs = Rx.Observable.fromConfig(DISABLED_CALENDARS);
+ return Rx.Observable.combineLatest([calQueryObs, accQueryObs, configObs]).subscribe(
+ ([calendars, accountStore, disabledCalendars]) => {
+ this.setState({
+ accounts: accountStore.accounts() || [],
+ calendars: calendars || [],
+ disabledCalendars: disabledCalendars || [],
+ });
+ }
+ );
+ }
+
+ _now() {
+ return moment();
+ }
+
+ _getCurrentViewComponent() {
+ const components = {};
+ components[WEEK_VIEW] = WeekView;
+ components[MONTH_VIEW] = MonthView;
+ return components[this.state.currentView];
+ }
+
+ _changeCurrentView = currentView => {
+ this.setState({ currentView });
+ };
+
+ _changeCurrentMoment = currentMoment => {
+ this.setState({ currentMoment, focusedEvent: null });
+ };
+
+ _changeCurrentMomentFromValue = value => {
+ this.setState({ currentMoment: moment(value), focusedEvent: null });
+ };
+
+ _focusEvent = event => {
+ const value = event.start * 1000;
+ this.setState({ currentMoment: moment(value), focusedEvent: event });
+ };
+
+ render() {
+ const CurrentView = this._getCurrentViewComponent();
+ return (
+
+ );
+ }
+}
+
+NylasCalendar.WeekView = WeekView;
diff --git a/app/internal_packages/main-calendar/lib/core/top-banner.jsx b/app/internal_packages/main-calendar/lib/core/top-banner.jsx
new file mode 100644
index 000000000..a87465c5d
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/top-banner.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TopBanner extends React.Component {
+ static displayName = 'TopBanner';
+
+ static propTypes = {
+ bannerComponents: PropTypes.node,
+ };
+
+ render() {
+ if (!this.props.bannerComponents) {
+ return false;
+ }
+ return {this.props.bannerComponents}
;
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/week-view-all-day-events.jsx b/app/internal_packages/main-calendar/lib/core/week-view-all-day-events.jsx
new file mode 100644
index 000000000..f1e9a78a5
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/week-view-all-day-events.jsx
@@ -0,0 +1,48 @@
+import { React, PropTypes, Utils } from 'mailspring-exports';
+import CalendarEvent from './calendar-event';
+
+/*
+ * Displays the all day events across the top bar of the week event view.
+ *
+ * Putting this in its own component dramatically improves performance so
+ * we can use `shouldComponentUpdate` to selectively re-render these
+ * events.
+ */
+export default class WeekViewAllDayEvents extends React.Component {
+ static displayName = 'WeekViewAllDayEvents';
+
+ static propTypes = {
+ end: PropTypes.number,
+ start: PropTypes.number,
+ height: PropTypes.number,
+ minorDim: PropTypes.number,
+ allDayEvents: PropTypes.array,
+ allDayOverlap: PropTypes.object,
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ render() {
+ const eventComponents = this.props.allDayEvents.map(e => {
+ return (
+
+ );
+ });
+ return (
+
+ {eventComponents}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/week-view-event-column.jsx b/app/internal_packages/main-calendar/lib/core/week-view-event-column.jsx
new file mode 100644
index 000000000..e74eaac0a
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/week-view-event-column.jsx
@@ -0,0 +1,81 @@
+import moment from 'moment';
+import classnames from 'classnames';
+import { React, PropTypes, Utils } from 'mailspring-exports';
+import CalendarEvent from './calendar-event';
+
+/*
+ * This display a single column of events in the Week View.
+ * Putting it in its own component dramatically improves render
+ * performance since we can run `shouldComponentUpdate` on a
+ * column-by-column basis.
+ */
+export default class WeekViewEventColumn extends React.Component {
+ static displayName = 'WeekViewEventColumn';
+
+ static propTypes = {
+ events: PropTypes.array.isRequired,
+ day: PropTypes.instanceOf(moment),
+ dayEnd: PropTypes.number,
+ focusedEvent: PropTypes.object,
+ eventOverlap: PropTypes.object,
+ onEventClick: PropTypes.func,
+ onEventDoubleClick: PropTypes.func,
+ onEventFocused: PropTypes.func,
+ selectedEvents: PropTypes.arrayOf(PropTypes.object),
+ };
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ renderEvents() {
+ const {
+ events,
+ focusedEvent,
+ selectedEvents,
+ eventOverlap,
+ dayEnd,
+ day,
+ onEventClick,
+ onEventDoubleClick,
+ onEventFocused,
+ } = this.props;
+ return events.map(e => (
+
+ ));
+ }
+
+ render() {
+ const className = classnames({
+ 'event-column': true,
+ weekend: this.props.day.day() === 0 || this.props.day.day() === 6,
+ });
+ const end = moment(this.props.day)
+ .add(1, 'day')
+ .subtract(1, 'millisecond')
+ .valueOf();
+ return (
+
+ {this.renderEvents()}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/core/week-view.jsx b/app/internal_packages/main-calendar/lib/core/week-view.jsx
new file mode 100644
index 000000000..ff825fd64
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/core/week-view.jsx
@@ -0,0 +1,540 @@
+/* eslint react/jsx-no-bind: 0 */
+import _ from 'underscore';
+import moment from 'moment-timezone';
+import classnames from 'classnames';
+import { React, ReactDOM, PropTypes, Utils } from 'mailspring-exports';
+import { ScrollRegion } from 'mailspring-component-kit';
+import TopBanner from './top-banner';
+import HeaderControls from './header-controls';
+import FooterControls from './footer-controls';
+import CalendarDataSource from './calendar-data-source';
+import EventGridBackground from './event-grid-background';
+import WeekViewEventColumn from './week-view-event-column';
+import WeekViewAllDayEvents from './week-view-all-day-events';
+import CalendarEventContainer from './calendar-event-container';
+import CurrentTimeIndicator from './current-time-indicator';
+
+const BUFFER_DAYS = 7; // in each direction
+const DAYS_IN_VIEW = 7;
+const MIN_INTERVAL_HEIGHT = 21;
+const DAY_DUR = moment.duration(1, 'day').as('seconds');
+const INTERVAL_TIME = moment.duration(30, 'minutes').as('seconds');
+
+// This pre-fetches from Utils to prevent constant disc access
+const overlapsBounds = Utils.overlapsBounds;
+
+export default class WeekView extends React.Component {
+ static displayName = 'WeekView';
+
+ static propTypes = {
+ dataSource: PropTypes.instanceOf(CalendarDataSource).isRequired,
+ currentMoment: PropTypes.instanceOf(moment).isRequired,
+ focusedEvent: PropTypes.object,
+ bannerComponents: PropTypes.node,
+ headerComponents: PropTypes.node,
+ footerComponents: PropTypes.node,
+ disabledCalendars: PropTypes.array,
+ changeCurrentView: PropTypes.func,
+ changeCurrentMoment: PropTypes.func,
+ onCalendarMouseUp: PropTypes.func,
+ onCalendarMouseDown: PropTypes.func,
+ onCalendarMouseMove: PropTypes.func,
+ onEventClick: PropTypes.func,
+ onEventDoubleClick: PropTypes.func,
+ onEventFocused: PropTypes.func,
+ selectedEvents: PropTypes.arrayOf(PropTypes.object),
+ };
+
+ static defaultProps = {
+ changeCurrentView: () => {},
+ bannerComponents: false,
+ headerComponents: false,
+ footerComponents: false,
+ };
+
+ _waitingForShift = 0;
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ events: [],
+ intervalHeight: MIN_INTERVAL_HEIGHT,
+ };
+ }
+
+ componentDidMount() {
+ this._mounted = true;
+ this._centerScrollRegion();
+ this._setIntervalHeight();
+ window.addEventListener('resize', this._setIntervalHeight, true);
+ const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap);
+ wrap.scrollLeft += wrap.clientWidth;
+ this.updateSubscription();
+ }
+
+ componentDidUpdate(prevProps) {
+ this._setIntervalHeight();
+ const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap);
+ wrap.scrollLeft += this._waitingForShift;
+ this._waitingForShift = 0;
+ if (
+ prevProps.currentMoment !== this.props.currentMoment ||
+ prevProps.disabledCalendars !== this.props.disabledCalendars
+ ) {
+ this.updateSubscription();
+ }
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ this._sub.dispose();
+ window.removeEventListener('resize', this._setIntervalHeight);
+ }
+
+ // Indirection for testing purposes
+ _now() {
+ return moment();
+ }
+
+ updateSubscription() {
+ if (this._sub) {
+ this._sub.dispose();
+ }
+
+ const { start, end } = this._calculateMomentRange();
+
+ this._sub = this.props.dataSource
+ .buildObservable({
+ disabledCalendars: this.props.disabledCalendars,
+ startTime: start.unix(),
+ endTime: end.unix(),
+ })
+ .subscribe(state => {
+ this.setState(state);
+ });
+ }
+
+ _calculateMomentRange() {
+ const { currentMoment } = this.props;
+ let start;
+
+ // NOTE: Since we initialize a new time from one of the properties of
+ // the props.currentMomet, we need to check for the timezone!
+ //
+ // Other relative operations (like adding or subtracting time) are
+ // independent of a timezone.
+ const tz = currentMoment.tz();
+ if (tz) {
+ start = moment.tz([currentMoment.year()], tz);
+ } else {
+ start = moment([currentMoment.year()]);
+ }
+
+ start = start
+ .weekday(0)
+ .week(currentMoment.week())
+ .subtract(BUFFER_DAYS, 'days');
+
+ const end = moment(start)
+ .add(BUFFER_DAYS * 2 + DAYS_IN_VIEW, 'days')
+ .subtract(1, 'millisecond');
+
+ return { start, end };
+ }
+
+ _renderDateLabel = (day, idx) => {
+ const className = classnames({
+ 'day-label-wrap': true,
+ 'is-today': this._isToday(day),
+ });
+ return (
+
+ {day.format('D')}
+ {day.format('ddd')}
+
+ );
+ };
+
+ _isToday(day) {
+ const todayYear = this._now().year();
+ const todayDayOfYear = this._now().dayOfYear();
+
+ return todayDayOfYear === day.dayOfYear() && todayYear === day.year();
+ }
+
+ _renderEventColumn = (eventsByDay, day) => {
+ const dayUnix = day.unix();
+ const events = eventsByDay[dayUnix];
+ return (
+
+ );
+ };
+
+ _allDayEventHeight(allDayOverlap) {
+ if (_.size(allDayOverlap) === 0) {
+ return 0;
+ }
+ return this._maxConcurrentEvents(allDayOverlap) * MIN_INTERVAL_HEIGHT + 1;
+ }
+
+ /*
+ * Computes the overlap between a set of events in not O(n^2).
+ *
+ * Returns a hash keyed by event id whose value is an object:
+ * - concurrentEvents: number of concurrent events
+ * - order: the order in that series of concurrent events
+ */
+ _eventOverlap(events) {
+ const times = {};
+ for (const event of events) {
+ if (!times[event.start]) {
+ times[event.start] = [];
+ }
+ if (!times[event.end]) {
+ times[event.end] = [];
+ }
+ times[event.start].push(event);
+ times[event.end].push(event);
+ }
+ const sortedTimes = Object.keys(times)
+ .map(k => parseInt(k, 10))
+ .sort();
+ const overlapById = {};
+ let startedEvents = [];
+ for (const t of sortedTimes) {
+ for (const e of times[t]) {
+ if (e.start === t) {
+ overlapById[e.id] = { concurrentEvents: 1, order: null };
+ startedEvents.push(e);
+ }
+ if (e.end === t) {
+ startedEvents = _.reject(startedEvents, o => o.id === e.id);
+ }
+ }
+ for (const e of startedEvents) {
+ if (!overlapById[e.id]) {
+ overlapById[e.id] = {};
+ }
+ const numEvents = this._findMaxConcurrent(startedEvents, overlapById);
+ overlapById[e.id].concurrentEvents = numEvents;
+ if (overlapById[e.id].order === null) {
+ // Dont' re-assign the order.
+ const order = this._findAvailableOrder(startedEvents, overlapById);
+ overlapById[e.id].order = order;
+ }
+ }
+ }
+ return overlapById;
+ }
+
+ _findMaxConcurrent(startedEvents, overlapById) {
+ let max = 1;
+ for (const e of startedEvents) {
+ max = Math.max(overlapById[e.id].concurrentEvents || 1, max);
+ }
+ return Math.max(max, startedEvents.length);
+ }
+
+ _findAvailableOrder(startedEvents, overlapById) {
+ const orders = startedEvents.map(e => overlapById[e.id].order);
+ let order = 1;
+ while (true) {
+ if (orders.indexOf(order) === -1) {
+ return order;
+ }
+ order += 1;
+ }
+ }
+
+ _maxConcurrentEvents(eventOverlap) {
+ let maxConcurrent = -1;
+ _.each(eventOverlap, ({ concurrentEvents }) => {
+ maxConcurrent = Math.max(concurrentEvents, maxConcurrent);
+ });
+ return maxConcurrent;
+ }
+
+ _daysInView() {
+ const { start } = this._calculateMomentRange();
+ const days = [];
+ for (let i = 0; i < DAYS_IN_VIEW + BUFFER_DAYS * 2; i++) {
+ // moment::weekday is locale aware since some weeks start on diff
+ // days. See http://momentjs.com/docs/#/get-set/weekday/
+ days.push(moment(start).weekday(i));
+ }
+ return days;
+ }
+
+ _headerComponents() {
+ const left = (
+
+ Today
+
+ );
+ const right = false;
+ return [left, right, this.props.headerComponents];
+ }
+
+ _onClickToday = () => {
+ this.props.changeCurrentMoment(this._now());
+ };
+
+ _onClickNextWeek = () => {
+ const newMoment = moment(this.props.currentMoment).add(1, 'week');
+ this.props.changeCurrentMoment(newMoment);
+ };
+
+ _onClickPrevWeek = () => {
+ const newMoment = moment(this.props.currentMoment).subtract(1, 'week');
+ this.props.changeCurrentMoment(newMoment);
+ };
+
+ _gridHeight() {
+ return DAY_DUR / INTERVAL_TIME * this.state.intervalHeight;
+ }
+
+ _centerScrollRegion() {
+ const wrap = ReactDOM.findDOMNode(this.refs.eventGridWrap);
+ wrap.scrollTop = this._gridHeight() / 2 - wrap.getBoundingClientRect().height / 2;
+ }
+
+ // This generates the ticks used mark the event grid and the
+ // corresponding legend in the week view.
+ *_tickGenerator({ type }) {
+ const height = this._gridHeight();
+
+ let step = INTERVAL_TIME;
+ let stepStart = 0;
+
+ // We only use a moment object so we can properly localize the "time"
+ // part. The day is irrelevant. We just need to make sure we're
+ // picking a non-DST boundary day.
+ const start = moment([2015, 1, 1]);
+
+ let duration = INTERVAL_TIME;
+ if (type === 'major') {
+ step = INTERVAL_TIME * 2;
+ duration += INTERVAL_TIME;
+ } else if (type === 'minor') {
+ step = INTERVAL_TIME * 2;
+ stepStart = INTERVAL_TIME;
+ duration += INTERVAL_TIME;
+ start.add(INTERVAL_TIME, 'seconds');
+ }
+
+ const curTime = moment(start);
+ for (let tsec = stepStart; tsec <= DAY_DUR; tsec += step) {
+ const y = tsec / DAY_DUR * height;
+ yield { time: curTime, yPos: y };
+ curTime.add(duration, 'seconds');
+ }
+ }
+
+ _setIntervalHeight = () => {
+ if (!this._mounted) {
+ return;
+ } // Resize unmounting is delayed in tests
+ const wrap = ReactDOM.findDOMNode(this.refs.eventGridWrap);
+ const wrapHeight = wrap.getBoundingClientRect().height;
+ if (this._lastWrapHeight === wrapHeight) {
+ return;
+ }
+ this._lastWrapHeight = wrapHeight;
+ const numIntervals = Math.floor(DAY_DUR / INTERVAL_TIME);
+ ReactDOM.findDOMNode(this.refs.eventGridLegendWrap).style.height = `${wrapHeight}px`;
+ this.setState({
+ intervalHeight: Math.max(wrapHeight / numIntervals, MIN_INTERVAL_HEIGHT),
+ });
+ };
+
+ _onScrollGrid = event => {
+ ReactDOM.findDOMNode(this.refs.eventGridLegendWrap).scrollTop = event.target.scrollTop;
+ };
+
+ _onScrollCalendarArea = event => {
+ if (!event.currentTarget.scrollLeft || this._waitingForShift) {
+ return;
+ }
+
+ const edgeWidth = event.currentTarget.clientWidth / DAYS_IN_VIEW * 2;
+
+ if (event.currentTarget.scrollLeft < edgeWidth) {
+ this._waitingForShift = event.currentTarget.clientWidth;
+ this._onClickPrevWeek();
+ } else if (
+ event.currentTarget.scrollLeft >
+ event.currentTarget.scrollWidth - event.currentTarget.clientWidth - edgeWidth
+ ) {
+ this._waitingForShift = -event.currentTarget.clientWidth;
+ this._onClickNextWeek();
+ }
+ };
+
+ _renderEventGridLabels() {
+ const labels = [];
+ let centering = 0;
+ for (const { time, yPos } of this._tickGenerator({ type: 'major' })) {
+ const hr = time.format('LT'); // Locale time. 2:00 pm or 14:00
+ const style = { top: yPos - centering };
+ labels.push(
+
+ {hr}
+
+ );
+ centering = 8; // center all except the 1st one.
+ }
+ return labels.slice(0, labels.length - 1);
+ }
+
+ _bufferRatio() {
+ return (BUFFER_DAYS * 2 + DAYS_IN_VIEW) / DAYS_IN_VIEW;
+ }
+
+ // We calculate events by days so we only need to iterate through all
+ // events in the span once.
+ _eventsByDay(days) {
+ const map = { allDay: [] };
+ const unixDays = days.map(d => d.unix());
+ unixDays.forEach(d => {
+ map[d] = [];
+ return;
+ });
+ for (const event of this.state.events) {
+ if (event.isAllDay) {
+ map.allDay.push(event);
+ } else {
+ for (const day of unixDays) {
+ const bounds = {
+ start: day,
+ end: day + DAY_DUR - 1,
+ };
+ if (overlapsBounds(bounds, event)) {
+ map[day].push(event);
+ }
+ }
+ }
+ }
+ return map;
+ }
+
+ render() {
+ const days = this._daysInView();
+ const todayColumnIdx = days.findIndex(d => this._isToday(d));
+ const eventsByDay = this._eventsByDay(days);
+ const allDayOverlap = this._eventOverlap(eventsByDay.allDay);
+ const tickGen = this._tickGenerator.bind(this);
+ const gridHeight = this._gridHeight();
+
+ const { start: startMoment, end: endMoment } = this._calculateMomentRange();
+
+ const start = moment(startMoment).add(BUFFER_DAYS, 'days');
+ const end = moment(endMoment).subtract(BUFFER_DAYS, 'days');
+ const headerText = `${start.format('MMMM D')} - ${end.format('MMMM D YYYY')}`;
+
+ return (
+
+
+
+
+
+
+
+
+
+ All Day
+
+
+
+ {this._renderEventGridLabels()}
+
+
+
+
+
+
+
{days.map(this._renderDateLabel)}
+
+
+
+
this.refs.scrollbar}
+ onScroll={this._onScrollGrid}
+ style={{ width: `${this._bufferRatio() * 100}%` }}
+ >
+
+ {days.map(_.partial(this._renderEventColumn, eventsByDay))}
+ BUFFER_DAYS && todayColumnIdx <= BUFFER_DAYS + DAYS_IN_VIEW
+ }
+ gridHeight={gridHeight}
+ numColumns={BUFFER_DAYS * 2 + DAYS_IN_VIEW}
+ todayColumnIdx={todayColumnIdx}
+ />
+
+
+
+
+
this.refs.eventGridWrap}
+ />
+
+
+
+
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/event-description-frame.jsx b/app/internal_packages/main-calendar/lib/event-description-frame.jsx
new file mode 100644
index 000000000..495e2f52c
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/event-description-frame.jsx
@@ -0,0 +1,137 @@
+import { EventedIFrame } from 'mailspring-component-kit';
+import { React, ReactDOM, PropTypes, Utils } from 'mailspring-exports';
+
+export default class EmailFrame extends React.Component {
+ static displayName = 'EmailFrame';
+
+ static propTypes = {
+ content: PropTypes.string.isRequired,
+ };
+
+ componentDidMount() {
+ this._mounted = true;
+ this._writeContent();
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
+ }
+
+ componentDidUpdate() {
+ this._writeContent();
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+ if (this._unlisten) {
+ this._unlisten();
+ }
+ }
+
+ _writeContent = () => {
+ const doc = ReactDOM.findDOMNode(this._iframeComponent).contentDocument;
+ if (!doc) {
+ return;
+ }
+ doc.open();
+
+ // NOTE: The iframe must have a modern DOCTYPE. The lack of this line
+ // will cause some bizzare non-standards compliant rendering with the
+ // message bodies. This is particularly felt with elements use
+ // the `border-collapse: collapse` css property while setting a
+ // `padding`.
+ doc.write('');
+ doc.write(
+ `${this.props.content}
`
+ );
+ doc.close();
+
+ // autolink(doc, {async: true});
+ // autoscaleImages(doc);
+ // addInlineDownloadPrompts(doc);
+
+ // Notify the EventedIFrame that we've replaced it's document (with `open`)
+ // so it can attach event listeners again.
+ this._iframeComponent.didReplaceDocument();
+ this._onMustRecalculateFrameHeight();
+ };
+
+ _onMustRecalculateFrameHeight = () => {
+ this._iframeComponent.setHeightQuietly(0);
+ this._lastComputedHeight = 0;
+ this._setFrameHeight();
+ };
+
+ _getFrameHeight = doc => {
+ let height = 0;
+
+ if (doc && doc.body) {
+ // Why reset the height? body.scrollHeight will always be 0 if the height
+ // of the body is dependent on the iframe height e.g. if height ===
+ // 100% in inline styles or an email stylesheet
+ const style = window.getComputedStyle(doc.body);
+ if (style.height === '0px') {
+ doc.body.style.height = 'auto';
+ }
+ height = doc.body.scrollHeight;
+ }
+
+ if (doc && doc.documentElement) {
+ height = doc.documentElement.scrollHeight;
+ }
+
+ // scrollHeight does not include space required by scrollbar
+ return height + 25;
+ };
+
+ _setFrameHeight = () => {
+ if (!this._mounted) {
+ return;
+ }
+
+ // Q: What's up with this holder?
+ // A: If you resize the window, or do something to trigger setFrameHeight
+ // on an already-loaded message view, all the heights go to zero for a brief
+ // second while the heights are recomputed. This causes the ScrollRegion to
+ // reset it's scrollTop to ~0 (the new combined heiht of all children).
+ // To prevent this, the holderNode holds the last computed height until
+ // the new height is computed.
+ const iframeEl = ReactDOM.findDOMNode(this._iframeComponent);
+ const height = this._getFrameHeight(iframeEl.contentDocument);
+
+ // Why 5px? Some emails have elements with a height of 100%, and then put
+ // tracking pixels beneath that. In these scenarios, the scrollHeight of the
+ // message is always <100% + 1px>, which leads us to resize them constantly.
+ // This is a hack, but I'm not sure of a better solution.
+ if (Math.abs(height - this._lastComputedHeight) > 5) {
+ this._iframeComponent.setHeightQuietly(height);
+ this._iframeHeightHolderEl.style.height = `${height}px`;
+ this._lastComputedHeight = height;
+ }
+
+ if (iframeEl.contentDocument.readyState !== 'complete') {
+ setTimeout(() => this._setFrameHeight(), 0);
+ }
+ };
+
+ render() {
+ return (
+ {
+ this._iframeHeightHolderEl = el;
+ }}
+ style={{ height: this._lastComputedHeight }}
+ >
+ {
+ this._iframeComponent = cm;
+ }}
+ seamless="seamless"
+ searchable
+ onResize={this._onMustRecalculateFrameHeight}
+ />
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/main.jsx b/app/internal_packages/main-calendar/lib/main.jsx
new file mode 100644
index 000000000..a74c73499
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/main.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { WorkspaceStore, ComponentRegistry } from 'mailspring-exports';
+import CalendarWrapper from './calendar-wrapper';
+import QuickEventButton from './quick-event-button';
+
+const Notice = () => (
+
+ Calendar is launching later this year! This preview is read-only and only supports Google
+ calendar.
+
+);
+Notice.displayName = 'Notice';
+
+export function activate() {
+ ComponentRegistry.register(CalendarWrapper, {
+ location: WorkspaceStore.Location.Center,
+ });
+ ComponentRegistry.register(Notice, {
+ location: WorkspaceStore.Sheet.Main.Header,
+ });
+ ComponentRegistry.register(QuickEventButton, {
+ location: WorkspaceStore.Location.Center.Toolbar,
+ });
+}
+
+export function deactivate() {
+ ComponentRegistry.unregister(CalendarWrapper);
+ ComponentRegistry.unregister(QuickEventButton);
+}
diff --git a/app/internal_packages/main-calendar/lib/quick-event-button.jsx b/app/internal_packages/main-calendar/lib/quick-event-button.jsx
new file mode 100644
index 000000000..c402e1bc4
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/quick-event-button.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Actions } from 'mailspring-exports';
+import QuickEventPopover from './quick-event-popover';
+
+export default class QuickEventButton extends React.Component {
+ static displayName = 'QuickEventButton';
+
+ onClick = event => {
+ event.stopPropagation();
+ const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
+ Actions.openPopover( , { originRect: buttonRect, direction: 'down' });
+ };
+
+ render() {
+ return (
+
+ +
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/lib/quick-event-popover.jsx b/app/internal_packages/main-calendar/lib/quick-event-popover.jsx
new file mode 100644
index 000000000..8e0337e58
--- /dev/null
+++ b/app/internal_packages/main-calendar/lib/quick-event-popover.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { Actions, Calendar, DatabaseStore, DateUtils, Event } from 'mailspring-exports';
+
+export default class QuickEventPopover extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ start: null,
+ end: null,
+ leftoverText: null,
+ };
+ }
+
+ onInputKeyDown = event => {
+ const { key, target: { value } } = event;
+ if (value.length > 0 && ['Enter', 'Return'].includes(key)) {
+ // This prevents onInputChange from being fired
+ event.stopPropagation();
+ this.createEvent(DateUtils.parseDateString(value));
+ Actions.closePopover();
+ }
+ };
+
+ onInputChange = event => {
+ this.setState(DateUtils.parseDateString(event.target.value));
+ };
+
+ createEvent = async ({ leftoverText, start, end }) => {
+ const allCalendars = await DatabaseStore.findAll(Calendar);
+ if (allCalendars.length === 0) {
+ throw new Error("Can't create an event, you have no calendars");
+ }
+ const cals = allCalendars.filter(c => !c.readOnly);
+ if (cals.length === 0) {
+ AppEnv.showErrorDialog(
+ "This account has no editable calendars. We can't " +
+ 'create an event for you. Please make sure you have an editable calendar ' +
+ 'with your account provider.'
+ );
+ return;
+ }
+
+ const event = new Event({
+ calendarId: cals[0].id,
+ accountId: cals[0].accountId,
+ start: start.unix(),
+ end: end.unix(),
+ when: {
+ start_time: start.unix(),
+ end_time: end.unix(),
+ },
+ title: leftoverText,
+ });
+
+ console.log(event);
+
+ // todo bg
+ // return DatabaseStore.inTransaction((t) => {
+ // return t.persistModel(event)
+ // }).then(() => {
+ // const task = new SyncbackEventTask(event.id);
+ // Actions.queueTask(task);
+ // })
+ };
+
+ render() {
+ let dateInterpretation;
+ if (this.state.start) {
+ dateInterpretation = (
+
+ Title: {this.state.leftoverText}
+ Start: {DateUtils.format(this.state.start, DateUtils.DATE_FORMAT_SHORT)}
+ End: {DateUtils.format(this.state.end, DateUtils.DATE_FORMAT_SHORT)}
+
+ );
+ }
+
+ return (
+
+
+ {dateInterpretation}
+
+ );
+ }
+}
diff --git a/app/internal_packages/main-calendar/package.json b/app/internal_packages/main-calendar/package.json
new file mode 100644
index 000000000..4f39fdcff
--- /dev/null
+++ b/app/internal_packages/main-calendar/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "main-calendar",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Calendar",
+ "license": "GPL-3.0",
+ "private": true,
+ "scripts": {
+ },
+ "engines": {
+ "mailspring": "*"
+ },
+ "windowTypes": {
+ "calendar": true
+ }
+}
diff --git a/app/internal_packages/main-calendar/styles/main-calendar.less b/app/internal_packages/main-calendar/styles/main-calendar.less
new file mode 100644
index 000000000..a16076102
--- /dev/null
+++ b/app/internal_packages/main-calendar/styles/main-calendar.less
@@ -0,0 +1,91 @@
+// The ui-variables file is provided by base themes provided by N1.
+@import 'ui-variables';
+@import 'ui-mixins';
+
+.main-calendar {
+ height: 100%;
+}
+
+.fixed-popover .calendar-event-popover {
+ color: fadeout(@text-color, 20%);
+ background-color: @background-primary;
+ display: flex;
+ flex-direction: column;
+ font-size: @font-size-small;
+ max-height: 95vh;
+ width: 300px;
+
+ .location {
+ color: @text-color-very-subtle;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ word-wrap: break-word;
+ flex-shrink: 0;
+ }
+ .title-wrapper {
+ color: @text-color-inverse;
+ display: flex;
+ font-size: @font-size-larger;
+ background-color: @accent-primary;
+ border-top-left-radius: @border-radius-base;
+ border-top-right-radius: @border-radius-base;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ align-items: center;
+ flex-shrink: 0;
+ .title {
+ padding-right: @padding-base-vertical;
+ }
+ }
+ .edit-icon {
+ background-color: @text-color-inverse;
+ cursor: pointer;
+ }
+
+ .invitees .scroll-region-content {
+ max-height: 160px;
+ word-wrap: break-word;
+ position: relative;
+ }
+ .description {
+ white-space: pre-line;
+ word-wrap: break-word;
+ }
+ .description .scroll-region-content {
+ max-height: 250px;
+ position: relative;
+ }
+ .label {
+ color: @text-color-very-subtle;
+ }
+ .section {
+ border-top: 1px solid @border-color-divider;
+ padding: @padding-base-vertical @padding-base-horizontal;
+ }
+ .row.time {
+ .time-picker {
+ text-align: center;
+ }
+ .time-picker-wrap {
+ margin-right: 5px;
+
+ .time-options {
+ z-index: 10; // So the time pickers show over
+ }
+ }
+ }
+}
+
+.quick-event-popover {
+ width: 250px;
+}
+
+.preview-notice {
+ display: block;
+ box-sizing: border-box;
+ -webkit-print-color-adjust: exact;
+ padding: 8px 12px;
+ border-bottom: 1px solid rgb(235, 224, 204);
+ color: rgb(169, 136, 66);
+ background-color: rgb(242, 235, 222);
+ white-space: nowrap;
+ overflow: hidden;
+}
diff --git a/app/internal_packages/main-calendar/styles/nylas-calendar.less b/app/internal_packages/main-calendar/styles/nylas-calendar.less
new file mode 100644
index 000000000..7e71e021c
--- /dev/null
+++ b/app/internal_packages/main-calendar/styles/nylas-calendar.less
@@ -0,0 +1,435 @@
+@import 'ui-variables';
+body.platform-win32 {
+ .calendar-toggles {
+ .colored-checkbox {
+ border-radius: 0;
+ .bg-color {
+ border-radius: 0;
+ }
+ }
+ }
+}
+
+.nylas-calendar {
+ height: 100%;
+ display: flex;
+
+ .calendar-toggles {
+ display: flex;
+ background-color: @source-list-bg;
+ border-right: 1px solid @border-color-divider;
+ color: @text-color-subtle;
+ .calendar-toggles-wrap {
+ padding: 12px;
+ padding-top: 0px;
+ }
+ .colored-checkbox {
+ position: relative;
+ top: 1px;
+ margin-left: 1px;
+ display: inline-block;
+ border-radius: 3px;
+ width: 12px;
+ height: 12px;
+ box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(0, 0, 0, 0.13);
+ background: white;
+ .bg-color {
+ width: 100%;
+ height: 100%;
+ border-radius: 3px;
+ }
+ }
+ label {
+ padding-left: 0.4em;
+ }
+ .toggle-wrap,
+ .account-label {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+ }
+ .account-calendars-wrap {
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+ .account-label {
+ cursor: default;
+ color: @text-color-very-subtle;
+ font-weight: @font-weight-semi-bold;
+ font-size: @font-size-small * 0.9;
+ margin-top: @padding-large-vertical;
+ letter-spacing: -0.2px;
+ }
+ }
+
+ background: @background-off-primary;
+ .calendar-view {
+ height: 100%;
+ flex: 1;
+ position: relative;
+ }
+
+ .calendar-event {
+ @interval-height: 21px;
+ position: absolute;
+ font-size: 11px;
+ line-height: 11px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-height: @interval-height;
+
+ border-top: 0;
+ border-bottom: 2px solid @background-primary;
+ border-left: 0;
+
+ display: flex;
+ cursor: default;
+ .default-header {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 4px 5px 5px 6px;
+ cursor: default;
+ opacity: 0.7;
+ flex: 1;
+ }
+
+ &:before {
+ content: '';
+ position: absolute;
+ width: 2px;
+ height: 100%;
+ left: 0;
+ top: 0;
+ background: rgba(0, 0, 0, 0.1);
+ }
+
+ &.horizontal {
+ white-space: nowrap;
+ }
+ &.selected {
+ background-color: @accent-primary !important;
+ }
+ }
+
+ .calendar-mouse-handler {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ .week-view {
+ height: 100%;
+
+ @legend-width: 75px;
+
+ .calendar-body-wrap {
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ height: 100%;
+
+ .calendar-area-wrap {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-x: auto;
+ overflow-y: hidden;
+ position: relative;
+ }
+
+ .calendar-legend {
+ width: @legend-width;
+ box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
+ z-index: 2;
+ }
+ }
+
+ .week-header {
+ position: relative;
+ padding-top: 75px;
+ border-bottom: 1px solid @border-color-divider;
+ flex-shrink: 0;
+ }
+
+ .date-labels {
+ position: absolute;
+ width: 100%;
+ top: 0;
+ left: 0;
+ height: 100%;
+ display: flex;
+ }
+
+ .all-day-events {
+ position: relative;
+ width: auto;
+ z-index: 2;
+ overflow: hidden;
+ }
+
+ .all-day-legend {
+ width: @legend-width;
+ position: relative;
+ }
+
+ .legend-text {
+ font-size: 11px;
+ position: absolute;
+ color: #bfbfbf;
+ right: 10px;
+ }
+
+ .date-label-legend {
+ width: @legend-width;
+ position: relative;
+ border-bottom: 1px solid #dddddd;
+ box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.15);
+ z-index: 3;
+ .legend-text {
+ bottom: 7px;
+ }
+ }
+
+ .day-label-wrap {
+ padding: 15px;
+ text-align: center;
+ flex: 1;
+ box-shadow: inset 1px 0 0 rgba(177, 177, 177, 0.15);
+ &.is-today {
+ .date-label {
+ color: @accent-primary;
+ }
+ .weekday-label {
+ color: @accent-primary;
+ }
+ }
+ }
+ .date-label {
+ display: block;
+ font-size: 16px;
+ font-weight: 300;
+ color: #808080;
+ }
+ .weekday-label {
+ display: block;
+ font-weight: 500;
+ text-transform: uppercase;
+ margin-top: 3px;
+ font-size: 12px;
+ color: #ccd4d8;
+ }
+
+ .event-grid-wrap {
+ flex: 1;
+ overflow: auto;
+ background: @background-primary;
+ box-shadow: inset 0 1px 2.5px rgba(0, 0, 0, 0.15);
+ height: 100%;
+ }
+ .event-grid {
+ display: flex;
+ position: relative;
+ }
+ .event-grid-legend-wrap {
+ overflow: hidden;
+ box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
+ }
+ .event-grid-legend {
+ position: relative;
+ background: @background-primary;
+ z-index: 2;
+ }
+ .event-grid-bg-wrap {
+ .event-grid-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ .cursor {
+ background: rgba(0, 0, 0, 0.04);
+ position: absolute;
+ }
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 0;
+ }
+
+ .event-column {
+ flex: 1;
+ position: relative;
+ height: 100%;
+ z-index: 1;
+ overflow: hidden;
+ box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
+ &.weekend {
+ background: rgba(0, 0, 0, 0.02);
+ }
+ }
+ }
+
+ .month-view {
+ }
+
+ .current-time-indicator {
+ position: absolute;
+ width: 100%;
+ height: 1px;
+ opacity: 0;
+ border-top: 1px solid rgb(255, 100, 100);
+ z-index: 2;
+ transition: opacity ease-in-out 300ms;
+
+ &.visible {
+ opacity: 1;
+ }
+ div {
+ position: absolute;
+ width: 11px;
+ height: 11px;
+ border-radius: 6px;
+ background-color: rgb(255, 100, 100);
+ transform: translate3d(-75%, -50%, 0);
+ border: 1px solid @background-primary;
+ }
+ }
+
+ .top-banner {
+ color: rgba(33, 99, 146, 0.6);
+ background: #e0eff6;
+ font-size: 12px;
+ line-height: 25px;
+ text-align: center;
+ box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.07);
+ }
+
+ .header-controls {
+ padding: 10px;
+ display: flex;
+ color: #808080;
+ border-bottom: 1px solid @border-color-divider;
+ box-shadow: inset 0 -1px 1px rgba(191, 191, 191, 0.12);
+ flex-shrink: 0;
+
+ .title {
+ display: inline-block;
+ width: 275px;
+ }
+ .title,
+ .btn-icon {
+ font-size: 15px;
+ line-height: 25px;
+ }
+
+ .btn-icon {
+ margin: 0;
+ height: auto;
+ img {
+ vertical-align: baseline;
+ }
+ }
+ }
+
+ .footer-controls {
+ padding: 10px;
+ min-height: 45px;
+ display: flex;
+ color: #808080;
+ background: @background-primary;
+ border-top: @border-color-divider;
+ box-shadow: 0 -3px 16px rgba(0, 0, 0, 0.11);
+ z-index: 2;
+ }
+
+ .center-controls {
+ text-align: center;
+ flex: 1;
+ order: 0;
+ }
+}
+
+.mini-month-view {
+ width: 100%;
+ height: 100%;
+ min-width: 200px;
+ min-height: 200px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ background: @background-primary;
+ border: 1px solid @border-color-divider;
+ border-radius: @border-radius-base;
+
+ .header {
+ display: flex;
+ background: @background-secondary;
+ padding: 4px 0 3px 0;
+ .month-title {
+ padding-top: 3px;
+ color: @text-color;
+ flex: 1;
+ }
+ border-bottom: 1px solid @border-color-divider;
+ .btn.btn-icon {
+ line-height: 27px;
+ height: 27px;
+ margin-top: -1px;
+ margin-right: 0;
+ &:active {
+ background: transparent;
+ }
+ }
+ }
+
+ .legend {
+ display: flex;
+ .weekday {
+ flex: 1;
+ }
+ padding: 3px 0;
+ border-bottom: 1px solid @border-color-divider;
+ }
+
+ .day-grid {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ .week {
+ flex: 1;
+ display: flex;
+ min-height: 28px;
+ }
+ .day {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ flex: 1;
+ min-height: 28px;
+ color: @text-color-very-subtle;
+ &.cur-month {
+ color: @text-color;
+ }
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ cursor: pointer;
+ }
+ &.today {
+ border: 1px solid @accent-primary;
+ }
+ &.cur-day {
+ background: @accent-primary;
+ color: @text-color-inverse;
+ &:hover {
+ background: darken(@accent-primary, 5%);
+ }
+ }
+ }
+ }
+}
diff --git a/app/menus/darwin.js b/app/menus/darwin.js
index ddc90b4c7..9a55fb3ea 100644
--- a/app/menus/darwin.js
+++ b/app/menus/darwin.js
@@ -193,6 +193,8 @@ module.exports = {
command: 'application:toggle-dev',
},
{ type: 'separator' },
+ { label: localized('Calendar Preview'), command: 'application:show-calendar' },
+ { type: 'separator' },
{ label: localized('Create a Plugin') + '...', command: 'window:create-package' },
{ label: localized('Install a Plugin') + '...', command: 'window:install-package' },
{ type: 'separator' },
diff --git a/app/menus/linux.js b/app/menus/linux.js
index 56bfe9aaa..8d23656c8 100644
--- a/app/menus/linux.js
+++ b/app/menus/linux.js
@@ -163,6 +163,8 @@ module.exports = {
command: 'application:toggle-dev',
},
{ type: 'separator' },
+ { label: localized('Calendar Preview'), command: 'application:show-calendar' },
+ { type: 'separator' },
{ label: localized('Create a Plugin') + '...', command: 'window:create-package' },
{ label: localized('Install a Plugin') + '...', command: 'window:install-package' },
{ type: 'separator' },
diff --git a/app/menus/win32.js b/app/menus/win32.js
index c4dce6cbd..3654c6ec6 100644
--- a/app/menus/win32.js
+++ b/app/menus/win32.js
@@ -141,6 +141,8 @@ module.exports = {
command: 'application:toggle-dev',
},
{ type: 'separator' },
+ { label: localized('Calendar Preview'), command: 'application:show-calendar' },
+ { type: 'separator' },
{ label: localized('Create a Plugin') + '...', command: 'window:create-package' },
{ label: localized('Install a Plugin') + '...', command: 'window:install-package' },
{ type: 'separator' },
diff --git a/app/package-lock.json b/app/package-lock.json
index 164cd9d2c..4eeccc295 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1798,6 +1798,19 @@
"sshpk": "^1.7.0"
}
},
+ "ical-expander": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ical-expander/-/ical-expander-2.0.0.tgz",
+ "integrity": "sha512-DABFhfEl0c58MeiTbG8hO5VEWQfKlFA7jSqdoIbS2caxS4nGyA73nVBGsFtIbmTrDO7Osgw9/up2ZCnvuxGcKA==",
+ "requires": {
+ "ical.js": "^1.2.2"
+ }
+ },
+ "ical.js": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.3.0.tgz",
+ "integrity": "sha512-wQ0w77MGOe6vNhZMBQuZAtZoTzDEOQ/QoCDofWL7yfkQ/HYWX5NyWPYjN+yj+4JahOvTkhXjTlrZtiQKv+BSOA=="
+ },
"iconv-lite": {
"version": "0.4.19",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
@@ -2667,6 +2680,12 @@
"resolved": "https://registry.npmjs.org/lsmod/-/lsmod-1.0.0.tgz",
"integrity": "sha1-mgD3bco26yP6BTUK/htYXUKZ5ks="
},
+ "luxon": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.11.1.tgz",
+ "integrity": "sha512-ZmfPxP91Swg5MmHOeV/hvdIWiUYm6LCySg0xVRBVtOHZdNqiD32qxUhMek4XbyyXWtKdGMUD+hW+o5pilbRVuA==",
+ "optional": true
+ },
"macos-notification-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/macos-notification-state/-/macos-notification-state-1.1.0.tgz",
@@ -3629,6 +3648,14 @@
}
}
},
+ "rrule": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.0.tgz",
+ "integrity": "sha512-TRigkTJtG7Y1yOjNSKvFvVmvj/PzRZLR8lLcPW9GASOlaoqoL1J0kNuUV9I3LuZc7qFT+QB2NbxSLL9d33/ylg==",
+ "requires": {
+ "luxon": "^1.3.3"
+ }
+ },
"rst-selector-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
diff --git a/app/package.json b/app/package.json
index dde16f713..8f2ecbb70 100644
--- a/app/package.json
+++ b/app/package.json
@@ -19,6 +19,7 @@
"classnames": "1.2.1",
"collapse-whitespace": "^1.1.6",
"debug": "github:emorikawa/debug#nylas",
+ "deep-extend": "0.6.0",
"electron-spellchecker": "github:bengotow/electron-spellchecker#master",
"emoji-data": "^0.2.0",
"enzyme": "^3.8.0",
@@ -27,10 +28,11 @@
"fs-plus": "^2.3.2",
"getmac": "^1.2.1",
"graceful-fs": "^4.1.11",
+ "ical-expander": "^2.0.0",
+ "ical.js": "^1.3.0",
"immutable": "^3.8.2",
"is-online": "7.0.0",
"jasmine-json": "~0.0",
- "deep-extend": "0.6.0",
"jasmine-react-helpers": "^0.2",
"jasmine-reporters": "1.x.x",
"juice": "^1.4",
diff --git a/app/src/app-env.es6 b/app/src/app-env.es6
index aefbac69a..2e6ed46ba 100644
--- a/app/src/app-env.es6
+++ b/app/src/app-env.es6
@@ -619,7 +619,13 @@ export default class AppEnvConstructor {
// gets fired.
async startSecondaryWindow() {
await this.startWindow();
- ipcRenderer.on('load-settings-changed', (...args) => this.populateHotWindow(...args));
+ ipcRenderer.on('load-settings-changed', (event, loadSettings) => {
+ let url = window.location.href.substr(0, window.location.href.indexOf('loadSettings='));
+ url += `loadSettings=${encodeURIComponent(JSON.stringify(loadSettings))}`;
+ window.history.replaceState('', '', url);
+
+ this.populateHotWindow(loadSettings);
+ });
}
// We setup the initial Sheet for hot windows. This is the default title
@@ -644,7 +650,7 @@ export default class AppEnvConstructor {
//
// This also means that the windowType has changed and a different set of
// plugins needs to be loaded.
- populateHotWindow(event, loadSettings) {
+ populateHotWindow(loadSettings) {
this.loadSettings = loadSettings;
this.constructor.loadSettings = loadSettings;
diff --git a/app/src/browser/application.es6 b/app/src/browser/application.es6
index 42c04461e..7909f8a2e 100644
--- a/app/src/browser/application.es6
+++ b/app/src/browser/application.es6
@@ -235,6 +235,7 @@ export default class Application extends EventEmitter {
// Configures required javascript environment flags.
setupJavaScriptArguments() {
+ app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
app.commandLine.appendSwitch('js-flags', '--harmony');
}
@@ -356,6 +357,14 @@ export default class Application extends EventEmitter {
}
});
+ this.on('application:show-calendar', () => {
+ this.windowManager.ensureWindow(WindowManager.CALENDAR_WINDOW, {});
+ const main = this.windowManager.get(WindowManager.MAIN_WINDOW);
+ if (main) {
+ main.sendMessage('run-calendar-sync');
+ }
+ });
+
this.on('application:view-help', () => {
const helpUrl = 'http://support.getmailspring.com/hc/en-us';
shell.openExternal(helpUrl);
diff --git a/app/src/browser/window-manager.es6 b/app/src/browser/window-manager.es6
index 7ac185a4b..a3b6da94f 100644
--- a/app/src/browser/window-manager.es6
+++ b/app/src/browser/window-manager.es6
@@ -6,6 +6,7 @@ import { localized } from '../intl';
const MAIN_WINDOW = 'default';
const SPEC_WINDOW = 'spec';
const ONBOARDING_WINDOW = 'onboarding';
+const CALENDAR_WINDOW = 'calendar';
export default class WindowManager {
constructor({
@@ -251,6 +252,18 @@ export default class WindowManager {
height: 600,
};
+ // The SPEC_WINDOW gets passed its own bootstrapScript
+ coreWinOpts[WindowManager.CALENDAR_WINDOW] = {
+ windowKey: WindowManager.CALENDAR_WINDOW,
+ windowType: WindowManager.CALENDAR_WINDOW,
+ title: localized('Calendar Preview'),
+ width: 900,
+ height: 600,
+ frame: false,
+ toolbar: true,
+ hidden: false,
+ };
+
// The SPEC_WINDOW gets passed its own bootstrapScript
coreWinOpts[WindowManager.SPEC_WINDOW] = {
windowKey: WindowManager.SPEC_WINDOW,
@@ -272,3 +285,4 @@ export default class WindowManager {
WindowManager.MAIN_WINDOW = MAIN_WINDOW;
WindowManager.SPEC_WINDOW = SPEC_WINDOW;
WindowManager.ONBOARDING_WINDOW = ONBOARDING_WINDOW;
+WindowManager.CALENDAR_WINDOW = CALENDAR_WINDOW;
diff --git a/app/src/flux/mailsync-bridge.es6 b/app/src/flux/mailsync-bridge.es6
index 36d3855c2..33b7088b5 100644
--- a/app/src/flux/mailsync-bridge.es6
+++ b/app/src/flux/mailsync-bridge.es6
@@ -103,6 +103,13 @@ export default class MailsyncBridge {
return;
}
+ // Temporary: allow calendar sync to be manually invoked
+ ipcRenderer.on('run-calendar-sync', () => {
+ for (const client of Object.values(this._clients)) {
+ client.sendMessage({ type: 'sync-calendar' });
+ }
+ });
+
Actions.queueTask.listen(this._onQueueTask, this);
Actions.queueTasks.listen(this._onQueueTasks, this);
Actions.cancelTask.listen(this._onCancelTask, this);
diff --git a/app/src/flux/models/event.es6 b/app/src/flux/models/event.es6
index d3d13a5e8..f25eb4ef1 100644
--- a/app/src/flux/models/event.es6
+++ b/app/src/flux/models/event.es6
@@ -1,5 +1,3 @@
-import moment from 'moment';
-
import Model from './model';
import Attributes from '../attributes';
import Contact from './contact';
@@ -8,108 +6,34 @@ import Contact from './contact';
let chrono = null;
export default class Event extends Model {
+ // Note: This class doesn't have many table-level attributes. We store the ICS
+ // data for the event in the model JSON and parse it when we pull it out.
static attributes = Object.assign({}, Model.attributes, {
calendarId: Attributes.String({
queryable: true,
+ jsonKey: 'cid',
modelKey: 'calendarId',
- jsonKey: 'calendar_id',
- }),
- title: Attributes.String({
- modelKey: 'title',
- jsonKey: 'title',
- }),
- description: Attributes.String({
- modelKey: 'description',
- jsonKey: 'description',
- }),
- // Can Have 1 of 4 types of subobjects. The Type can be:
- //
- // time
- // object: "time"
- // time: (unix timestamp)
- //
- // timestamp
- // object: "timestamp"
- // start_time: (unix timestamp)
- // end_time: (unix timestamp)
- //
- // date
- // object: "date"
- // date: (ISO 8601 date format. i.e. 1912-06-23)
- //
- // datespan
- // object: "datespan"
- // start_date: (ISO 8601 date)
- // end_date: (ISO 8601 date)
- when: Attributes.Object({
- modelKey: 'when',
}),
- location: Attributes.String({
- modelKey: 'location',
- jsonKey: 'location',
- }),
-
- owner: Attributes.String({
- modelKey: 'owner',
- jsonKey: 'owner',
- }),
-
- // Subobject:
- // name (string) - The participant's full name (optional)
- // email (string) - The participant's email address
- // status (string) - Attendance status. Allowed values are yes, maybe,
- // no and noreply. Defaults is noreply
- // comment (string) - A comment by the participant (optional)
- participants: Attributes.Object({
- modelKey: 'participants',
- jsonKey: 'participants',
- }),
- status: Attributes.String({
- modelKey: 'status',
- jsonKey: 'status',
- }),
- readOnly: Attributes.Boolean({
- modelKey: 'readOnly',
- jsonKey: 'read_only',
- }),
- busy: Attributes.Boolean({
- modelKey: 'busy',
- jsonKey: 'busy',
- }),
-
- // Has a sub object of the form:
- // rrule: (array) - Array of recurrence rule (RRULE) strings. See RFC-2445
- // timezone: (string) - IANA time zone database formatted string
- // (e.g. America/New_York)
- recurrence: Attributes.Object({
- modelKey: 'recurrence',
- jsonKey: 'recurrence',
- }),
-
- // ---- EXTRACTED ATTRIBUTES -----
-
- // The "object" type of the "when" object. Can be either "time",
- // "timestamp", "date", or "datespan"
- type: Attributes.String({
- modelKey: 'type',
- jsonKey: '_type',
+ ics: Attributes.String({
+ jsonKey: 'ics',
+ modelKey: 'ics',
}),
// The calculated Unix start time. See the implementation for how we
// treat each type of "when" attribute.
- start: Attributes.Number({
+ recurrenceStart: Attributes.Number({
queryable: true,
- modelKey: 'start',
- jsonKey: '_start',
+ modelKey: 'recurrenceStart',
+ jsonKey: 'rs',
}),
// The calculated Unix end time. See the implementation for how we
// treat each type of "when" attribute.
- end: Attributes.Number({
+ recurrenceEnd: Attributes.Number({
queryable: true,
- modelKey: 'end',
- jsonKey: '_end',
+ modelKey: 'recurrenceEnd',
+ jsonKey: 're',
}),
// This corresponds to the rowid in the FTS table. We need to use the FTS
@@ -133,75 +57,11 @@ export default class Event extends Model {
return Event.sortOrderAttribute().descending();
};
- // We use moment to parse the date so we can more easily pick up the
- // current timezone of the current locale.
- // We also create a start and end times that span the full day without
- // bleeding into the next.
- _unixRangeForDatespan(startDate, endDate) {
- return {
- start: moment(startDate).unix(),
- end: moment(endDate)
- .add(1, 'day')
- .subtract(1, 'second')
- .unix(),
- };
- }
-
- fromJSON(json) {
- super.fromJSON(json);
-
- const when = this.when;
-
- if (!when) {
- return this;
- }
-
- if (when.time) {
- this.start = when.time;
- this.end = when.time;
- } else if (when.start_time && when.end_time) {
- this.start = when.start_time;
- this.end = when.end_time;
- } else if (when.date) {
- const range = this._unixRangeForDatespan(when.date, when.date);
- this.start = range.start;
- this.end = range.end;
- } else if (when.start_date && when.end_date) {
- const range = this._unixRangeForDatespan(when.start_date, when.end_date);
- this.start = range.start;
- this.end = range.end;
- }
-
- return this;
- }
-
- fromDraft(draft) {
- if (!this.title || this.title.length === 0) {
- this.title = draft.subject;
- }
-
- if (!this.participants || this.participants.length === 0) {
- this.participants = draft.participants().map(contact => {
- return {
- name: contact.name,
- email: contact.email,
- status: 'noreply',
- };
- });
- }
- return this;
- }
-
- isAllDay() {
- const daySpan = 86400 - 1;
- return this.end - this.start >= daySpan;
- }
-
displayTitle() {
const displayTitle = this.title.replace(/.*Invitation: /, '');
const [displayTitleWithoutDate, date] = displayTitle.split(' @ ');
if (!chrono) {
- chrono = require('chrono-node').default; //eslint-disable-line
+ chrono = require('chrono-node'); //eslint-disable-line
}
if (date && chrono.parseDate(date)) {
return displayTitleWithoutDate;