mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
Merge branch 'feature/calendar-preview'
This commit is contained in:
commit
8945c5188e
39 changed files with 3225 additions and 156 deletions
1
app/internal_packages/main-calendar/README.md
Normal file
1
app/internal_packages/main-calendar/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# composer package
|
103
app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
Normal file
103
app/internal_packages/main-calendar/lib/calendar-wrapper.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export const DAY_VIEW = 'day';
|
||||
export const WEEK_VIEW = 'week';
|
||||
export const MONTH_VIEW = 'month';
|
||||
export const YEAR_VIEW = 'year';
|
|
@ -0,0 +1,57 @@
|
|||
import Rx from 'rx-lite';
|
||||
import { Event, Matcher, DatabaseStore } from 'mailspring-exports';
|
||||
import IcalExpander from 'ical-expander';
|
||||
|
||||
export default class CalendarDataSource {
|
||||
buildObservable({ startTime, endTime, disabledCalendars }) {
|
||||
const end = Event.attributes.recurrenceEnd;
|
||||
const start = Event.attributes.recurrenceStart;
|
||||
|
||||
let matcher = new Matcher.Or([
|
||||
new Matcher.And([start.lte(endTime), end.gte(startTime)]),
|
||||
new Matcher.And([start.lte(endTime), start.gte(startTime)]),
|
||||
new Matcher.And([end.gte(startTime), end.lte(endTime)]),
|
||||
new Matcher.And([end.gte(endTime), start.lte(startTime)]),
|
||||
]);
|
||||
|
||||
if (disabledCalendars && disabledCalendars.length) {
|
||||
new Matcher.And([matcher, Event.attributes.calendarId.notIn(disabledCalendars)]);
|
||||
}
|
||||
|
||||
const query = DatabaseStore.findAll(Event).where(matcher);
|
||||
this.observable = Rx.Observable.fromQuery(query).flatMapLatest(results => {
|
||||
const events = [];
|
||||
results.forEach(result => {
|
||||
const icalExpander = new IcalExpander({ ics: result.ics, maxIterations: 100 });
|
||||
const expanded = icalExpander.between(new Date(startTime * 1000), new Date(endTime * 1000));
|
||||
|
||||
[...expanded.events, ...expanded.occurrences].forEach((e, idx) => {
|
||||
const start = e.startDate.toJSDate().getTime() / 1000;
|
||||
const end = e.endDate.toJSDate().getTime() / 1000;
|
||||
const item = e.item || e;
|
||||
events.push({
|
||||
start,
|
||||
end,
|
||||
id: `${result.id}-e${idx}`,
|
||||
calendarId: result.calendarId,
|
||||
title: item.summary,
|
||||
displayTitle: item.summary,
|
||||
description: item.description,
|
||||
isAllDay: end - start >= 86400 - 1,
|
||||
organizer: item.organizer ? { email: item.organizer } : null,
|
||||
attendees: item.attendees.map(a => ({
|
||||
...a.jCal[1],
|
||||
email: a.getFirstValue(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
return Rx.Observable.from([{ events }]);
|
||||
});
|
||||
return this.observable;
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
return this.observable.subscribe(callback);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,282 @@
|
|||
import moment from 'moment';
|
||||
import {
|
||||
React,
|
||||
PropTypes,
|
||||
Actions,
|
||||
DatabaseStore,
|
||||
DateUtils,
|
||||
SyncbackEventTask,
|
||||
} from 'mailspring-exports';
|
||||
import {
|
||||
DatePicker,
|
||||
RetinaImg,
|
||||
ScrollRegion,
|
||||
TabGroupRegion,
|
||||
TimePicker,
|
||||
} from 'mailspring-component-kit';
|
||||
import EventAttendeesInput from './event-attendees-input';
|
||||
|
||||
export default class CalendarEventPopover extends React.Component {
|
||||
static propTypes = {
|
||||
event: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { description, start, end, location, attendees } = this.props.event;
|
||||
|
||||
this.state = { description, start, end, location };
|
||||
this.state.title = this.props.event.displayTitle;
|
||||
this.state.editing = false;
|
||||
this.state.attendees = attendees || [];
|
||||
}
|
||||
|
||||
componentWillReceiveProps = nextProps => {
|
||||
const { description, start, end, location, attendees } = nextProps.event;
|
||||
this.setState({ description, start, end, location });
|
||||
this.setState({
|
||||
attendees: attendees || [],
|
||||
title: nextProps.event.displayTitle,
|
||||
});
|
||||
};
|
||||
|
||||
onEdit = () => {
|
||||
this.setState({ editing: true });
|
||||
};
|
||||
|
||||
getStartMoment = () => moment(this.state.start * 1000);
|
||||
getEndMoment = () => moment(this.state.end * 1000);
|
||||
|
||||
saveEdits = () => {
|
||||
const event = this.props.event.clone();
|
||||
const keys = ['title', 'description', 'location', 'attendees'];
|
||||
for (const key of keys) {
|
||||
event[key] = this.state[key];
|
||||
}
|
||||
|
||||
// TODO, this component shouldn't save the event here, we should expose an
|
||||
// `onEditEvent` or similar callback
|
||||
// TODO: How will this affect the event if the when object was originally
|
||||
// a datespan, with start_date and end_date attributes?
|
||||
event.when.start_time = this.state.start;
|
||||
event.when.end_time = this.state.end;
|
||||
|
||||
DatabaseStore.inTransaction(t => {
|
||||
this.setState({ editing: false }); // TODO: where's the best place to put this?
|
||||
return t.persistModel(event);
|
||||
}).then(() => {
|
||||
const task = new SyncbackEventTask(event.id);
|
||||
Actions.queueTask(task);
|
||||
});
|
||||
};
|
||||
|
||||
extractNotesFromDescription(node) {
|
||||
const els = node.querySelectorAll('meta[itemprop=description]');
|
||||
let notes = null;
|
||||
if (els.length) {
|
||||
notes = Array.from(els)
|
||||
.map(el => el.content)
|
||||
.join('\n');
|
||||
} else {
|
||||
notes = node.innerText;
|
||||
}
|
||||
while (true) {
|
||||
const nextNotes = notes.replace('\n\n', '\n');
|
||||
if (nextNotes === notes) {
|
||||
break;
|
||||
}
|
||||
notes = nextNotes;
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
// If on the hour, formats as "3 PM", else formats as "3:15 PM"
|
||||
formatTime(momentTime) {
|
||||
const min = momentTime.minutes();
|
||||
if (min === 0) {
|
||||
return momentTime.format('h A');
|
||||
}
|
||||
return momentTime.format('h:mm A');
|
||||
}
|
||||
|
||||
updateAttendees = attendees => {
|
||||
this.setState({ attendees });
|
||||
};
|
||||
|
||||
updateField = (key, value) => {
|
||||
const updates = {};
|
||||
updates[key] = value;
|
||||
this.setState(updates);
|
||||
};
|
||||
|
||||
_onChangeDay = newTimestamp => {
|
||||
const newDay = moment(newTimestamp);
|
||||
const start = this.getStartMoment();
|
||||
const end = this.getEndMoment();
|
||||
start.year(newDay.year());
|
||||
end.year(newDay.year());
|
||||
start.dayOfYear(newDay.dayOfYear());
|
||||
end.dayOfYear(newDay.dayOfYear());
|
||||
this.setState({ start: start.unix(), end: end.unix() });
|
||||
};
|
||||
|
||||
_onChangeStartTime = newTimestamp => {
|
||||
const newStart = moment(newTimestamp);
|
||||
let newEnd = this.getEndMoment();
|
||||
if (newEnd.isSameOrBefore(newStart)) {
|
||||
const leftInDay = moment(newStart)
|
||||
.endOf('day')
|
||||
.diff(newStart);
|
||||
const move = Math.min(leftInDay, moment.duration(1, 'hour').asMilliseconds());
|
||||
newEnd = moment(newStart).add(move, 'ms');
|
||||
}
|
||||
this.setState({ start: newStart.unix(), end: newEnd.unix() });
|
||||
};
|
||||
|
||||
_onChangeEndTime = newTimestamp => {
|
||||
const newEnd = moment(newTimestamp);
|
||||
let newStart = this.getStartMoment();
|
||||
if (newStart.isSameOrAfter(newEnd)) {
|
||||
const sinceDay = moment(newEnd).diff(newEnd.startOf('day'));
|
||||
const move = Math.min(sinceDay, moment.duration(1, 'hour').asMilliseconds());
|
||||
newStart = moment(newEnd).subtract(move, 'ms');
|
||||
}
|
||||
this.setState({ end: newEnd.unix(), start: newStart.unix() });
|
||||
};
|
||||
|
||||
renderTime() {
|
||||
const startMoment = this.getStartMoment();
|
||||
const endMoment = this.getEndMoment();
|
||||
const date = startMoment.format('dddd, MMMM D'); // e.g. Tuesday, February 22
|
||||
const timeRange = `${this.formatTime(startMoment)} - ${this.formatTime(endMoment)}`;
|
||||
return (
|
||||
<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>
|
||||
on
|
||||
<DatePicker value={startVal} onChange={this._onChangeDay} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderEditable = () => {
|
||||
const { title, description, start, end, location, attendees } = this.state;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const descriptionRoot = document.createElement('root');
|
||||
fragment.appendChild(descriptionRoot);
|
||||
descriptionRoot.innerHTML = description;
|
||||
|
||||
const notes = this.extractNotesFromDescription(descriptionRoot);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<EventAttendeesInput
|
||||
className="event-participant-field"
|
||||
attendees={attendees}
|
||||
change={val => {
|
||||
this.updateField('attendees', 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, attendees } = 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>
|
||||
<ScrollRegion className="section invitees">
|
||||
<div className="label">Invitees: </div>
|
||||
<div>{attendees.map((a, idx) => <div key={idx}> {a.cn} </div>)}</div>
|
||||
</ScrollRegion>
|
||||
<ScrollRegion className="section description">
|
||||
<div className="description">
|
||||
<div className="label">Notes: </div>
|
||||
<div>{notes}</div>
|
||||
</div>
|
||||
</ScrollRegion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
126
app/internal_packages/main-calendar/lib/core/calendar-event.jsx
Normal file
126
app/internal_packages/main-calendar/lib/core/calendar-event.jsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { React, ReactDOM, PropTypes } from 'mailspring-exports';
|
||||
import { InjectedComponentSet } from 'mailspring-component-kit';
|
||||
import { calcColor } from './calendar-helpers';
|
||||
|
||||
export default class CalendarEvent extends React.Component {
|
||||
static displayName = 'CalendarEvent';
|
||||
|
||||
static propTypes = {
|
||||
event: PropTypes.object.isRequired,
|
||||
order: PropTypes.number,
|
||||
selected: PropTypes.bool,
|
||||
scopeEnd: PropTypes.number.isRequired,
|
||||
scopeStart: PropTypes.number.isRequired,
|
||||
direction: PropTypes.oneOf(['horizontal', 'vertical']),
|
||||
fixedSize: PropTypes.number,
|
||||
focused: PropTypes.bool,
|
||||
concurrentEvents: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
onDoubleClick: PropTypes.func,
|
||||
onFocused: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
order: 1,
|
||||
direction: 'vertical',
|
||||
fixedSize: -1,
|
||||
concurrentEvents: 1,
|
||||
onClick: () => {},
|
||||
onDoubleClick: () => {},
|
||||
onFocused: () => {},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._scrollFocusedEventIntoView();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._scrollFocusedEventIntoView();
|
||||
}
|
||||
|
||||
_scrollFocusedEventIntoView() {
|
||||
const { focused } = this.props;
|
||||
if (!focused) {
|
||||
return;
|
||||
}
|
||||
const eventNode = ReactDOM.findDOMNode(this);
|
||||
if (!eventNode) {
|
||||
return;
|
||||
}
|
||||
const { event, onFocused } = this.props;
|
||||
eventNode.scrollIntoViewIfNeeded(true);
|
||||
onFocused(event);
|
||||
}
|
||||
|
||||
_getDimensions() {
|
||||
const scopeLen = this.props.scopeEnd - this.props.scopeStart;
|
||||
const duration = this.props.event.end - this.props.event.start;
|
||||
|
||||
let top = Math.max((this.props.event.start - this.props.scopeStart) / scopeLen, 0);
|
||||
let height = Math.min((duration - this._overflowBefore()) / scopeLen, 1);
|
||||
|
||||
let width = 1;
|
||||
let left;
|
||||
if (this.props.fixedSize === -1) {
|
||||
width = 1 / this.props.concurrentEvents;
|
||||
left = width * (this.props.order - 1);
|
||||
width = `${width * 100}%`;
|
||||
left = `${left * 100}%`;
|
||||
} else {
|
||||
width = this.props.fixedSize;
|
||||
left = this.props.fixedSize * (this.props.order - 1);
|
||||
}
|
||||
|
||||
top = `${top * 100}%`;
|
||||
height = `${height * 100}%`;
|
||||
|
||||
return { left, width, height, top };
|
||||
}
|
||||
|
||||
_getStyles() {
|
||||
let styles = {};
|
||||
if (this.props.direction === 'vertical') {
|
||||
styles = this._getDimensions();
|
||||
} else if (this.props.direction === 'horizontal') {
|
||||
const d = this._getDimensions();
|
||||
styles = {
|
||||
left: d.top,
|
||||
width: d.height,
|
||||
height: d.width,
|
||||
top: d.left,
|
||||
};
|
||||
}
|
||||
styles.backgroundColor = calcColor(this.props.event.calendarId);
|
||||
return styles;
|
||||
}
|
||||
|
||||
_overflowBefore() {
|
||||
return Math.max(this.props.scopeStart - this.props.event.start, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { direction, event, onClick, onDoubleClick, selected } = this.props;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
import _ from 'underscore';
|
||||
import { remote, clipboard } from 'electron';
|
||||
import { React, PropTypes, Utils, Contact, ContactStore, RegExpUtils } from 'mailspring-exports';
|
||||
import { TokenizingTextField, Menu, InjectedComponentSet } from 'mailspring-component-kit';
|
||||
|
||||
const TokenRenderer = props => {
|
||||
const { email, cn } = props.token;
|
||||
let chipText = email;
|
||||
if (cn && cn.length > 0 && cn !== email) {
|
||||
chipText = cn;
|
||||
}
|
||||
return (
|
||||
<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 EventAttendeesInput extends React.Component {
|
||||
static displayName = 'EventAttendeesInput';
|
||||
|
||||
static propTypes = {
|
||||
attendees: PropTypes.array.isRequired,
|
||||
change: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
onEmptied: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
|
||||
}
|
||||
|
||||
// Public. Can be called by any component that has a ref to this one to
|
||||
// focus the input field.
|
||||
focus = () => {
|
||||
this.refs.textField.focus();
|
||||
};
|
||||
|
||||
_completionNode = p => {
|
||||
return <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.attendees,
|
||||
p => values.includes(p.email) || values.map(o => o.email).includes(p.email)
|
||||
);
|
||||
this.props.change(updates);
|
||||
};
|
||||
|
||||
_edit = (token, replacementString) => {
|
||||
const tokenIndex = this.props.attendees.indexOf(token);
|
||||
|
||||
this._tokensForString(replacementString).then(replacements => {
|
||||
const updates = this.props.attendees.slice(0);
|
||||
updates.splice(tokenIndex, 1, ...replacements);
|
||||
this.props.change(updates);
|
||||
});
|
||||
};
|
||||
|
||||
_add = (values, options = {}) => {
|
||||
// If the input is a string, parse out email addresses and build
|
||||
// an array of contact objects. For each email address wrapped in
|
||||
// parentheses, look for a preceding name, if one exists.
|
||||
let tokensPromise = null;
|
||||
if (typeof values === 'string') {
|
||||
tokensPromise = this._tokensForString(values, options);
|
||||
} else {
|
||||
tokensPromise = Promise.resolve(values);
|
||||
}
|
||||
|
||||
tokensPromise.then(tokens => {
|
||||
// Safety check: remove anything from the incoming tokens that isn't
|
||||
// a Contact. We should never receive anything else in the tokens array.
|
||||
const contactTokens = tokens.filter(value => value instanceof Contact);
|
||||
let updates = this.props.attendees.slice(0);
|
||||
|
||||
for (const token of contactTokens) {
|
||||
// add the participant to field. _.union ensures that the token will
|
||||
// only appear once, in case it already exists in the attendees.
|
||||
updates = _.union(updates, [token]);
|
||||
}
|
||||
|
||||
this.props.change(updates);
|
||||
});
|
||||
};
|
||||
|
||||
_onShowContextMenu = participant => {
|
||||
// Warning: Menu is already initialized as Menu.cjsx!
|
||||
const MenuClass = remote.Menu;
|
||||
const MenuItem = remote.MenuItem;
|
||||
|
||||
const menu = new MenuClass();
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: `Copy ${participant.email}`,
|
||||
click: () => clipboard.writeText(participant.email),
|
||||
})
|
||||
);
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
type: 'separator',
|
||||
})
|
||||
);
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: 'Remove',
|
||||
click: () => this._remove([participant]),
|
||||
})
|
||||
);
|
||||
menu.popup(remote.getCurrentWindow());
|
||||
};
|
||||
|
||||
_onInputTrySubmit = (inputValue, completions = [], selectedItem) => {
|
||||
if (RegExpUtils.emailRegex().test(inputValue)) {
|
||||
return inputValue; // no token default to raw value.
|
||||
}
|
||||
return selectedItem || completions[0]; // first completion if any
|
||||
};
|
||||
|
||||
_shouldBreakOnKeydown = event => {
|
||||
const val = event.target.value.trim();
|
||||
if (RegExpUtils.emailRegex().test(val) && event.key === ' ') {
|
||||
return true;
|
||||
}
|
||||
return [',', ';'].includes(event.key);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TokenizingTextField
|
||||
className={this.props.className}
|
||||
ref="textField"
|
||||
tokens={this.props.attendees}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 }}>
|
||||
|
||||
</div>
|
||||
{this.props.footerComponents}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
134
app/internal_packages/main-calendar/lib/core/mini-month-view.jsx
Normal file
134
app/internal_packages/main-calendar/lib/core/mini-month-view.jsx
Normal 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)}>
|
||||
‹
|
||||
</div>
|
||||
<span className="month-title">{this._shownMonthMoment().format('MMMM YYYY')}</span>
|
||||
<div className="btn btn-icon" onClick={_.partial(this._changeMonth, 1)}>
|
||||
›
|
||||
</div>
|
||||
</div>
|
||||
{this._renderLegend()}
|
||||
{this._renderDays()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
18
app/internal_packages/main-calendar/lib/core/month-view.jsx
Normal file
18
app/internal_packages/main-calendar/lib/core/month-view.jsx
Normal 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>;
|
||||
}
|
||||
}
|
206
app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx
Normal file
206
app/internal_packages/main-calendar/lib/core/nylas-calendar.jsx
Normal 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;
|
17
app/internal_packages/main-calendar/lib/core/top-banner.jsx
Normal file
17
app/internal_packages/main-calendar/lib/core/top-banner.jsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
540
app/internal_packages/main-calendar/lib/core/week-view.jsx
Normal file
540
app/internal_packages/main-calendar/lib/core/week-view.jsx
Normal file
|
@ -0,0 +1,540 @@
|
|||
/* eslint react/jsx-no-bind: 0 */
|
||||
import _ from 'underscore';
|
||||
import moment from 'moment-timezone';
|
||||
import classnames from 'classnames';
|
||||
import { React, ReactDOM, PropTypes, Utils } from 'mailspring-exports';
|
||||
import { ScrollRegion } from 'mailspring-component-kit';
|
||||
import TopBanner from './top-banner';
|
||||
import HeaderControls from './header-controls';
|
||||
import FooterControls from './footer-controls';
|
||||
import CalendarDataSource from './calendar-data-source';
|
||||
import EventGridBackground from './event-grid-background';
|
||||
import WeekViewEventColumn from './week-view-event-column';
|
||||
import WeekViewAllDayEvents from './week-view-all-day-events';
|
||||
import CalendarEventContainer from './calendar-event-container';
|
||||
import CurrentTimeIndicator from './current-time-indicator';
|
||||
|
||||
const BUFFER_DAYS = 7; // in each direction
|
||||
const DAYS_IN_VIEW = 7;
|
||||
const MIN_INTERVAL_HEIGHT = 21;
|
||||
const DAY_DUR = moment.duration(1, 'day').as('seconds');
|
||||
const INTERVAL_TIME = moment.duration(30, 'minutes').as('seconds');
|
||||
|
||||
// This pre-fetches from Utils to prevent constant disc access
|
||||
const overlapsBounds = Utils.overlapsBounds;
|
||||
|
||||
export default class WeekView extends React.Component {
|
||||
static displayName = 'WeekView';
|
||||
|
||||
static propTypes = {
|
||||
dataSource: PropTypes.instanceOf(CalendarDataSource).isRequired,
|
||||
currentMoment: PropTypes.instanceOf(moment).isRequired,
|
||||
focusedEvent: PropTypes.object,
|
||||
bannerComponents: PropTypes.node,
|
||||
headerComponents: PropTypes.node,
|
||||
footerComponents: PropTypes.node,
|
||||
disabledCalendars: PropTypes.array,
|
||||
changeCurrentView: PropTypes.func,
|
||||
changeCurrentMoment: PropTypes.func,
|
||||
onCalendarMouseUp: PropTypes.func,
|
||||
onCalendarMouseDown: PropTypes.func,
|
||||
onCalendarMouseMove: PropTypes.func,
|
||||
onEventClick: PropTypes.func,
|
||||
onEventDoubleClick: PropTypes.func,
|
||||
onEventFocused: PropTypes.func,
|
||||
selectedEvents: PropTypes.arrayOf(PropTypes.object),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
changeCurrentView: () => {},
|
||||
bannerComponents: false,
|
||||
headerComponents: false,
|
||||
footerComponents: false,
|
||||
};
|
||||
|
||||
_waitingForShift = 0;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
events: [],
|
||||
intervalHeight: MIN_INTERVAL_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
this._centerScrollRegion();
|
||||
this._setIntervalHeight();
|
||||
window.addEventListener('resize', this._setIntervalHeight, true);
|
||||
const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap);
|
||||
wrap.scrollLeft += wrap.clientWidth;
|
||||
this.updateSubscription();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this._setIntervalHeight();
|
||||
const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap);
|
||||
wrap.scrollLeft += this._waitingForShift;
|
||||
this._waitingForShift = 0;
|
||||
if (
|
||||
prevProps.currentMoment !== this.props.currentMoment ||
|
||||
prevProps.disabledCalendars !== this.props.disabledCalendars
|
||||
) {
|
||||
this.updateSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
this._sub.dispose();
|
||||
window.removeEventListener('resize', this._setIntervalHeight);
|
||||
}
|
||||
|
||||
// Indirection for testing purposes
|
||||
_now() {
|
||||
return moment();
|
||||
}
|
||||
|
||||
updateSubscription() {
|
||||
if (this._sub) {
|
||||
this._sub.dispose();
|
||||
}
|
||||
|
||||
const { start, end } = this._calculateMomentRange();
|
||||
|
||||
this._sub = this.props.dataSource
|
||||
.buildObservable({
|
||||
disabledCalendars: this.props.disabledCalendars,
|
||||
startTime: start.unix(),
|
||||
endTime: end.unix(),
|
||||
})
|
||||
.subscribe(state => {
|
||||
this.setState(state);
|
||||
});
|
||||
}
|
||||
|
||||
_calculateMomentRange() {
|
||||
const { currentMoment } = this.props;
|
||||
let start;
|
||||
|
||||
// NOTE: Since we initialize a new time from one of the properties of
|
||||
// the props.currentMomet, we need to check for the timezone!
|
||||
//
|
||||
// Other relative operations (like adding or subtracting time) are
|
||||
// independent of a timezone.
|
||||
const tz = currentMoment.tz();
|
||||
if (tz) {
|
||||
start = moment.tz([currentMoment.year()], tz);
|
||||
} else {
|
||||
start = moment([currentMoment.year()]);
|
||||
}
|
||||
|
||||
start = start
|
||||
.weekday(0)
|
||||
.week(currentMoment.week())
|
||||
.subtract(BUFFER_DAYS, 'days');
|
||||
|
||||
const end = moment(start)
|
||||
.add(BUFFER_DAYS * 2 + DAYS_IN_VIEW, 'days')
|
||||
.subtract(1, 'millisecond');
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
_renderDateLabel = (day, idx) => {
|
||||
const className = classnames({
|
||||
'day-label-wrap': true,
|
||||
'is-today': this._isToday(day),
|
||||
});
|
||||
return (
|
||||
<div className={className} key={idx}>
|
||||
<span className="date-label">{day.format('D')}</span>
|
||||
<span className="weekday-label">{day.format('ddd')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
_isToday(day) {
|
||||
const todayYear = this._now().year();
|
||||
const todayDayOfYear = this._now().dayOfYear();
|
||||
|
||||
return todayDayOfYear === day.dayOfYear() && todayYear === day.year();
|
||||
}
|
||||
|
||||
_renderEventColumn = (eventsByDay, day) => {
|
||||
const dayUnix = day.unix();
|
||||
const events = eventsByDay[dayUnix];
|
||||
return (
|
||||
<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._calculateMomentRange();
|
||||
const days = [];
|
||||
for (let i = 0; i < DAYS_IN_VIEW + BUFFER_DAYS * 2; i++) {
|
||||
// moment::weekday is locale aware since some weeks start on diff
|
||||
// days. See http://momentjs.com/docs/#/get-set/weekday/
|
||||
days.push(moment(start).weekday(i));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
_headerComponents() {
|
||||
const left = (
|
||||
<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.currentTarget.scrollLeft || this._waitingForShift) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edgeWidth = event.currentTarget.clientWidth / DAYS_IN_VIEW * 2;
|
||||
|
||||
if (event.currentTarget.scrollLeft < edgeWidth) {
|
||||
this._waitingForShift = event.currentTarget.clientWidth;
|
||||
this._onClickPrevWeek();
|
||||
} else if (
|
||||
event.currentTarget.scrollLeft >
|
||||
event.currentTarget.scrollWidth - event.currentTarget.clientWidth - edgeWidth
|
||||
) {
|
||||
this._waitingForShift = -event.currentTarget.clientWidth;
|
||||
this._onClickNextWeek();
|
||||
}
|
||||
};
|
||||
|
||||
_renderEventGridLabels() {
|
||||
const labels = [];
|
||||
let centering = 0;
|
||||
for (const { time, yPos } of this._tickGenerator({ type: 'major' })) {
|
||||
const hr = time.format('LT'); // Locale time. 2:00 pm or 14:00
|
||||
const style = { top: yPos - centering };
|
||||
labels.push(
|
||||
<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();
|
||||
|
||||
const { start: startMoment, end: endMoment } = this._calculateMomentRange();
|
||||
|
||||
const start = moment(startMoment).add(BUFFER_DAYS, 'days');
|
||||
const end = moment(endMoment).subtract(BUFFER_DAYS, 'days');
|
||||
const headerText = `${start.format('MMMM D')} - ${end.format('MMMM D YYYY')}`;
|
||||
|
||||
return (
|
||||
<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={headerText}
|
||||
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"
|
||||
onWheel={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={endMoment.unix()}
|
||||
height={this._allDayEventHeight(allDayOverlap)}
|
||||
start={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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
29
app/internal_packages/main-calendar/lib/main.jsx
Normal file
29
app/internal_packages/main-calendar/lib/main.jsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { WorkspaceStore, ComponentRegistry } from 'mailspring-exports';
|
||||
import CalendarWrapper from './calendar-wrapper';
|
||||
import QuickEventButton from './quick-event-button';
|
||||
|
||||
const Notice = () => (
|
||||
<div className="preview-notice">
|
||||
Calendar is launching later this year! This preview is read-only and only supports Google
|
||||
calendar.
|
||||
</div>
|
||||
);
|
||||
Notice.displayName = 'Notice';
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(CalendarWrapper, {
|
||||
location: WorkspaceStore.Location.Center,
|
||||
});
|
||||
ComponentRegistry.register(Notice, {
|
||||
location: WorkspaceStore.Sheet.Main.Header,
|
||||
});
|
||||
ComponentRegistry.register(QuickEventButton, {
|
||||
location: WorkspaceStore.Location.Center.Toolbar,
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(CalendarWrapper);
|
||||
ComponentRegistry.unregister(QuickEventButton);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
16
app/internal_packages/main-calendar/package.json
Normal file
16
app/internal_packages/main-calendar/package.json
Normal 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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
// The ui-variables file is provided by base themes provided by N1.
|
||||
@import 'ui-variables';
|
||||
@import 'ui-mixins';
|
||||
|
||||
.main-calendar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fixed-popover .calendar-event-popover {
|
||||
color: fadeout(@text-color, 20%);
|
||||
background-color: @background-primary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: @font-size-small;
|
||||
max-height: 95vh;
|
||||
width: 300px;
|
||||
|
||||
.location {
|
||||
color: @text-color-very-subtle;
|
||||
padding: @padding-base-vertical @padding-base-horizontal;
|
||||
word-wrap: break-word;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title-wrapper {
|
||||
color: @text-color-inverse;
|
||||
display: flex;
|
||||
font-size: @font-size-larger;
|
||||
background-color: @accent-primary;
|
||||
border-top-left-radius: @border-radius-base;
|
||||
border-top-right-radius: @border-radius-base;
|
||||
padding: @padding-base-vertical @padding-base-horizontal;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
.title {
|
||||
padding-right: @padding-base-vertical;
|
||||
}
|
||||
}
|
||||
.edit-icon {
|
||||
background-color: @text-color-inverse;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invitees .scroll-region-content {
|
||||
max-height: 160px;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
.description {
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.description .scroll-region-content {
|
||||
max-height: 250px;
|
||||
position: relative;
|
||||
}
|
||||
.label {
|
||||
color: @text-color-very-subtle;
|
||||
}
|
||||
.section {
|
||||
border-top: 1px solid @border-color-divider;
|
||||
padding: @padding-base-vertical @padding-base-horizontal;
|
||||
}
|
||||
.row.time {
|
||||
.time-picker {
|
||||
text-align: center;
|
||||
}
|
||||
.time-picker-wrap {
|
||||
margin-right: 5px;
|
||||
|
||||
.time-options {
|
||||
z-index: 10; // So the time pickers show over
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-event-popover {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.preview-notice {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
-webkit-print-color-adjust: exact;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgb(235, 224, 204);
|
||||
color: rgb(169, 136, 66);
|
||||
background-color: rgb(242, 235, 222);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
435
app/internal_packages/main-calendar/styles/nylas-calendar.less
Normal file
435
app/internal_packages/main-calendar/styles/nylas-calendar.less
Normal 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: 12px;
|
||||
padding-top: 0px;
|
||||
}
|
||||
.colored-checkbox {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
margin-left: 1px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(0, 0, 0, 0.13);
|
||||
background: white;
|
||||
.bg-color {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
label {
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
.toggle-wrap,
|
||||
.account-label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.account-calendars-wrap {
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.account-label {
|
||||
cursor: default;
|
||||
color: @text-color-very-subtle;
|
||||
font-weight: @font-weight-semi-bold;
|
||||
font-size: @font-size-small * 0.9;
|
||||
margin-top: @padding-large-vertical;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
background: @background-off-primary;
|
||||
.calendar-view {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
@interval-height: 21px;
|
||||
position: absolute;
|
||||
font-size: 11px;
|
||||
line-height: 11px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: @interval-height;
|
||||
|
||||
border-top: 0;
|
||||
border-bottom: 2px solid @background-primary;
|
||||
border-left: 0;
|
||||
|
||||
display: flex;
|
||||
cursor: default;
|
||||
.default-header {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 4px 5px 5px 6px;
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
white-space: nowrap;
|
||||
}
|
||||
&.selected {
|
||||
background-color: @accent-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-mouse-handler {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.week-view {
|
||||
height: 100%;
|
||||
|
||||
@legend-width: 75px;
|
||||
|
||||
.calendar-body-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.calendar-area-wrap {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
width: @legend-width;
|
||||
box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.week-header {
|
||||
position: relative;
|
||||
padding-top: 75px;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-labels {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.all-day-events {
|
||||
position: relative;
|
||||
width: auto;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.all-day-legend {
|
||||
width: @legend-width;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
color: #bfbfbf;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.date-label-legend {
|
||||
width: @legend-width;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.15);
|
||||
z-index: 3;
|
||||
.legend-text {
|
||||
bottom: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.day-label-wrap {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
box-shadow: inset 1px 0 0 rgba(177, 177, 177, 0.15);
|
||||
&.is-today {
|
||||
.date-label {
|
||||
color: @accent-primary;
|
||||
}
|
||||
.weekday-label {
|
||||
color: @accent-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
.date-label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: #808080;
|
||||
}
|
||||
.weekday-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: #ccd4d8;
|
||||
}
|
||||
|
||||
.event-grid-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: @background-primary;
|
||||
box-shadow: inset 0 1px 2.5px rgba(0, 0, 0, 0.15);
|
||||
height: 100%;
|
||||
}
|
||||
.event-grid {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
.event-grid-legend-wrap {
|
||||
overflow: hidden;
|
||||
box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
|
||||
}
|
||||
.event-grid-legend {
|
||||
position: relative;
|
||||
background: @background-primary;
|
||||
z-index: 2;
|
||||
}
|
||||
.event-grid-bg-wrap {
|
||||
.event-grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.cursor {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
position: absolute;
|
||||
}
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.event-column {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
box-shadow: 1px 0 0 rgba(177, 177, 177, 0.15);
|
||||
&.weekend {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-view {
|
||||
}
|
||||
|
||||
.current-time-indicator {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
border-top: 1px solid rgb(255, 100, 100);
|
||||
z-index: 2;
|
||||
transition: opacity ease-in-out 300ms;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
div {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(255, 100, 100);
|
||||
transform: translate3d(-75%, -50%, 0);
|
||||
border: 1px solid @background-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.top-banner {
|
||||
color: rgba(33, 99, 146, 0.6);
|
||||
background: #e0eff6;
|
||||
font-size: 12px;
|
||||
line-height: 25px;
|
||||
text-align: center;
|
||||
box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
color: #808080;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
box-shadow: inset 0 -1px 1px rgba(191, 191, 191, 0.12);
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
width: 275px;
|
||||
}
|
||||
.title,
|
||||
.btn-icon {
|
||||
font-size: 15px;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin: 0;
|
||||
height: auto;
|
||||
img {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-controls {
|
||||
padding: 10px;
|
||||
min-height: 45px;
|
||||
display: flex;
|
||||
color: #808080;
|
||||
background: @background-primary;
|
||||
border-top: @border-color-divider;
|
||||
box-shadow: 0 -3px 16px rgba(0, 0, 0, 0.11);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.center-controls {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-month-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 200px;
|
||||
min-height: 200px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: @background-primary;
|
||||
border: 1px solid @border-color-divider;
|
||||
border-radius: @border-radius-base;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background: @background-secondary;
|
||||
padding: 4px 0 3px 0;
|
||||
.month-title {
|
||||
padding-top: 3px;
|
||||
color: @text-color;
|
||||
flex: 1;
|
||||
}
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
.btn.btn-icon {
|
||||
line-height: 27px;
|
||||
height: 27px;
|
||||
margin-top: -1px;
|
||||
margin-right: 0;
|
||||
&:active {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
.weekday {
|
||||
flex: 1;
|
||||
}
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
}
|
||||
|
||||
.day-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
.week {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
}
|
||||
.day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 28px;
|
||||
color: @text-color-very-subtle;
|
||||
&.cur-month {
|
||||
color: @text-color;
|
||||
}
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
&.today {
|
||||
border: 1px solid @accent-primary;
|
||||
}
|
||||
&.cur-day {
|
||||
background: @accent-primary;
|
||||
color: @text-color-inverse;
|
||||
&:hover {
|
||||
background: darken(@accent-primary, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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' },
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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' },
|
||||
|
|
27
app/package-lock.json
generated
27
app/package-lock.json
generated
|
@ -1798,6 +1798,19 @@
|
|||
"sshpk": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"ical-expander": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ical-expander/-/ical-expander-2.0.0.tgz",
|
||||
"integrity": "sha512-DABFhfEl0c58MeiTbG8hO5VEWQfKlFA7jSqdoIbS2caxS4nGyA73nVBGsFtIbmTrDO7Osgw9/up2ZCnvuxGcKA==",
|
||||
"requires": {
|
||||
"ical.js": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"ical.js": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.3.0.tgz",
|
||||
"integrity": "sha512-wQ0w77MGOe6vNhZMBQuZAtZoTzDEOQ/QoCDofWL7yfkQ/HYWX5NyWPYjN+yj+4JahOvTkhXjTlrZtiQKv+BSOA=="
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.19",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
|
||||
|
@ -2667,6 +2680,12 @@
|
|||
"resolved": "https://registry.npmjs.org/lsmod/-/lsmod-1.0.0.tgz",
|
||||
"integrity": "sha1-mgD3bco26yP6BTUK/htYXUKZ5ks="
|
||||
},
|
||||
"luxon": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.11.1.tgz",
|
||||
"integrity": "sha512-ZmfPxP91Swg5MmHOeV/hvdIWiUYm6LCySg0xVRBVtOHZdNqiD32qxUhMek4XbyyXWtKdGMUD+hW+o5pilbRVuA==",
|
||||
"optional": true
|
||||
},
|
||||
"macos-notification-state": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/macos-notification-state/-/macos-notification-state-1.1.0.tgz",
|
||||
|
@ -3629,6 +3648,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"rrule": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.0.tgz",
|
||||
"integrity": "sha512-TRigkTJtG7Y1yOjNSKvFvVmvj/PzRZLR8lLcPW9GASOlaoqoL1J0kNuUV9I3LuZc7qFT+QB2NbxSLL9d33/ylg==",
|
||||
"requires": {
|
||||
"luxon": "^1.3.3"
|
||||
}
|
||||
},
|
||||
"rst-selector-parser": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
"classnames": "1.2.1",
|
||||
"collapse-whitespace": "^1.1.6",
|
||||
"debug": "github:emorikawa/debug#nylas",
|
||||
"deep-extend": "0.6.0",
|
||||
"electron-spellchecker": "github:bengotow/electron-spellchecker#master",
|
||||
"emoji-data": "^0.2.0",
|
||||
"enzyme": "^3.8.0",
|
||||
|
@ -27,10 +28,11 @@
|
|||
"fs-plus": "^2.3.2",
|
||||
"getmac": "^1.2.1",
|
||||
"graceful-fs": "^4.1.11",
|
||||
"ical-expander": "^2.0.0",
|
||||
"ical.js": "^1.3.0",
|
||||
"immutable": "^3.8.2",
|
||||
"is-online": "7.0.0",
|
||||
"jasmine-json": "~0.0",
|
||||
"deep-extend": "0.6.0",
|
||||
"jasmine-react-helpers": "^0.2",
|
||||
"jasmine-reporters": "1.x.x",
|
||||
"juice": "^1.4",
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -235,6 +235,7 @@ export default class Application extends EventEmitter {
|
|||
|
||||
// Configures required javascript environment flags.
|
||||
setupJavaScriptArguments() {
|
||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||
app.commandLine.appendSwitch('js-flags', '--harmony');
|
||||
}
|
||||
|
||||
|
@ -356,6 +357,14 @@ export default class Application extends EventEmitter {
|
|||
}
|
||||
});
|
||||
|
||||
this.on('application:show-calendar', () => {
|
||||
this.windowManager.ensureWindow(WindowManager.CALENDAR_WINDOW, {});
|
||||
const main = this.windowManager.get(WindowManager.MAIN_WINDOW);
|
||||
if (main) {
|
||||
main.sendMessage('run-calendar-sync');
|
||||
}
|
||||
});
|
||||
|
||||
this.on('application:view-help', () => {
|
||||
const helpUrl = 'http://support.getmailspring.com/hc/en-us';
|
||||
shell.openExternal(helpUrl);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -103,6 +103,13 @@ export default class MailsyncBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
// Temporary: allow calendar sync to be manually invoked
|
||||
ipcRenderer.on('run-calendar-sync', () => {
|
||||
for (const client of Object.values(this._clients)) {
|
||||
client.sendMessage({ type: 'sync-calendar' });
|
||||
}
|
||||
});
|
||||
|
||||
Actions.queueTask.listen(this._onQueueTask, this);
|
||||
Actions.queueTasks.listen(this._onQueueTasks, this);
|
||||
Actions.cancelTask.listen(this._onCancelTask, this);
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import moment from 'moment';
|
||||
|
||||
import Model from './model';
|
||||
import Attributes from '../attributes';
|
||||
import Contact from './contact';
|
||||
|
@ -8,108 +6,34 @@ import Contact from './contact';
|
|||
let chrono = null;
|
||||
|
||||
export default class Event extends Model {
|
||||
// Note: This class doesn't have many table-level attributes. We store the ICS
|
||||
// data for the event in the model JSON and parse it when we pull it out.
|
||||
static attributes = Object.assign({}, Model.attributes, {
|
||||
calendarId: Attributes.String({
|
||||
queryable: true,
|
||||
jsonKey: 'cid',
|
||||
modelKey: 'calendarId',
|
||||
jsonKey: 'calendar_id',
|
||||
}),
|
||||
title: Attributes.String({
|
||||
modelKey: 'title',
|
||||
jsonKey: 'title',
|
||||
}),
|
||||
description: Attributes.String({
|
||||
modelKey: 'description',
|
||||
jsonKey: 'description',
|
||||
}),
|
||||
// Can Have 1 of 4 types of subobjects. The Type can be:
|
||||
//
|
||||
// time
|
||||
// object: "time"
|
||||
// time: (unix timestamp)
|
||||
//
|
||||
// timestamp
|
||||
// object: "timestamp"
|
||||
// start_time: (unix timestamp)
|
||||
// end_time: (unix timestamp)
|
||||
//
|
||||
// date
|
||||
// object: "date"
|
||||
// date: (ISO 8601 date format. i.e. 1912-06-23)
|
||||
//
|
||||
// datespan
|
||||
// object: "datespan"
|
||||
// start_date: (ISO 8601 date)
|
||||
// end_date: (ISO 8601 date)
|
||||
when: Attributes.Object({
|
||||
modelKey: 'when',
|
||||
}),
|
||||
|
||||
location: Attributes.String({
|
||||
modelKey: 'location',
|
||||
jsonKey: 'location',
|
||||
}),
|
||||
|
||||
owner: Attributes.String({
|
||||
modelKey: 'owner',
|
||||
jsonKey: 'owner',
|
||||
}),
|
||||
|
||||
// Subobject:
|
||||
// name (string) - The participant's full name (optional)
|
||||
// email (string) - The participant's email address
|
||||
// status (string) - Attendance status. Allowed values are yes, maybe,
|
||||
// no and noreply. Defaults is noreply
|
||||
// comment (string) - A comment by the participant (optional)
|
||||
participants: Attributes.Object({
|
||||
modelKey: 'participants',
|
||||
jsonKey: 'participants',
|
||||
}),
|
||||
status: Attributes.String({
|
||||
modelKey: 'status',
|
||||
jsonKey: 'status',
|
||||
}),
|
||||
readOnly: Attributes.Boolean({
|
||||
modelKey: 'readOnly',
|
||||
jsonKey: 'read_only',
|
||||
}),
|
||||
busy: Attributes.Boolean({
|
||||
modelKey: 'busy',
|
||||
jsonKey: 'busy',
|
||||
}),
|
||||
|
||||
// Has a sub object of the form:
|
||||
// rrule: (array) - Array of recurrence rule (RRULE) strings. See RFC-2445
|
||||
// timezone: (string) - IANA time zone database formatted string
|
||||
// (e.g. America/New_York)
|
||||
recurrence: Attributes.Object({
|
||||
modelKey: 'recurrence',
|
||||
jsonKey: 'recurrence',
|
||||
}),
|
||||
|
||||
// ---- EXTRACTED ATTRIBUTES -----
|
||||
|
||||
// The "object" type of the "when" object. Can be either "time",
|
||||
// "timestamp", "date", or "datespan"
|
||||
type: Attributes.String({
|
||||
modelKey: 'type',
|
||||
jsonKey: '_type',
|
||||
ics: Attributes.String({
|
||||
jsonKey: 'ics',
|
||||
modelKey: 'ics',
|
||||
}),
|
||||
|
||||
// The calculated Unix start time. See the implementation for how we
|
||||
// treat each type of "when" attribute.
|
||||
start: Attributes.Number({
|
||||
recurrenceStart: Attributes.Number({
|
||||
queryable: true,
|
||||
modelKey: 'start',
|
||||
jsonKey: '_start',
|
||||
modelKey: 'recurrenceStart',
|
||||
jsonKey: 'rs',
|
||||
}),
|
||||
|
||||
// The calculated Unix end time. See the implementation for how we
|
||||
// treat each type of "when" attribute.
|
||||
end: Attributes.Number({
|
||||
recurrenceEnd: Attributes.Number({
|
||||
queryable: true,
|
||||
modelKey: 'end',
|
||||
jsonKey: '_end',
|
||||
modelKey: 'recurrenceEnd',
|
||||
jsonKey: 're',
|
||||
}),
|
||||
|
||||
// This corresponds to the rowid in the FTS table. We need to use the FTS
|
||||
|
@ -133,75 +57,11 @@ export default class Event extends Model {
|
|||
return Event.sortOrderAttribute().descending();
|
||||
};
|
||||
|
||||
// We use moment to parse the date so we can more easily pick up the
|
||||
// current timezone of the current locale.
|
||||
// We also create a start and end times that span the full day without
|
||||
// bleeding into the next.
|
||||
_unixRangeForDatespan(startDate, endDate) {
|
||||
return {
|
||||
start: moment(startDate).unix(),
|
||||
end: moment(endDate)
|
||||
.add(1, 'day')
|
||||
.subtract(1, 'second')
|
||||
.unix(),
|
||||
};
|
||||
}
|
||||
|
||||
fromJSON(json) {
|
||||
super.fromJSON(json);
|
||||
|
||||
const when = this.when;
|
||||
|
||||
if (!when) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (when.time) {
|
||||
this.start = when.time;
|
||||
this.end = when.time;
|
||||
} else if (when.start_time && when.end_time) {
|
||||
this.start = when.start_time;
|
||||
this.end = when.end_time;
|
||||
} else if (when.date) {
|
||||
const range = this._unixRangeForDatespan(when.date, when.date);
|
||||
this.start = range.start;
|
||||
this.end = range.end;
|
||||
} else if (when.start_date && when.end_date) {
|
||||
const range = this._unixRangeForDatespan(when.start_date, when.end_date);
|
||||
this.start = range.start;
|
||||
this.end = range.end;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
fromDraft(draft) {
|
||||
if (!this.title || this.title.length === 0) {
|
||||
this.title = draft.subject;
|
||||
}
|
||||
|
||||
if (!this.participants || this.participants.length === 0) {
|
||||
this.participants = draft.participants().map(contact => {
|
||||
return {
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
status: 'noreply',
|
||||
};
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
isAllDay() {
|
||||
const daySpan = 86400 - 1;
|
||||
return this.end - this.start >= daySpan;
|
||||
}
|
||||
|
||||
displayTitle() {
|
||||
const displayTitle = this.title.replace(/.*Invitation: /, '');
|
||||
const [displayTitleWithoutDate, date] = displayTitle.split(' @ ');
|
||||
if (!chrono) {
|
||||
chrono = require('chrono-node').default; //eslint-disable-line
|
||||
chrono = require('chrono-node'); //eslint-disable-line
|
||||
}
|
||||
if (date && chrono.parseDate(date)) {
|
||||
return displayTitleWithoutDate;
|
||||
|
|
Loading…
Reference in a new issue