From d9bca1941ff2395e69b18ef59ecb48d74de443ad Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Sat, 9 Feb 2019 02:29:23 -0800 Subject: [PATCH 1/5] Revive old calendar prototype, link against new C++ sync engine --- app/internal_packages/main-calendar/README.md | 1 + .../main-calendar/lib/calendar-wrapper.jsx | 103 ++++ .../lib/core/calendar-constants.es6 | 4 + .../lib/core/calendar-data-source.es6 | 29 + .../lib/core/calendar-event-container.jsx | 113 ++++ .../lib/core/calendar-event-popover.jsx | 292 +++++++++ .../main-calendar/lib/core/calendar-event.jsx | 126 ++++ .../lib/core/calendar-helpers.jsx | 10 + .../lib/core/calendar-toggles.jsx | 69 +++ .../lib/core/current-time-indicator.jsx | 61 ++ .../lib/core/event-grid-background.jsx | 87 +++ .../lib/core/event-participants-input.jsx | 180 ++++++ .../lib/core/event-search-bar.jsx | 88 +++ .../lib/core/footer-controls.jsx | 31 + .../lib/core/header-controls.jsx | 56 ++ .../lib/core/mini-month-view.jsx | 134 +++++ .../main-calendar/lib/core/month-view.jsx | 18 + .../main-calendar/lib/core/nylas-calendar.jsx | 206 +++++++ .../main-calendar/lib/core/top-banner.jsx | 17 + .../lib/core/week-view-all-day-events.jsx | 48 ++ .../lib/core/week-view-event-column.jsx | 81 +++ .../main-calendar/lib/core/week-view.jsx | 564 ++++++++++++++++++ .../lib/event-description-frame.jsx | 137 +++++ .../main-calendar/lib/main.jsx | 66 ++ .../main-calendar/lib/quick-event-button.jsx | 27 + .../main-calendar/lib/quick-event-popover.jsx | 91 +++ .../main-calendar/package.json | 16 + .../main-calendar/styles/main-calendar.less | 67 +++ .../main-calendar/styles/nylas-calendar.less | 435 ++++++++++++++ app/menus/darwin.js | 2 + app/menus/linux.js | 2 + app/menus/win32.js | 2 + app/package-lock.json | 242 ++++++++ app/package.json | 3 +- app/src/app-env.es6 | 10 +- app/src/browser/application.es6 | 4 + app/src/browser/window-manager.es6 | 14 + app/src/flux/models/event.es6 | 179 ++---- 38 files changed, 3478 insertions(+), 137 deletions(-) create mode 100644 app/internal_packages/main-calendar/README.md create mode 100644 app/internal_packages/main-calendar/lib/calendar-wrapper.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-constants.es6 create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-data-source.es6 create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-event-container.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-event.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-helpers.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/calendar-toggles.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/current-time-indicator.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/event-grid-background.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/event-participants-input.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/event-search-bar.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/footer-controls.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/header-controls.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/mini-month-view.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/month-view.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/top-banner.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/week-view-all-day-events.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/week-view-event-column.jsx create mode 100644 app/internal_packages/main-calendar/lib/core/week-view.jsx create mode 100644 app/internal_packages/main-calendar/lib/event-description-frame.jsx create mode 100644 app/internal_packages/main-calendar/lib/main.jsx create mode 100644 app/internal_packages/main-calendar/lib/quick-event-button.jsx create mode 100644 app/internal_packages/main-calendar/lib/quick-event-popover.jsx create mode 100644 app/internal_packages/main-calendar/package.json create mode 100644 app/internal_packages/main-calendar/styles/main-calendar.less create mode 100644 app/internal_packages/main-calendar/styles/nylas-calendar.less 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..99d1680e6 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/core/calendar-data-source.es6 @@ -0,0 +1,29 @@ +import Rx from 'rx-lite'; +import { Event, Matcher, DatabaseStore } from 'mailspring-exports'; + +export default class CalendarDataSource { + buildObservable({ startTime, endTime, disabledCalendars }) { + const end = Event.attributes.end; + const start = Event.attributes.start; + + const matcher = new Matcher.And([ + 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)]), + ]), + Event.attributes.calendarId.notIn(disabledCalendars || []), + ]); + + const query = DatabaseStore.findAll(Event).where(matcher); + this.observable = Rx.Observable.fromQuery(query).flatMapLatest(results => { + return Rx.Observable.from([{ events: results }]); + }); + 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..d31b39e56 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx @@ -0,0 +1,292 @@ +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 EventParticipantsInput from './event-participants-input'; + +export default class CalendarEventPopover extends React.Component { + static propTypes = { + event: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + const { description, start, end, location, participants } = this.props.event; + + this.state = { description, start, end, location }; + this.state.title = this.props.event.displayTitle(); + this.state.editing = false; + this.state.participants = participants || []; + } + + componentWillReceiveProps = nextProps => { + const { description, start, end, location, participants } = nextProps.event; + this.setState({ description, start, end, location }); + this.setState({ + participants: participants || [], + 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', 'participants']; + 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'); + } + + updateParticipants = participants => { + this.setState({ participants }); + }; + + 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   + + +
+ ); + } + + renderParticipants(participants) { + const names = []; + for (let i = 0; i < participants.length; i++) { + names.push(
{participants[i].name}
); + } + return names; + } + + renderEditable = () => { + const { title, description, start, end, location, participants } = 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('participants', val); + }} + /> +
+
+
Notes:
+ { + this.updateField('description', e.target.value); + }} + /> +
+ Save + Actions.closePopover()}>Cancel +
+
+ ); + }; + + render() { + if (this.state.editing) { + return this.renderEditable(); + } + const { title, description, location, participants } = this.state; + + const fragment = document.createDocumentFragment(); + const descriptionRoot = document.createElement('root'); + fragment.appendChild(descriptionRoot); + descriptionRoot.innerHTML = description; + + const notes = this.extractNotesFromDescription(descriptionRoot); + + return ( +
+
+
{title}
+ +
+
{location}
+
{this.renderTime()}
+
+
Invitees:
+ {this.renderParticipants(participants)} +
+
+
+
Notes:
+ +
{notes}
+
+
+
+
+ ); + } +} 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..d12ea9682 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/core/calendar-event.jsx @@ -0,0 +1,126 @@ +import { React, ReactDOM, PropTypes, Event } 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.instanceOf(Event).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-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-participants-input.jsx b/app/internal_packages/main-calendar/lib/core/event-participants-input.jsx new file mode 100644 index 000000000..4a58977c8 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/core/event-participants-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, name } = props.token; + let chipText = email; + if (name && name.length > 0 && name !== email) { + chipText = name; + } + return ( +
+ + {chipText} +
+ ); +}; + +TokenRenderer.propTypes = { + token: PropTypes.object, +}; + +export default class EventParticipantsInput extends React.Component { + static displayName = 'EventParticipantsInput'; + + static propTypes = { + participants: 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.participants, + p => values.includes(p.email) || values.map(o => o.email).includes(p.email) + ); + this.props.change(updates); + }; + + _edit = (token, replacementString) => { + const tokenIndex = this.props.participants.indexOf(token); + + this._tokensForString(replacementString).then(replacements => { + const updates = this.props.participants.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.participants.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 participants. + 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-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 ; + } +} 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..ba9c53ae7 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/core/week-view.jsx @@ -0,0 +1,564 @@ +/* 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, + }; + + constructor(props) { + super(props); + this.state = { + events: [], + intervalHeight: MIN_INTERVAL_HEIGHT, + }; + } + + componentWillMount() { + this._initializeComponent(this.props); + } + + componentDidMount() { + this._mounted = true; + this._centerScrollRegion(); + this._setIntervalHeight(); + const weekStart = moment(this.state.startMoment) + .add(BUFFER_DAYS, 'days') + .unix(); + this._scrollTime = weekStart; + this._ensureHorizontalScrollPos(); + window.addEventListener('resize', this._setIntervalHeight, true); + } + + componentWillReceiveProps(props) { + this._initializeComponent(props); + } + + componentDidUpdate() { + this._setIntervalHeight(); + this._ensureHorizontalScrollPos(); + } + + componentWillUnmount() { + this._mounted = false; + this._sub.dispose(); + window.removeEventListener('resize', this._setIntervalHeight); + } + + // Indirection for testing purposes + _now() { + return moment(); + } + + _initializeComponent(props) { + this.todayYear = this._now().year(); + this.todayDayOfYear = this._now().dayOfYear(); + + if (this._sub) { + this._sub.dispose(); + } + + const startMoment = this._calculateStartMoment(props); + const endMoment = this._calculateEndMoment(props); + + this._sub = this.props.dataSource + .buildObservable({ + disabledCalendars: props.disabledCalendars, + startTime: startMoment.unix(), + endTime: endMoment.unix(), + }) + .subscribe(state => { + this.setState(state); + }); + + this.setState({ startMoment, endMoment }); + + const percent = + (this._scrollTime - startMoment.unix()) / (endMoment.unix() - startMoment.unix()); + if (percent < 0 || percent > 1) { + this._scrollTime = startMoment.unix(); + } else { + const weekStart = moment(props.currentMoment) + .startOf('day') + .weekday(0) + .unix(); + this._scrollTime = weekStart; + } + } + + _calculateStartMoment(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 = props.currentMoment.tz(); + if (tz) { + start = moment.tz([props.currentMoment.year()], tz); + } else { + start = moment([props.currentMoment.year()]); + } + + start = start + .weekday(0) + .week(props.currentMoment.week()) + .subtract(BUFFER_DAYS, 'days'); + return start; + } + + _calculateEndMoment(props) { + const end = moment(this._calculateStartMoment(props)) + .add(BUFFER_DAYS * 2 + DAYS_IN_VIEW, 'days') + .subtract(1, 'millisecond'); + return end; + } + + _renderDateLabel = day => { + const className = classnames({ + 'day-label-wrap': true, + 'is-today': this._isToday(day), + }); + return ( +
+ {day.format('D')} + {day.format('ddd')} +
+ ); + }; + + _isToday(day) { + return this.todayDayOfYear === day.dayOfYear() && this.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.state.startMoment; + 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; + } + + _currentWeekText() { + const start = moment(this.state.startMoment).add(BUFFER_DAYS, 'days'); + const end = moment(this.state.endMoment).subtract(BUFFER_DAYS, 'days'); + return `${start.format('MMMM D')} - ${end.format('MMMM D YYYY')}`; + } + + _headerComponents() { + const left = ( + + ); + 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.target.scrollLeft) { + return; + } + const percent = event.target.scrollLeft / event.target.scrollWidth; + const weekStart = this.state.startMoment.unix(); + const weekEnd = this.state.endMoment.unix(); + this._scrollTime = weekStart + (weekEnd - weekStart) * percent; + + if (percent < 0.25) { + this._onClickPrevWeek(); + } else if (percent + DAYS_IN_VIEW / (BUFFER_DAYS * 2 + DAYS_IN_VIEW) > 0.95) { + this._onClickNextWeek(); + } + this._ensureHorizontalScrollPos(); + }; + + _ensureHorizontalScrollPos() { + if (!this._scrollTime) return; + const weekStart = this.state.startMoment.unix(); + const weekEnd = this.state.endMoment.unix(); + let percent = (this._scrollTime - weekStart) / (weekEnd - weekStart); + percent = Math.min(Math.max(percent, 0), 1); + const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap); + wrap.scrollLeft = wrap.scrollWidth * percent; + } + + _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(); + + 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..db881d3a9 --- /dev/null +++ b/app/internal_packages/main-calendar/lib/main.jsx @@ -0,0 +1,66 @@ +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { WorkspaceStore, ComponentRegistry } from 'mailspring-exports'; +import CalendarWrapper from './calendar-wrapper'; +import QuickEventButton from './quick-event-button'; + +// +// function resolveHelperPath(callback) { +// const resourcesPath = AppEnv.getLoadSettings().resourcePath; +// let pathToCalendarApp = path.join(resourcesPath, '..', 'Nylas Calendar.app'); +// +// fs.exists(pathToCalendarApp, (exists) => { +// if (exists) { +// callback(pathToCalendarApp); +// return; +// } +// +// pathToCalendarApp = path.join(resourcesPath, 'build', 'resources', 'mac', 'Nylas Calendar.app'); +// fs.exists(pathToCalendarApp, (fallbackExists) => { +// if (fallbackExists) { +// callback(pathToCalendarApp); +// return; +// } +// callback(null); +// }); +// }); +// } + +export function activate() { + // if (process.platform === 'darwin') { + // resolveHelperPath((helperPath) => { + // if (!helperPath) { + // return; + // } + + // exec(`chmod +x "${helperPath}/Contents/MacOS/Nylas Calendar"`, () => { + // exec(`open "${helperPath}"`); + // }); + + // if (!AppEnv.config.get('addedToDockCalendar')) { + // exec(`defaults write com.apple.dock persistent-apps -array-add "tile-datafile-data_CFURLString${helperPath}/_CFURLStringType0"`, () => { + // AppEnv.config.set('addedToDockCalendar', true); + // exec(`killall Dock`); + // }); + // } + // }); + + // AppEnv.onBeforeUnload(() => { + // exec('killall "Nylas Calendar"'); + // return true; + // }); + // } + + ComponentRegistry.register(CalendarWrapper, { + location: WorkspaceStore.Location.Center, + }); + // 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..b4d3c176d --- /dev/null +++ b/app/internal_packages/main-calendar/styles/main-calendar.less @@ -0,0 +1,67 @@ +// The ui-variables file is provided by base themes provided by N1. +@import 'ui-variables'; +@import 'ui-mixins'; + +.main-calendar { + height: 100%; + + .event-grid-legend { + border-left: 1px solid @border-color-divider; + } +} + +.calendar-event-popover { + color: fadeout(@text-color, 20%); + background-color: @background-primary; + display: flex; + flex-direction: column; + font-size: @font-size-small; + width: 300px; + + .location { + color: @text-color-very-subtle; + padding: @padding-base-vertical @padding-base-horizontal; + word-wrap: break-word; + } + .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; + } + .edit-icon { + background-color: @text-color-inverse; + cursor: pointer; + } + .description .scroll-region-content { + max-height: 300px; + word-wrap: break-word; + 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; +} 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..1e105a12e --- /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: 20px; + padding-top: 6px; + } + .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..fbdd87b00 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1564,6 +1564,22 @@ "wide-align": "^1.1.0" } }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "requires": { + "is-property": "^1.0.2" + } + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "requires": { + "is-property": "^1.0.0" + } + }, "get-document": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-document/-/get-document-1.0.0.tgz", @@ -1798,6 +1814,184 @@ "sshpk": "^1.7.0" } }, + "ical": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ical/-/ical-0.5.0.tgz", + "integrity": "sha1-caDliqDMMAGdol8pKG4MAzYBsG8=", + "requires": { + "request": "2.68.0", + "rrule": "2.0.0" + }, + "dependencies": { + "assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=" + }, + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + }, + "aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=" + }, + "bl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.0.3.tgz", + "integrity": "sha1-/FQhoo/UImA2w7OJGmaiW8ZNIm4=", + "requires": { + "readable-stream": "~2.0.5" + } + }, + "boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", + "requires": { + "hoek": "2.x.x" + } + }, + "caseless": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", + "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=" + }, + "cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", + "requires": { + "boom": "2.x.x" + } + }, + "form-data": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.1.tgz", + "integrity": "sha1-rjFduaSQf6BlUCMEpm13M0de43w=", + "requires": { + "async": "^2.0.1", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.11" + } + }, + "har-validator": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "integrity": "sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0=", + "requires": { + "chalk": "^1.1.1", + "commander": "^2.9.0", + "is-my-json-valid": "^2.12.4", + "pinkie-promise": "^2.0.0" + } + }, + "hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", + "requires": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + } + }, + "hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" + }, + "http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", + "requires": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "node-uuid": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz", + "integrity": "sha1-sEDrCSOWivq/jTL7HxfxFn/auQc=" + }, + "qs": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.0.4.tgz", + "integrity": "sha1-UQGdhHIMk5uCc36EVWp4Izjs6ns=" + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "request": { + "version": "2.68.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.68.0.tgz", + "integrity": "sha1-Nf5qXNGEOTpHfJWwclYE2CL7kQU=", + "requires": { + "aws-sign2": "~0.6.0", + "bl": "~1.0.0", + "caseless": "~0.11.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~1.0.0-rc3", + "har-validator": "~2.0.6", + "hawk": "~3.1.0", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "node-uuid": "~1.4.7", + "oauth-sign": "~0.8.0", + "qs": "~6.0.2", + "stringstream": "~0.0.4", + "tough-cookie": "~2.2.0", + "tunnel-agent": "~0.4.1" + } + }, + "sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", + "requires": { + "hoek": "2.x.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "tough-cookie": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.2.2.tgz", + "integrity": "sha1-yDoYMPTl7wuT7yo0iOck+N4Basc=" + }, + "tunnel-agent": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", + "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=" + } + } + }, "iconv-lite": { "version": "0.4.19", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", @@ -1924,6 +2118,23 @@ "ip-regex": "^2.0.0" } }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==" + }, + "is-my-json-valid": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.19.0.tgz", + "integrity": "sha512-mG0f/unGX1HZ5ep4uhRaPOS8EkAY8/j6mDRMJrutq4CqhoJWYp7qAlonIPy3TV7p3ju4TK9fo/PbnoksWmsp5Q==", + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, "is-number-object": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.3.tgz", @@ -1958,6 +2169,11 @@ "isobject": "^3.0.1" } }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=" + }, "is-redirect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", @@ -2132,6 +2348,11 @@ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "optional": true }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=" + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -3112,6 +3333,19 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "^2.0.0" + } + }, "postcss": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", @@ -3629,6 +3863,14 @@ } } }, + "rrule": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.0.0.tgz", + "integrity": "sha1-xUp1/8iGcd1y/kWVVqa89s8s8m4=", + "requires": { + "underscore": ">= 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..918493d1d 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,10 @@ "fs-plus": "^2.3.2", "getmac": "^1.2.1", "graceful-fs": "^4.1.11", + "ical": "^0.5.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..096bde3b0 100644 --- a/app/src/browser/application.es6 +++ b/app/src/browser/application.es6 @@ -356,6 +356,10 @@ export default class Application extends EventEmitter { } }); + this.on('application:show-calendar', () => { + this.windowManager.ensureWindow(WindowManager.CALENDAR_WINDOW, {}); + }); + 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/models/event.es6 b/app/src/flux/models/event.es6 index d3d13a5e8..0d063418c 100644 --- a/app/src/flux/models/event.es6 +++ b/app/src/flux/models/event.es6 @@ -1,4 +1,4 @@ -import moment from 'moment'; +import ical from 'ical'; import Model from './model'; import Attributes from '../attributes'; @@ -8,99 +8,20 @@ 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', }), // The calculated Unix start time. See the implementation for how we // treat each type of "when" attribute. start: Attributes.Number({ queryable: true, - modelKey: 'start', + modelKey: '_start', jsonKey: '_start', }), @@ -108,7 +29,7 @@ export default class Event extends Model { // treat each type of "when" attribute. end: Attributes.Number({ queryable: true, - modelKey: 'end', + modelKey: '_end', jsonKey: '_end', }), @@ -133,62 +54,52 @@ 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(), - }; - } + busy = false; + + title = ''; + + description = ''; + + status = ''; + + location = ''; + + participants = []; + + organizer = null; fromJSON(json) { super.fromJSON(json); - const when = this.when; + const ics = ical.parseICS(json.ics); + const event = ics[Object.keys(ics).find(key => ics[key].type === 'VEVENT')]; - 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', + if (event) { + this.title = event.summary; + this.description = event.description; + this.status = event.status; + this.location = event.location; + this.busy = event.transparency === 'OPAQUE'; + this.participants = []; + if (event.organizer) { + this.organizer = { + name: event.organizer.params ? event.organizer.params.CN : undefined, + email: event.organizer.val, }; + } + let attendees = event.attendee; + if (attendees && !(attendees instanceof Array)) { + attendees = [attendees]; + } + (attendees || []).forEach(attendee => { + this.participants.push({ + name: attendee.params ? attendee.params.CN : undefined, + status: attendee.params ? attendee.params.PARTSTAT : undefined, + email: attendee.val, + }); }); } + return this; } @@ -201,7 +112,7 @@ export default class Event extends Model { 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; From 295300361ba0a7d22803301d8d509ef0495b21b1 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Sat, 9 Feb 2019 11:52:39 -0800 Subject: [PATCH 2/5] =?UTF-8?q?Switch=20to=20Mozilla=E2=80=99s=20ical.js?= =?UTF-8?q?=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/calendar-event-popover.jsx | 36 +-- ...ts-input.jsx => event-attendees-input.jsx} | 24 +- .../main-calendar/lib/core/week-view.jsx | 133 ++++------ app/package-lock.json | 245 +----------------- app/package.json | 2 +- app/src/flux/models/event.es6 | 52 ++-- 6 files changed, 109 insertions(+), 383 deletions(-) rename app/internal_packages/main-calendar/lib/core/{event-participants-input.jsx => event-attendees-input.jsx} (89%) 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 index d31b39e56..475049a9e 100644 --- a/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx +++ b/app/internal_packages/main-calendar/lib/core/calendar-event-popover.jsx @@ -14,7 +14,7 @@ import { TabGroupRegion, TimePicker, } from 'mailspring-component-kit'; -import EventParticipantsInput from './event-participants-input'; +import EventAttendeesInput from './event-attendees-input'; export default class CalendarEventPopover extends React.Component { static propTypes = { @@ -23,19 +23,19 @@ export default class CalendarEventPopover extends React.Component { constructor(props) { super(props); - const { description, start, end, location, participants } = this.props.event; + 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.participants = participants || []; + this.state.attendees = attendees || []; } componentWillReceiveProps = nextProps => { - const { description, start, end, location, participants } = nextProps.event; + const { description, start, end, location, attendees } = nextProps.event; this.setState({ description, start, end, location }); this.setState({ - participants: participants || [], + attendees: attendees || [], title: nextProps.event.displayTitle(), }); }; @@ -49,7 +49,7 @@ export default class CalendarEventPopover extends React.Component { saveEdits = () => { const event = this.props.event.clone(); - const keys = ['title', 'description', 'location', 'participants']; + const keys = ['title', 'description', 'location', 'attendees']; for (const key of keys) { event[key] = this.state[key]; } @@ -99,8 +99,8 @@ export default class CalendarEventPopover extends React.Component { return momentTime.format('h:mm A'); } - updateParticipants = participants => { - this.setState({ participants }); + updateAttendees = attendees => { + this.setState({ attendees }); }; updateField = (key, value) => { @@ -180,16 +180,8 @@ export default class CalendarEventPopover extends React.Component { ); } - renderParticipants(participants) { - const names = []; - for (let i = 0; i < participants.length; i++) { - names.push(
{participants[i].name}
); - } - return names; - } - renderEditable = () => { - const { title, description, start, end, location, participants } = this.state; + const { title, description, start, end, location, attendees } = this.state; const fragment = document.createDocumentFragment(); const descriptionRoot = document.createElement('root'); @@ -222,11 +214,11 @@ export default class CalendarEventPopover extends React.Component {
{this.renderEditableTime(start, end)}
Invitees:
- { - this.updateField('participants', val); + this.updateField('attendees', val); }} />
@@ -251,7 +243,7 @@ export default class CalendarEventPopover extends React.Component { if (this.state.editing) { return this.renderEditable(); } - const { title, description, location, participants } = this.state; + const { title, description, location, attendees } = this.state; const fragment = document.createDocumentFragment(); const descriptionRoot = document.createElement('root'); @@ -276,7 +268,7 @@ export default class CalendarEventPopover extends React.Component {
{this.renderTime()}
Invitees:
- {this.renderParticipants(participants)} + {attendees.map((a, idx) =>
{a.cn}
)}
diff --git a/app/internal_packages/main-calendar/lib/core/event-participants-input.jsx b/app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx similarity index 89% rename from app/internal_packages/main-calendar/lib/core/event-participants-input.jsx rename to app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx index 4a58977c8..aa0cb994b 100644 --- a/app/internal_packages/main-calendar/lib/core/event-participants-input.jsx +++ b/app/internal_packages/main-calendar/lib/core/event-attendees-input.jsx @@ -4,10 +4,10 @@ import { React, PropTypes, Utils, Contact, ContactStore, RegExpUtils } from 'mai import { TokenizingTextField, Menu, InjectedComponentSet } from 'mailspring-component-kit'; const TokenRenderer = props => { - const { email, name } = props.token; + const { email, cn } = props.token; let chipText = email; - if (name && name.length > 0 && name !== email) { - chipText = name; + if (cn && cn.length > 0 && cn !== email) { + chipText = cn; } return (
@@ -26,11 +26,11 @@ TokenRenderer.propTypes = { token: PropTypes.object, }; -export default class EventParticipantsInput extends React.Component { - static displayName = 'EventParticipantsInput'; +export default class EventAttendeesInput extends React.Component { + static displayName = 'EventAttendeesInput'; static propTypes = { - participants: PropTypes.array.isRequired, + attendees: PropTypes.array.isRequired, change: PropTypes.func.isRequired, className: PropTypes.string, onEmptied: PropTypes.func, @@ -71,17 +71,17 @@ export default class EventParticipantsInput extends React.Component { _remove = values => { const updates = _.reject( - this.props.participants, + 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.participants.indexOf(token); + const tokenIndex = this.props.attendees.indexOf(token); this._tokensForString(replacementString).then(replacements => { - const updates = this.props.participants.slice(0); + const updates = this.props.attendees.slice(0); updates.splice(tokenIndex, 1, ...replacements); this.props.change(updates); }); @@ -102,11 +102,11 @@ export default class EventParticipantsInput extends React.Component { // 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.participants.slice(0); + 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 participants. + // only appear once, in case it already exists in the attendees. updates = _.union(updates, [token]); } @@ -160,7 +160,7 @@ export default class EventParticipantsInput extends React.Component { p.email} tokenIsValid={p => ContactStore.isValidContact(p)} tokenRenderer={TokenRenderer} diff --git a/app/internal_packages/main-calendar/lib/core/week-view.jsx b/app/internal_packages/main-calendar/lib/core/week-view.jsx index ba9c53ae7..b50739534 100644 --- a/app/internal_packages/main-calendar/lib/core/week-view.jsx +++ b/app/internal_packages/main-calendar/lib/core/week-view.jsx @@ -52,6 +52,8 @@ export default class WeekView extends React.Component { footerComponents: false, }; + _waitingForShift = 0; + constructor(props) { super(props); this.state = { @@ -60,29 +62,24 @@ export default class WeekView extends React.Component { }; } - componentWillMount() { - this._initializeComponent(this.props); - } - componentDidMount() { this._mounted = true; this._centerScrollRegion(); this._setIntervalHeight(); - const weekStart = moment(this.state.startMoment) - .add(BUFFER_DAYS, 'days') - .unix(); - this._scrollTime = weekStart; - this._ensureHorizontalScrollPos(); window.addEventListener('resize', this._setIntervalHeight, true); + const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap); + wrap.scrollLeft += wrap.clientWidth; + this.updateSubscription(); } - componentWillReceiveProps(props) { - this._initializeComponent(props); - } - - componentDidUpdate() { + componentDidUpdate(prevProps) { this._setIntervalHeight(); - this._ensureHorizontalScrollPos(); + const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap); + wrap.scrollLeft += this._waitingForShift; + this._waitingForShift = 0; + if (prevProps.currentMoment !== this.props.currentMoment) { + this.updateSubscription(); + } } componentWillUnmount() { @@ -96,43 +93,26 @@ export default class WeekView extends React.Component { return moment(); } - _initializeComponent(props) { - this.todayYear = this._now().year(); - this.todayDayOfYear = this._now().dayOfYear(); - + updateSubscription() { if (this._sub) { this._sub.dispose(); } - const startMoment = this._calculateStartMoment(props); - const endMoment = this._calculateEndMoment(props); + const { start, end } = this._calculateMomentRange(); this._sub = this.props.dataSource .buildObservable({ - disabledCalendars: props.disabledCalendars, - startTime: startMoment.unix(), - endTime: endMoment.unix(), + disabledCalendars: this.props.disabledCalendars, + startTime: start.unix(), + endTime: end.unix(), }) .subscribe(state => { this.setState(state); }); - - this.setState({ startMoment, endMoment }); - - const percent = - (this._scrollTime - startMoment.unix()) / (endMoment.unix() - startMoment.unix()); - if (percent < 0 || percent > 1) { - this._scrollTime = startMoment.unix(); - } else { - const weekStart = moment(props.currentMoment) - .startOf('day') - .weekday(0) - .unix(); - this._scrollTime = weekStart; - } } - _calculateStartMoment(props) { + _calculateMomentRange() { + const { currentMoment } = this.props; let start; // NOTE: Since we initialize a new time from one of the properties of @@ -140,34 +120,32 @@ export default class WeekView extends React.Component { // // Other relative operations (like adding or subtracting time) are // independent of a timezone. - const tz = props.currentMoment.tz(); + const tz = currentMoment.tz(); if (tz) { - start = moment.tz([props.currentMoment.year()], tz); + start = moment.tz([currentMoment.year()], tz); } else { - start = moment([props.currentMoment.year()]); + start = moment([currentMoment.year()]); } start = start .weekday(0) - .week(props.currentMoment.week()) + .week(currentMoment.week()) .subtract(BUFFER_DAYS, 'days'); - return start; - } - _calculateEndMoment(props) { - const end = moment(this._calculateStartMoment(props)) + const end = moment(start) .add(BUFFER_DAYS * 2 + DAYS_IN_VIEW, 'days') .subtract(1, 'millisecond'); - return end; + + return { start, end }; } - _renderDateLabel = day => { + _renderDateLabel = (day, idx) => { const className = classnames({ 'day-label-wrap': true, 'is-today': this._isToday(day), }); return ( -
+
{day.format('D')} {day.format('ddd')}
@@ -175,7 +153,10 @@ export default class WeekView extends React.Component { }; _isToday(day) { - return this.todayDayOfYear === day.dayOfYear() && this.todayYear === day.year(); + const todayYear = this._now().year(); + const todayDayOfYear = this._now().dayOfYear(); + + return todayDayOfYear === day.dayOfYear() && todayYear === day.year(); } _renderEventColumn = (eventsByDay, day) => { @@ -282,7 +263,7 @@ export default class WeekView extends React.Component { } _daysInView() { - const start = this.state.startMoment; + 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 @@ -292,12 +273,6 @@ export default class WeekView extends React.Component { return days; } - _currentWeekText() { - const start = moment(this.state.startMoment).add(BUFFER_DAYS, 'days'); - const end = moment(this.state.endMoment).subtract(BUFFER_DAYS, 'days'); - return `${start.format('MMMM D')} - ${end.format('MMMM D YYYY')}`; - } - _headerComponents() { const left = (