Revive old calendar prototype, link against new C++ sync engine

This commit is contained in:
Ben Gotow 2019-02-09 02:29:23 -08:00
parent 1677bd7ed0
commit d9bca1941f
38 changed files with 3478 additions and 137 deletions

View file

@ -0,0 +1 @@
# composer package

View file

@ -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(<CalendarEventPopover event={eventModel} />, {
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 (
<KeyCommandsRegion
className="main-calendar"
localHandlers={{
'core:remove-from-view': this._onDeleteSelectedEvents,
}}
>
<NylasCalendar
dataSource={this._dataSource}
onEventClick={this._onEventClick}
onEventDoubleClick={this._onEventDoubleClick}
onEventFocused={this._onEventFocused}
selectedEvents={this.state.selectedEvents}
/>
</KeyCommandsRegion>
);
}
}

View file

@ -0,0 +1,4 @@
export const DAY_VIEW = 'day';
export const WEEK_VIEW = 'week';
export const MONTH_VIEW = 'month';
export const YEAR_VIEW = 'year';

View file

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

View file

@ -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 (
<div
className="calendar-mouse-handler"
onMouseUp={this._onCalendarMouseUp}
onMouseDown={this._onCalendarMouseDown}
onMouseMove={this._onCalendarMouseMove}
>
{this.props.children}
</div>
);
}
}

View file

@ -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 (
<div>
{date}
<br />
{timeRange}
</div>
);
}
renderEditableTime() {
const startVal = this.state.start * 1000;
const endVal = this.state.end * 1000;
return (
<div className="row time">
<RetinaImg name="ic-eventcard-time@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
<span>
<TimePicker value={startVal} onChange={this._onChangeStartTime} />
to
<TimePicker value={endVal} onChange={this._onChangeEndTime} />
<span className="timezone">
{moment()
.tz(DateUtils.timeZone)
.format('z')}
</span>
&nbsp; on &nbsp;
<DatePicker value={startVal} onChange={this._onChangeDay} />
</span>
</div>
);
}
renderParticipants(participants) {
const names = [];
for (let i = 0; i < participants.length; i++) {
names.push(<div key={i}> {participants[i].name} </div>);
}
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 (
<div className="calendar-event-popover" tabIndex="0">
<TabGroupRegion>
<div className="title-wrapper">
<input
className="title"
type="text"
value={title}
onChange={e => {
this.updateField('title', e.target.value);
}}
/>
</div>
<input
className="location"
type="text"
value={location}
onChange={e => {
this.updateField('location', e.target.value);
}}
/>
<div className="section">{this.renderEditableTime(start, end)}</div>
<div className="section">
<div className="label">Invitees: </div>
<EventParticipantsInput
className="event-participant-field"
participants={participants}
change={val => {
this.updateField('participants', val);
}}
/>
</div>
<div className="section">
<div className="label">Notes: </div>
<input
type="text"
value={notes}
onChange={e => {
this.updateField('description', e.target.value);
}}
/>
</div>
<span onClick={this.saveEdits}>Save</span>
<span onClick={() => Actions.closePopover()}>Cancel</span>
</TabGroupRegion>
</div>
);
};
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 (
<div className="calendar-event-popover" tabIndex="0">
<div className="title-wrapper">
<div className="title">{title}</div>
<RetinaImg
className="edit-icon"
name="edit-icon.png"
title="Edit Item"
mode={RetinaImg.Mode.ContentIsMask}
onClick={this.onEdit}
/>
</div>
<div className="location">{location}</div>
<div className="section">{this.renderTime()}</div>
<div className="section">
<div className="label">Invitees: </div>
{this.renderParticipants(participants)}
</div>
<div className="section">
<div className="description">
<div className="label">Notes: </div>
<ScrollRegion>
<div>{notes}</div>
</ScrollRegion>
</div>
</div>
</div>
);
}
}

View file

@ -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 (
<div
id={event.id}
tabIndex={0}
style={this._getStyles()}
className={`calendar-event ${direction} ${selected ? 'selected' : null}`}
onClick={e => onClick(e, event)}
onDoubleClick={() => onDoubleClick(event)}
>
<span className="default-header" style={{ order: 0 }}>
{event.displayTitle()}
</span>
<InjectedComponentSet
className="event-injected-components"
style={{ position: 'absolute' }}
matching={{ role: 'Calendar:Event' }}
exposedProps={{ event: event }}
direction="row"
/>
</div>
);
}
}

View file

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

View file

@ -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 (
<div
title={calendar.name}
onClick={onClick}
className="toggle-wrap"
key={`check-${calendar.id}`}
>
<div className={checkboxClass}>
<div className="bg-color" style={{ backgroundColor: bgColor }} />
</div>
<label>{calendar.name}</label>
</div>
);
});
}
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(
<div key={accountId} className="account-calendars-wrap">
<div className="account-label">{account.label}</div>
{renderCalendarToggles(calendars, props.disabledCalendars)}
</div>
);
}
return <div className="calendar-toggles-wrap">{accountSections}</div>;
}
CalendarToggles.propTypes = {
accounts: PropTypes.array,
calendars: PropTypes.array,
disabledCalendars: PropTypes.array,
};

View file

@ -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 ? (
<div style={{ left: `${Math.round(todayColumnIdx * 100 / numColumns)}%` }} />
) : null;
return (
<div
className={classNames({ 'current-time-indicator': true, visible: visible })}
style={{ top: gridHeight * (msecIntoDay / msecsPerDay) }}
>
{todayMarker}
</div>
);
}
}

View file

@ -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 (
<div className="event-grid-bg-wrap">
<div ref="cursor" className="cursor" />
<canvas ref="canvas" className="event-grid-bg" style={styles} />
</div>
);
}
}

View file

@ -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 (
<div className="participant">
<InjectedComponentSet
matching={{ role: 'Composer:RecipientChip' }}
exposedProps={{ contact: props.token }}
direction="column"
inline
/>
<span className="participant-primary">{chipText}</span>
</div>
);
};
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 <Menu.NameEmailItem name={p.name} email={p.email} />;
};
_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 (
<TokenizingTextField
className={this.props.className}
ref="textField"
tokens={this.props.participants}
tokenKey={p => 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}
/>
);
}
}

View file

@ -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 <span />;
return (
<SearchBar
query={query}
suggestions={suggestions}
placeholder="Search all events"
suggestionKey={event => event.id}
suggestionRenderer={this.renderEvent}
onSearchQueryChanged={this.onSearchQueryChanged}
onSelectSuggestion={this.onSelectEvent}
onClearSearchQuery={this.onClearSearchQuery}
onClearSearchSuggestions={this.onClearSearchSuggestions}
/>
);
}
}
export default EventSearchBar;

View file

@ -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 (
<div className="footer-controls">
<div className="spacer" style={{ order: 0, flex: 1 }}>
&nbsp;
</div>
{this.props.footerComponents}
</div>
);
}
}

View file

@ -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 (
<button className="btn btn-icon next" ref="onNextAction" onClick={this.props.nextAction}>
<RetinaImg name="ic-calendar-right-arrow.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
);
}
_renderPrevAction() {
if (!this.props.prevAction) {
return false;
}
return (
<button className="btn btn-icon prev" ref="onPreviousAction" onClick={this.props.prevAction}>
<RetinaImg name="ic-calendar-left-arrow.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
);
}
render() {
return (
<div className="header-controls">
<div className="center-controls">
{this._renderPrevAction()}
<span className="title">{this.props.title}</span>
{this._renderNextAction()}
</div>
{this.props.headerComponents}
</div>
);
}
}

View file

@ -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(
<span key={i} className="weekday">
{dayStr}
</span>
);
}
return <div className="legend">{legendEls}</div>;
}
_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(
<div className={className} key={`${week}-${weekday}`} data-timestamp={dayIter.valueOf()}>
{dayStr}
</div>
);
}
weekEls.push(
<div className="week" key={week}>
{dayEls}
</div>
);
}
return (
<div className="day-grid" onClick={this._onClickDay}>
{weekEls}
</div>
);
}
render() {
return (
<div className="mini-month-view">
<div className="header">
<div className="btn btn-icon" onClick={_.partial(this._changeMonth, -1)}>
&lsaquo;
</div>
<span className="month-title">{this._shownMonthMoment().format('MMMM YYYY')}</span>
<div className="btn btn-icon" onClick={_.partial(this._changeMonth, 1)}>
&rsaquo;
</div>
</div>
{this._renderLegend()}
{this._renderDays()}
</div>
);
}
}

View file

@ -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 <button onClick={this._onClick}>Change to week</button>;
}
}

View file

@ -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 (
<div className="nylas-calendar">
<ResizableRegion
className="calendar-toggles"
initialWidth={200}
minWidth={200}
maxWidth={300}
handle={ResizableRegion.Handle.Right}
style={{ flexDirection: 'column' }}
>
<ScrollRegion style={{ flex: 1 }}>
<EventSearchBar
onSelectEvent={this._focusEvent}
disabledCalendars={this.state.disabledCalendars}
/>
<CalendarToggles
accounts={this.state.accounts}
calendars={this.state.calendars}
disabledCalendars={this.state.disabledCalendars}
/>
</ScrollRegion>
<div style={{ width: '100%' }}>
<MiniMonthView
value={this.state.currentMoment.valueOf()}
onChange={this._changeCurrentMomentFromValue}
/>
</div>
</ResizableRegion>
<CurrentView
dataSource={this.props.dataSource}
currentMoment={this.state.currentMoment}
focusedEvent={this.state.focusedEvent}
bannerComponents={this.props.bannerComponents[this.state.currentView]}
headerComponents={this.props.headerComponents[this.state.currentView]}
footerComponents={this.props.footerComponents[this.state.currentView]}
changeCurrentView={this._changeCurrentView}
disabledCalendars={this.state.disabledCalendars}
changeCurrentMoment={this._changeCurrentMoment}
onCalendarMouseUp={this.props.onCalendarMouseUp}
onCalendarMouseDown={this.props.onCalendarMouseDown}
onCalendarMouseMove={this.props.onCalendarMouseMove}
selectedEvents={this.props.selectedEvents}
onEventClick={this.props.onEventClick}
onEventDoubleClick={this.props.onEventDoubleClick}
onEventFocused={this.props.onEventFocused}
/>
</div>
);
}
}
NylasCalendar.WeekView = WeekView;

View file

@ -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 <div className="top-banner">{this.props.bannerComponents}</div>;
}
}

View file

@ -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 (
<CalendarEvent
event={e}
order={this.props.allDayOverlap[e.id].order}
key={e.id}
scopeStart={this.props.start}
scopeEnd={this.props.end}
direction="horizontal"
fixedSize={this.props.minorDim}
concurrentEvents={this.props.allDayOverlap[e.id].concurrentEvents}
/>
);
});
return (
<div className="all-day-events" style={{ height: this.props.height }}>
{eventComponents}
</div>
);
}
}

View file

@ -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 => (
<CalendarEvent
ref={`event-${e.id}`}
event={e}
selected={selectedEvents.includes(e)}
order={eventOverlap[e.id].order}
focused={focusedEvent ? focusedEvent.id === e.id : false}
key={e.id}
scopeEnd={dayEnd}
scopeStart={day.unix()}
concurrentEvents={eventOverlap[e.id].concurrentEvents}
onClick={onEventClick}
onDoubleClick={onEventDoubleClick}
onFocused={onEventFocused}
/>
));
}
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 (
<div
className={className}
key={this.props.day.valueOf()}
data-start={this.props.day.valueOf()}
data-end={end}
>
{this.renderEvents()}
</div>
);
}
}

View file

@ -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 (
<div className={className} key={day.valueOf()}>
<span className="date-label">{day.format('D')}</span>
<span className="weekday-label">{day.format('ddd')}</span>
</div>
);
};
_isToday(day) {
return this.todayDayOfYear === day.dayOfYear() && this.todayYear === day.year();
}
_renderEventColumn = (eventsByDay, day) => {
const dayUnix = day.unix();
const events = eventsByDay[dayUnix];
return (
<WeekViewEventColumn
day={day}
dayEnd={dayUnix + DAY_DUR - 1}
key={day.valueOf()}
events={events}
eventOverlap={this._eventOverlap(events)}
focusedEvent={this.props.focusedEvent}
selectedEvents={this.props.selectedEvents}
onEventClick={this.props.onEventClick}
onEventDoubleClick={this.props.onEventDoubleClick}
onEventFocused={this.props.onEventFocused}
/>
);
};
_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 = (
<button
key="today"
className="btn"
ref="todayBtn"
onClick={this._onClickToday}
style={{ position: 'absolute', left: 10 }}
>
Today
</button>
);
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(
<span className="legend-text" key={yPos} style={style}>
{hr}
</span>
);
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 (
<div className="calendar-view week-view">
<CalendarEventContainer
ref="calendarEventContainer"
onCalendarMouseUp={this.props.onCalendarMouseUp}
onCalendarMouseDown={this.props.onCalendarMouseDown}
onCalendarMouseMove={this.props.onCalendarMouseMove}
>
<TopBanner bannerComponents={this.props.bannerComponents} />
<HeaderControls
title={this._currentWeekText()}
ref="headerControls"
headerComponents={this._headerComponents()}
nextAction={this._onClickNextWeek}
prevAction={this._onClickPrevWeek}
/>
<div className="calendar-body-wrap">
<div className="calendar-legend">
<div
className="date-label-legend"
style={{ height: this._allDayEventHeight(allDayOverlap) + 75 + 1 }}
>
<span className="legend-text">All Day</span>
</div>
<div className="event-grid-legend-wrap" ref="eventGridLegendWrap">
<div className="event-grid-legend" style={{ height: gridHeight }}>
{this._renderEventGridLabels()}
</div>
</div>
</div>
<div
className="calendar-area-wrap"
ref="calendarAreaWrap"
onScroll={this._onScrollCalendarArea}
>
<div className="week-header" style={{ width: `${this._bufferRatio() * 100}%` }}>
<div className="date-labels">{days.map(this._renderDateLabel)}</div>
<WeekViewAllDayEvents
ref="weekViewAllDayEvents"
minorDim={MIN_INTERVAL_HEIGHT}
end={this.state.endMoment.unix()}
height={this._allDayEventHeight(allDayOverlap)}
start={this.state.startMoment.unix()}
allDayEvents={eventsByDay.allDay}
allDayOverlap={allDayOverlap}
/>
</div>
<ScrollRegion
className="event-grid-wrap"
ref="eventGridWrap"
getScrollbar={() => this.refs.scrollbar}
onScroll={this._onScrollGrid}
style={{ width: `${this._bufferRatio() * 100}%` }}
>
<div className="event-grid" style={{ height: gridHeight }}>
{days.map(_.partial(this._renderEventColumn, eventsByDay))}
<CurrentTimeIndicator
visible={
todayColumnIdx > BUFFER_DAYS && todayColumnIdx <= BUFFER_DAYS + DAYS_IN_VIEW
}
gridHeight={gridHeight}
numColumns={BUFFER_DAYS * 2 + DAYS_IN_VIEW}
todayColumnIdx={todayColumnIdx}
/>
<EventGridBackground
height={gridHeight}
intervalHeight={this.state.intervalHeight}
numColumns={BUFFER_DAYS * 2 + DAYS_IN_VIEW}
ref="eventGridBg"
tickGenerator={tickGen}
/>
</div>
</ScrollRegion>
</div>
<ScrollRegion.Scrollbar
ref="scrollbar"
getScrollRegion={() => this.refs.eventGridWrap}
/>
</div>
<FooterControls footerComponents={this.props.footerComponents} />
</CalendarEventContainer>
</div>
);
}
}

View file

@ -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 <table> elements use
// the `border-collapse: collapse` css property while setting a
// `padding`.
doc.write('<!DOCTYPE html>');
doc.write(
`<div id='inbox-html-wrapper' class="${process.platform}">${this.props.content}</div>`
);
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 (
<div
className="iframe-container"
ref={el => {
this._iframeHeightHolderEl = el;
}}
style={{ height: this._lastComputedHeight }}
>
<EventedIFrame
ref={cm => {
this._iframeComponent = cm;
}}
seamless="seamless"
searchable
onResize={this._onMustRecalculateFrameHeight}
/>
</div>
);
}
}

View file

@ -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 "<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>${helperPath}/</string><key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>"`, () => {
// 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);
}

View file

@ -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(<QuickEventPopover />, { originRect: buttonRect, direction: 'down' });
};
render() {
return (
<button
style={{ order: -50 }}
tabIndex={-1}
className="btn btn-toolbar"
onClick={this.onClick}
>
+
</button>
);
}
}

View file

@ -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 = (
<span className="date-interpretation">
Title: {this.state.leftoverText} <br />
Start: {DateUtils.format(this.state.start, DateUtils.DATE_FORMAT_SHORT)} <br />
End: {DateUtils.format(this.state.end, DateUtils.DATE_FORMAT_SHORT)}
</span>
);
}
return (
<div className="quick-event-popover nylas-date-input">
<input
tabIndex="0"
type="text"
placeholder="Coffee next Monday at 9AM'"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
/>
{dateInterpretation}
</div>
);
}
}

View file

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

View file

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

View file

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

View file

@ -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' },

View file

@ -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' },

View file

@ -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' },

242
app/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

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

View file

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