Upgrade to Electron 8, improve TS usage and TS errors outside calendar [requires re- npm install] (#2284)

* Shfit away from default exports and PropTypes for better TS support

* localize strings and expand use of types in WeekView, create new EventOccurence distinct from Event

* Remove calendar wrap, use TS enum for view type + consistent prop interface

* Bump Typescript to 3.8.3 and improve query / attribute / search typings

* Re-use the Autolinker for calendar event descriptions with aggressive phone detection

* Clean up WeekView and the editing popover, lots of cruft here

* Update ScrollRegion to initialize scrollbar provided by external ref

* Expose ScrollRegion’s resizeObserver to clean up tick interval tracking

* Simply tickGenerator and move it to a helper

* Bump to Electron 8.x for Chrome 75+ CSS features

* Bump Handlebars dep to fix annoying npm audit noise

* Remove electron-remote from electron-spellchecker

* Explicitly add node-gyp, why is this necessary?

* Fix lesslint issues

* Bump eslint and let it fix 133 issues

* Satisfy remaining eslint@2020 errors by hand

* Add tsc-watch npm script and fix all TS errors outside calendar

* Configure appveyor to publish all the pdb files it gets

* Log sync exit codes and signals for easier triage on Windows

* Upgrade npm, mark that the build process supports Node 11+ not just Node 11

* Resolve more errors

* Upgrade sqlite to be a context-aware native module

* Fix: Tab key no longer navigating into contenteditable because tabIndex not inferred

* Fix: Bad print styles because Chrome now adds more CSS of it’s own when doctype is missing

* Fix: before-navigate is now called after beforeunload
This commit is contained in:
Ben Gotow 2021-02-14 15:58:22 -06:00 committed by GitHub
parent 3b07b0767f
commit cff437e900
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
224 changed files with 20037 additions and 3123 deletions

View file

@ -32,7 +32,7 @@ build_script:
- cmd: node app/build/create-signed-windows-installer.js
before_deploy:
- cmd: 7z -ttar a dummy %APPVEYOR_BUILD_FOLDER%\app\dist\*.dll %APPVEYOR_BUILD_FOLDER%\app\dist\mailsync.exe -so | 7z -si -tgzip a .\app\dist\mailsync.tar.gz
- cmd: 7z -ttar a dummy %APPVEYOR_BUILD_FOLDER%\app\dist\*.dll %APPVEYOR_BUILD_FOLDER%\app\dist\*.pdb %APPVEYOR_BUILD_FOLDER%\app\dist\mailsync.exe -so | 7z -si -tgzip a .\app\dist\mailsync.tar.gz
- ps: Get-ChildItem .\app\dist\*.tar.gz | % { Push-AppveyorArtifact $_.FullName -FileName "win-ia32/$($_.Name)" -DeploymentName s3-deployment }
- ps: Get-ChildItem .\app\dist\MailspringSetup.exe | % { Push-AppveyorArtifact $_.FullName -FileName "win-ia32/$($_.Name)" -DeploymentName s3-deployment }
- ps: Get-ChildItem .\app\dist\*.nupkg | % { Push-AppveyorArtifact $_.FullName -FileName "win-ia32/$($_.Name)" -DeploymentName s3-deployment }

View file

@ -6,7 +6,7 @@ git:
language: node_js
node_js:
- '11'
- '12'
addons:
artifacts:

View file

@ -29,5 +29,6 @@
"git.ignoreLimitWarning": true,
"files.exclude": {
"**/*.dll": true
}
}
},
"typescript.tsdk": "node_modules/typescript/lib"
}

11
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "2.0.0",
"command": "./node_modules/.bin/tsc",
"type": "shell",
"args": ["-w", "-p", "./app", "--noEmit"],
"presentation": {
"reveal": "silent"
},
"isBackground": true,
"problemMatcher": "$tsc-watch"
}

View file

@ -52,7 +52,7 @@ const onDeleteItem = function(item) {
return;
}
const chosen = remote.dialog.showMessageBox({
const response = remote.dialog.showMessageBoxSync({
type: 'info',
message: localized('Are you sure?'),
detail: localized(
@ -62,7 +62,7 @@ const onDeleteItem = function(item) {
defaultId: 0,
});
if (chosen !== 0) {
if (response !== 0) {
return;
}

View file

@ -18,7 +18,7 @@ import {
import SidebarItem from './sidebar-item';
import * as SidebarActions from './sidebar-actions';
import { ISidebarSection } from './types';
import { ISidebarSection, ISidebarItem } from './types';
function isSectionCollapsed(title) {
if (AppEnv.savedState.sidebarKeysCollapsed[title] !== undefined) {
@ -106,8 +106,8 @@ class SidebarSection {
];
const items = [];
for (let names of standardNames) {
names = Array.isArray(names) ? names : [names];
for (const nameOrNames of standardNames) {
const names = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames];
const categories = CategoryStore.getCategoriesWithRoles(accounts, ...names);
if (categories.length === 0) {
continue;
@ -192,15 +192,16 @@ class SidebarSection {
// Inbox.FolderA.FolderB
// Inbox.FolderB
//
const items = [];
const seenItems = {};
const items: ISidebarItem[] = [];
const seenItems: { [key: string]: ISidebarItem } = {};
for (const category of CategoryStore.userCategories(account)) {
// https://regex101.com/r/jK8cC2/1
let item, parentKey;
let item: ISidebarItem = null;
const re = RegExpUtils.subcategorySplitRegex();
const itemKey = category.displayName.replace(re, '/');
let parent = null;
let parentKey: string = null;
const parentComponents = itemKey.split('/');
for (let i = parentComponents.length; i >= 1; i--) {
parentKey = parentComponents.slice(0, i).join('/');

View file

@ -12,7 +12,7 @@ export interface ISidebarItem {
collapsed: boolean;
counterStyle: string;
onDelete?: () => void;
onEdited?: () => void;
onEdited?: (item, name: string) => void;
onCollapseToggled: () => void;
onDrop: (item, event) => void;
shouldAcceptDrop: (item, event) => void;

View file

@ -3,25 +3,25 @@ import SidebarItem from '../lib/sidebar-item';
describe('sidebar-item', function sidebarItemSpec() {
it('preserves nested labels on rename', () => {
spyOn(Actions, 'queueTask');
const categories = [new Folder({ path: 'a.b/c', accountId: window.TEST_ACCOUNT_ID })];
const queueTask = spyOn(Actions, 'queueTask');
const categories = [new Folder({ path: 'a.b/c', accountId: TEST_ACCOUNT_ID })];
AppEnv.savedState.sidebarKeysCollapsed = {};
const item = SidebarItem.forCategories(categories) as any;
item.onEdited(item, 'd');
const task = Actions.queueTask.calls[0].args[0];
const task = queueTask.calls[0].args[0];
const { existingPath, path } = task;
expect(existingPath).toBe('a.b/c');
expect(path).toBe('a.b/d');
});
it('preserves labels on rename', () => {
spyOn(Actions, 'queueTask');
const categories = [new Folder({ path: 'a', accountId: window.TEST_ACCOUNT_ID })];
const queueTask = spyOn(Actions, 'queueTask');
const categories = [new Folder({ path: 'a', accountId: TEST_ACCOUNT_ID })];
AppEnv.savedState.sidebarKeysCollapsed = {};
const item = SidebarItem.forCategories(categories);
item.onEdited(item, 'b') as any;
const task = Actions.queueTask.calls[0].args[0];
const task = queueTask.calls[0].args[0];
const { existingPath, path } = task;
expect(existingPath).toBe('a');
expect(path).toBe('b');

View file

@ -5,6 +5,7 @@ import { RetinaImg } from 'mailspring-component-kit';
function buildShareHTML(htmlEl, styleEl) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">

View file

@ -57,7 +57,7 @@ class SignatureEditor extends React.Component<SignatureEditorProps, SignatureEdi
t => sig.body === RenderSignatureData({ ...sig.data, templateName: t.name })
);
if (!htmlMatchesATemplate) {
const idx = remote.dialog.showMessageBox({
const idx = remote.dialog.showMessageBoxSync({
type: 'warning',
buttons: [localized('Cancel'), localized('Continue')],
message: localized('Revert custom HTML?'),

View file

@ -155,7 +155,7 @@ class TemplateStore extends MailspringStore {
_displayDialog(title, message, buttons) {
return (
remote.dialog.showMessageBox({
remote.dialog.showMessageBoxSync({
title: title,
message: title,
detail: message,

View file

@ -62,7 +62,9 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
dropzone = React.createRef<DropZone>();
sendButton = React.createRef<SendActionButton>();
focusContainer = React.createRef<KeyCommandsRegion & HTMLDivElement>();
editor = React.createRef<ComposerEditor>();
editor:
| React.RefObject<ComposerEditorPlaintext>
| React.RefObject<ComposerEditor> = React.createRef<any>();
header = React.createRef<ComposerHeader>();
_keymapHandlers = {
@ -158,7 +160,7 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
<div className="composer-body-wrap">
{draft.plaintext ? (
<ComposerEditorPlaintext
ref={this.editor}
ref={this.editor as React.RefObject<ComposerEditorPlaintext>}
value={draft.body}
propsForPlugins={{ draft, session }}
onFileReceived={this._onFileReceived}
@ -170,7 +172,7 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
) : (
<>
<ComposerEditor
ref={this.editor}
ref={this.editor as React.RefObject<ComposerEditor>}
value={draft.bodyEditorState}
className={quotedTextHidden && 'hiding-quoted-text'}
propsForPlugins={{ draft, session }}
@ -364,7 +366,7 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
}
if (warnings.length > 0 && !options.force) {
const response = dialog.showMessageBox({
const response = dialog.showMessageBoxSync({
type: 'warning',
buttons: [localized('Send Anyway'), localized('Cancel')],
message: localized('Are you sure?'),

View file

@ -385,7 +385,11 @@ body.platform-win32 {
.composer-body-wrap {
padding: 0 0 5px 0;
}
.composer-header {
.key-commands-region {
height: initial;
}
}
.composer-header-actions {
position: relative;
float: right;

View file

@ -14,7 +14,12 @@ class FoundInMailEnabledBarWithData extends React.Component<FoundInMailEnabledBa
static displayName = 'FoundInMailEnabledBar';
_onToggle = () => {
const accountId = this.props.perspective.accountId;
const { perspective } = this.props;
if (!perspective || perspective.type !== 'found-in-mail') {
return false;
}
const accountId = perspective.accountId;
let disabled = AppEnv.config.get(CONFIG_KEY);
if (disabled.includes(accountId)) {
disabled = disabled.filter(i => i !== accountId);

View file

@ -10,7 +10,7 @@ export const showGPeopleReadonlyNotice = (accountId: string) => {
acct.provider === 'gmail' &&
(!acct.authedAt || acct.authedAt < CONTACTS_OAUTH_SCOPE_ADDED)
) {
remote.dialog.showMessageBox({
remote.dialog.showMessageBoxSync({
message: localized(`Please re-authenticate with Google`),
detail: localized(
`To make changes to contacts in this account, you'll need to re-authorize Mailspring to access your data.\n\n` +

View file

@ -61,11 +61,12 @@ class DraftListStore extends MailspringStore {
const subscription = new MutableQuerySubscription(query, { emitResultSet: true });
const $resultSet = Rx.Observable.combineLatest(
[
Rx.Observable.fromNamedQuerySubscription('draft-list', subscription),
Rx.Observable.fromStore(OutboxStore) as any,
],
(resultSet: QueryResultSet<Message>, outbox) => {
Rx.Observable.fromNamedQuerySubscription('draft-list', subscription),
Rx.Observable.fromStore(OutboxStore),
(resultSet, outboxStore) => {
if (!(resultSet instanceof QueryResultSet)) {
throw 'Set emitResultSet=true and did not receive a QueryResultSet';
}
// Generate a new result set that includes additional information on
// the draft objects. This is similar to what we do in the thread-list,
// where we set thread.__messages to the message array.
@ -73,7 +74,7 @@ class DraftListStore extends MailspringStore {
// TODO BG modelWithId: task.headerMessageId does not work
mailboxPerspective.accountIds.forEach(aid => {
OutboxStore.itemsForAccount(aid).forEach(task => {
outboxStore.itemsForAccount(aid).forEach(task => {
let draft = resultSet.modelWithId(task.headerMessageId) as any;
if (draft) {
draft = draft.clone();

View file

@ -82,7 +82,7 @@ class GithubUserStore extends MailspringStore {
`https://api.github.com/search/repositories?q=user:${profile.login}&sort=stars&order=desc`
);
// Sort the repositories by their stars (`-` for descending order)
profile.repos = _.sortBy(repos.items, repo => -repo.stargazers_count);
profile.repos = _.sortBy(repos.items, repo => -repo['stargazers_count']);
// Trigger so that our React components refresh their state and display
// the updated data.
this.trigger(this);

View file

@ -33,7 +33,7 @@ class SubmitLocalizationsBar extends React.Component {
json: true,
});
if (status === 'success') {
remote.dialog.showMessageBox({
remote.dialog.showMessageBoxSync({
type: 'info',
buttons: [localized('OK')],
message: localized('Thank you!'),

View file

@ -1,104 +0,0 @@
import { Actions, DestroyModelTask, Event } 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 MailspringCalendar from './core/mailspring-calendar';
export default class CalendarWrapper extends React.Component<{}, { selectedEvents: Event[] }> {
static displayName = 'CalendarWrapper';
static containerRequired = false;
_dataSource = new CalendarDataSource();
constructor(props) {
super(props);
this.state = { selectedEvents: [] };
}
_openEventPopover(eventModel: Event) {
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: React.MouseEvent, event: Event) => {
let next = [...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: Event) => {
this._openEventPopover(eventModel);
};
_onEventFocused = (eventModel: Event) => {
this._openEventPopover(eventModel);
};
_onDeleteSelectedEvents = () => {
if (this.state.selectedEvents.length === 0) {
return;
}
const response = remote.dialog.showMessageBox({
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({
modelId: 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,
}}
>
<MailspringCalendar
dataSource={this._dataSource}
onEventClick={this._onEventClick}
onEventDoubleClick={this._onEventDoubleClick}
onEventFocused={this._onEventFocused}
selectedEvents={this.state.selectedEvents}
/>
</KeyCommandsRegion>
);
}
}

View file

@ -1,4 +1,5 @@
export const DAY_VIEW = 'day';
export const WEEK_VIEW = 'week';
export const MONTH_VIEW = 'month';
export const YEAR_VIEW = 'year';
export enum CalendarView {
DAY = 'day',
WEEK = 'week',
MONTH = 'month',
}

View file

@ -2,18 +2,32 @@ import Rx from 'rx-lite';
import { Event, Matcher, DatabaseStore } from 'mailspring-exports';
import IcalExpander from 'ical-expander';
export default class CalendarDataSource {
observable: Rx.Observable<{ events: Event[] }>;
export interface EventOccurrence {
start: number; // unix
end: number; // unix
id: string;
accountId: string;
calendarId: string;
title: string;
location: string;
description: string;
isAllDay: boolean;
organizer: { email: string } | null;
attendees: { email: string; name: string }[];
}
buildObservable({ startTime, endTime, disabledCalendars }) {
export class CalendarDataSource {
observable: Rx.Observable<{ events: EventOccurrence[] }>;
buildObservable({ startUnix, endUnix, disabledCalendars }) {
const end = Event.attributes.recurrenceEnd;
const start = Event.attributes.recurrenceStart;
const 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)]),
new Matcher.And([start.lte(endUnix), end.gte(startUnix)]),
new Matcher.And([start.lte(endUnix), start.gte(startUnix)]),
new Matcher.And([end.gte(startUnix), end.lte(endUnix)]),
new Matcher.And([end.gte(endUnix), start.lte(startUnix)]),
]);
if (disabledCalendars && disabledCalendars.length) {
@ -21,35 +35,9 @@ export default class CalendarDataSource {
}
const query = DatabaseStore.findAll<Event>(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 }]);
});
this.observable = Rx.Observable.fromQuery(query).flatMapLatest(results =>
Rx.Observable.from([{ events: occurrencesForEvents(results, { startUnix, endUnix }) }])
);
return this.observable;
}
@ -57,3 +45,39 @@ export default class CalendarDataSource {
return this.observable.subscribe(callback);
}
}
export function occurrencesForEvents(
results: Event[],
{ startUnix, endUnix }: { startUnix: number; endUnix: number }
) {
const occurences: EventOccurrence[] = [];
results.forEach(result => {
const icalExpander = new IcalExpander({ ics: result.ics, maxIterations: 100 });
const expanded = icalExpander.between(new Date(startUnix * 1000), new Date(endUnix * 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;
occurences.push({
start,
end,
id: `${result.id}-e${idx}`,
accountId: result.accountId,
calendarId: result.calendarId,
title: item.summary,
location: item.location,
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 occurences;
}

View file

@ -1,18 +1,28 @@
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 {
import { EventOccurrence } from './calendar-data-source';
export interface CalendarEventArgs {
event?: EventOccurrence;
time: number;
x: number;
y: number;
width: number;
height: number;
mouseIsDown: boolean;
}
interface CalendarEventContainerProps {
onCalendarMouseDown: (args: CalendarEventArgs) => void;
onCalendarMouseMove: (args: CalendarEventArgs) => void;
onCalendarMouseUp: (args: CalendarEventArgs) => void;
}
export class CalendarEventContainer extends React.Component<CalendarEventContainerProps> {
static displayName = 'CalendarEventContainer';
static propTypes = {
onCalendarMouseUp: PropTypes.func,
onCalendarMouseDown: PropTypes.func,
onCalendarMouseMove: PropTypes.func,
};
_DOMCache: {
eventColumn?: any;
gridWrap?: any;

View file

@ -1,12 +1,12 @@
import moment from 'moment';
import moment, { Moment } from 'moment';
import React from 'react';
import {
PropTypes,
Event,
Actions,
DatabaseStore,
DateUtils,
SyncbackEventTask,
localized,
RegExpUtils,
Autolink,
} from 'mailspring-exports';
import {
DatePicker,
@ -15,10 +15,12 @@ import {
TabGroupRegion,
TimePicker,
} from 'mailspring-component-kit';
import EventAttendeesInput from './event-attendees-input';
import { EventAttendeesInput } from './event-attendees-input';
import { EventOccurrence } from './calendar-data-source';
import { EventTimerangePicker } from './event-timerange-picker';
interface CalendarEventPopoverProps {
event: Event;
event: EventOccurrence;
}
interface CalendarEventPopoverState {
@ -31,36 +33,28 @@ interface CalendarEventPopoverState {
title: string;
}
export default class CalendarEventPopover extends React.Component<
export class CalendarEventPopover extends React.Component<
CalendarEventPopoverProps,
CalendarEventPopoverState
> {
static propTypes = {
event: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
const { description, start, end, location, attendees } = this.props.event;
const { description, start, end, location, attendees, title } = this.props.event;
this.state = {
description,
start,
end,
location,
title: this.props.event.displayTitle,
title,
editing: false,
attendees: 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,
});
const { description, start, end, location, attendees, title } = nextProps.event;
this.setState({ description, start, end, location, attendees, title });
};
onEdit = () => {
@ -89,34 +83,7 @@ export default class CalendarEventPopover extends React.Component<
Actions.queueTask(task);
};
extractNotesFromDescription(node) {
const els = node.querySelectorAll('meta[itemprop=description]');
let notes: string = null;
if (els.length) {
notes = Array.from(els)
.map((el: any) => 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 });
@ -128,86 +95,10 @@ export default class CalendarEventPopover extends React.Component<
this.setState(updates);
};
_onChangeDay = newTimestamp => {
const newDay = moment(newTimestamp);
const start = this.getStartMoment();
const end = this.getEndMoment();
start.year(newDay.year());
end.year(newDay.year());
start.dayOfYear(newDay.dayOfYear());
end.dayOfYear(newDay.dayOfYear());
this.setState({ start: start.unix(), end: end.unix() });
};
_onChangeStartTime = newTimestamp => {
const newStart = moment(newTimestamp);
let newEnd = this.getEndMoment();
if (newEnd.isSameOrBefore(newStart)) {
const leftInDay = moment(newStart)
.endOf('day')
.diff(newStart);
const move = Math.min(leftInDay, moment.duration(1, 'hour').asMilliseconds());
newEnd = moment(newStart).add(move, 'ms');
}
this.setState({ start: newStart.unix(), end: newEnd.unix() });
};
_onChangeEndTime = newTimestamp => {
const newEnd = moment(newTimestamp);
let newStart = this.getStartMoment();
if (newStart.isSameOrAfter(newEnd)) {
const sinceDay = moment(newEnd).diff(newEnd.startOf('day'));
const move = Math.min(sinceDay, moment.duration(1, 'hour').asMilliseconds());
newStart = moment(newEnd).subtract(move, 'ms');
}
this.setState({ end: newEnd.unix(), start: newStart.unix() });
};
renderTime() {
const startMoment = this.getStartMoment();
const endMoment = this.getEndMoment();
const date = startMoment.format('dddd, MMMM D'); // e.g. Tuesday, February 22
const timeRange = `${this.formatTime(startMoment)} - ${this.formatTime(endMoment)}`;
return (
<div>
{date}
<br />
{timeRange}
</div>
);
}
renderEditableTime() {
const startVal = this.state.start * 1000;
const endVal = this.state.end * 1000;
return (
<div className="row time">
<RetinaImg name="ic-eventcard-time@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
<span>
<TimePicker value={startVal} onChange={this._onChangeStartTime} />
to
<TimePicker value={endVal} onChange={this._onChangeEndTime} />
<span className="timezone">
{moment()
.tz(DateUtils.timeZone)
.format('z')}
</span>
&nbsp; on &nbsp;
<DatePicker value={startVal} onChange={this._onChangeDay} />
</span>
</div>
);
}
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);
const notes = extractNotesFromDescription(description);
return (
<div className="calendar-event-popover" tabIndex={0}>
@ -230,9 +121,15 @@ export default class CalendarEventPopover extends React.Component<
this.updateField('location', e.target.value);
}}
/>
<div className="section">{this.renderEditableTime()}</div>
<div className="section">
<div className="label">Invitees: </div>
<EventTimerangePicker
start={start}
end={end}
onChange={({ start, end }) => this.setState({ start, end })}
/>
</div>
<div className="section">
<div className="label">{localized(`Invitees`)}:</div>
<EventAttendeesInput
className="event-participant-field"
attendees={attendees}
@ -242,7 +139,7 @@ export default class CalendarEventPopover extends React.Component<
/>
</div>
<div className="section">
<div className="label">Notes: </div>
<div className="label">{localized(`Notes`)}:</div>
<input
type="text"
value={notes}
@ -251,8 +148,8 @@ export default class CalendarEventPopover extends React.Component<
}}
/>
</div>
<span onClick={this.saveEdits}>Save</span>
<span onClick={() => Actions.closePopover()}>Cancel</span>
<span onClick={this.saveEdits}>{localized(`Save`)}</span>
<span onClick={() => Actions.closePopover()}>{localized(`Cancel`)}</span>
</TabGroupRegion>
</div>
);
@ -262,14 +159,56 @@ export default class CalendarEventPopover extends React.Component<
if (this.state.editing) {
return this.renderEditable();
}
const { title, description, location, attendees } = this.state;
return (
<CalendarEventPopoverUnenditable
{...this.props}
onEdit={() => this.setState({ editing: true })}
/>
);
}
}
const fragment = document.createDocumentFragment();
const descriptionRoot = document.createElement('root');
fragment.appendChild(descriptionRoot);
descriptionRoot.innerHTML = description;
class CalendarEventPopoverUnenditable extends React.Component<{
event: EventOccurrence;
onEdit: () => void;
}> {
descriptionRef = React.createRef<HTMLDivElement>();
const notes = this.extractNotesFromDescription(descriptionRoot);
renderTime() {
const startMoment = moment(this.props.event.start * 1000);
const endMoment = moment(this.props.event.end * 1000);
const date = startMoment.format('dddd, MMMM D'); // e.g. Tuesday, February 22
const timeRange = `${formatTime(startMoment)} - ${formatTime(endMoment)}`;
return (
<div>
{date}
<br />
{timeRange}
</div>
);
}
componentDidMount() {
this.autolink();
}
componentDidUpdate() {
this.autolink();
}
autolink() {
if (!this.descriptionRef.current) return;
Autolink(this.descriptionRef.current, {
async: false,
telAggressiveMatch: true,
});
}
render() {
const { event, onEdit } = this.props;
const { title, description, location, attendees } = event;
const notes = extractNotesFromDescription(description);
return (
<div className="calendar-event-popover" tabIndex={0}>
@ -280,22 +219,67 @@ export default class CalendarEventPopover extends React.Component<
name="edit-icon.png"
title="Edit Item"
mode={RetinaImg.Mode.ContentIsMask}
onClick={this.onEdit}
onClick={onEdit}
/>
</div>
<div className="location">{location}</div>
{location && (
<div className="location">
{location.startsWith('http') || location.startsWith('tel:') ? (
<a href={location}>{location}</a>
) : (
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>
<div className="label">{localized(`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 className="label">{localized(`Notes`)}: </div>
<div ref={this.descriptionRef}>{notes}</div>
</div>
</ScrollRegion>
</div>
);
}
}
function extractNotesFromDescription(description: string) {
const fragment = document.createDocumentFragment();
const descriptionRoot = document.createElement('root');
fragment.appendChild(descriptionRoot);
descriptionRoot.innerHTML = description;
const els = descriptionRoot.querySelectorAll('meta[itemprop=description]');
let notes: string = null;
if (els.length) {
notes = Array.from(els)
.map((el: any) => el.content)
.join('\n');
} else {
notes = descriptionRoot.innerText;
}
while (true) {
const nextNotes = notes.replace('\n\n', '\n');
if (nextNotes === notes) {
break;
}
notes = nextNotes;
}
return notes;
}
function formatTime(momentTime: Moment) {
const min = momentTime.minutes();
if (min === 0) {
return momentTime.format('h A');
}
return momentTime.format('h:mm A');
}

View file

@ -1,25 +1,26 @@
import React, { CSSProperties } from 'react';
import ReactDOM from 'react-dom';
import { Event } from 'mailspring-exports';
import { InjectedComponentSet } from 'mailspring-component-kit';
import { EventOccurrence } from './calendar-data-source';
import { calcColor } from './calendar-helpers';
interface CalendarEventProps {
event: Event;
event: EventOccurrence;
order: number;
selected?: boolean;
selected: boolean;
scopeEnd: number;
scopeStart: number;
direction: 'horizontal' | 'vertical';
fixedSize: number;
focused?: boolean;
focused: boolean;
concurrentEvents: number;
onClick: (e: React.MouseEvent<any>, event: Event) => void;
onDoubleClick: (event: Event) => void;
onFocused: (event: Event) => void;
onClick: (e: React.MouseEvent<any>, event: EventOccurrence) => void;
onDoubleClick: (event: EventOccurrence) => void;
onFocused: (event: EventOccurrence) => void;
}
export default class CalendarEvent extends React.Component<CalendarEventProps> {
export class CalendarEvent extends React.Component<CalendarEventProps> {
static displayName = 'CalendarEvent';
static defaultProps = {
@ -116,7 +117,7 @@ export default class CalendarEvent extends React.Component<CalendarEventProps> {
onDoubleClick={() => onDoubleClick(event)}
>
<span className="default-header" style={{ order: 0 }}>
{event.displayTitle}
{event.title}
</span>
<InjectedComponentSet
className="event-injected-components"

View file

@ -4,7 +4,7 @@ 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)`;
bgColor = `hsla(${hue}, 80%, 45%, 0.55)`;
}
return bgColor;
}

View file

@ -2,13 +2,13 @@
import _ from 'underscore';
import classnames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { calcColor } from './calendar-helpers';
import { Calendar, Account } from 'mailspring-exports';
const DISABLED_CALENDARS = 'mailspring.disabledCalendars';
function renderCalendarToggles(calendars, disabledCalendars) {
function renderCalendarToggles(calendars: Calendar[], disabledCalendars: string[]) {
return calendars.map(calendar => {
const calendarId = calendar.id;
const onClick = () => {
@ -43,7 +43,12 @@ function renderCalendarToggles(calendars, disabledCalendars) {
});
}
export default function CalendarSourceList(props) {
interface CalendarSourceListProps {
accounts: Account[];
calendars: Calendar[];
disabledCalendars: string[];
}
export function CalendarSourceList(props: CalendarSourceListProps) {
const calsByAccountId = _.groupBy(props.calendars, 'accountId');
const accountSections = [];
for (const accountId of Object.keys(calsByAccountId)) {
@ -61,9 +66,3 @@ export default function CalendarSourceList(props) {
}
return <div className="calendar-source-list-wrap">{accountSections}</div>;
}
CalendarSourceList.propTypes = {
accounts: PropTypes.array,
calendars: PropTypes.array,
disabledCalendars: PropTypes.array,
};

View file

@ -1,16 +1,17 @@
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<
{
gridHeight: number;
numColumns: number;
todayColumnIdx: number;
visible: boolean;
},
interface CurrentTimeIndicatorProps {
gridHeight: number;
numColumns: number;
todayColumnIdx: number;
visible: boolean;
}
export class CurrentTimeIndicator extends React.Component<
CurrentTimeIndicatorProps,
{ msecIntoDay: number }
> {
_movementTimer = null;
@ -48,7 +49,7 @@ export default class CurrentTimeIndicator extends React.Component<
const todayMarker =
todayColumnIdx !== -1 ? (
<div style={{ left: `${Math.round(todayColumnIdx * 100 / numColumns)}%` }} />
<div style={{ left: `${Math.round((todayColumnIdx * 100) / numColumns)}%` }} />
) : null;
return (

View file

@ -1,10 +1,10 @@
import React from 'react';
import _ from 'underscore';
import { remote, clipboard } from 'electron';
import { PropTypes, Utils, Contact, ContactStore, RegExpUtils } from 'mailspring-exports';
import { Utils, Contact, ContactStore, RegExpUtils, localized } from 'mailspring-exports';
import { TokenizingTextField, Menu, InjectedComponentSet } from 'mailspring-component-kit';
const TokenRenderer = props => {
const TokenRenderer = (props: { token: Contact }) => {
const { email, cn } = props.token;
let chipText = email;
if (cn && cn.length > 0 && cn !== email) {
@ -23,10 +23,6 @@ const TokenRenderer = props => {
);
};
TokenRenderer.propTypes = {
token: PropTypes.object,
};
interface EventAttendeesInputProps {
attendees: any[];
change: (next: any[]) => void;
@ -35,7 +31,7 @@ interface EventAttendeesInputProps {
onFocus?: () => void;
}
export default class EventAttendeesInput extends React.Component<EventAttendeesInputProps> {
export class EventAttendeesInput extends React.Component<EventAttendeesInputProps> {
static displayName = 'EventAttendeesInput';
shouldComponentUpdate(nextProps, nextState) {
@ -123,7 +119,7 @@ export default class EventAttendeesInput extends React.Component<EventAttendeesI
const menu = new MenuClass();
menu.append(
new MenuItem({
label: `Copy ${participant.email}`,
label: `${localized(`Copy`)} ${participant.email}`,
click: () => clipboard.writeText(participant.email),
})
);

View file

@ -1,15 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { PropTypes, Utils } from 'mailspring-exports';
import { Utils } from 'mailspring-exports';
import { tickGenerator } from './week-view-helpers';
interface EventGridBackgroundProps {
height: number;
numColumns: number;
tickGenerator: (arg: { type: string }) => Array<{ yPos }>;
intervalHeight: number;
}
export default class EventGridBackground extends React.Component<EventGridBackgroundProps> {
export class EventGridBackground extends React.Component<EventGridBackgroundProps> {
static displayName = 'EventGridBackground';
_lastHoverRect: { x?; y?; width?; height? } = {};
@ -36,9 +36,9 @@ export default class EventGridBackground extends React.Component<EventGridBackgr
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);
for (const { y } of tickGenerator(type, this.props.intervalHeight)) {
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
}
ctx.stroke();
};
@ -47,7 +47,11 @@ export default class EventGridBackground extends React.Component<EventGridBackgr
doStroke('major', '#e0e0e0'); // Major ticks
}
mouseMove({ x, y, width }) {
onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const width = e.currentTarget.clientWidth;
const x = e.clientX;
const y = e.clientY;
if (!width || x == null || y == null) {
return;
}
@ -77,7 +81,7 @@ export default class EventGridBackground extends React.Component<EventGridBackgr
height: this.props.height,
};
return (
<div className="event-grid-bg-wrap">
<div className="event-grid-bg-wrap" onMouseMove={this.onMouseMove}>
<div ref="cursor" className="cursor" />
<canvas ref="canvas" className="event-grid-bg" style={styles} />
</div>

View file

@ -1,22 +1,19 @@
import React, { Component } from 'react';
import { Event, DatabaseStore } from 'mailspring-exports';
import { SearchBar } from 'mailspring-component-kit';
import PropTypes from 'prop-types';
import { Event, DatabaseStore, localized } from 'mailspring-exports';
import { EventOccurrence, occurrencesForEvents } from './calendar-data-source';
import moment from 'moment';
class EventSearchBar extends Component<
{
disabledCalendars: string[];
onSelectEvent: (event: Event) => void;
},
{ query: string; suggestions: Event[] }
interface EventSearchBarProps {
disabledCalendars: string[];
onSelectEvent: (event: EventOccurrence) => void;
}
export class EventSearchBar extends Component<
EventSearchBarProps,
{ query: string; suggestions: EventOccurrence[] }
> {
static displayName = 'EventSearchBar';
static defaultProps = {
disabledCalendars: [],
onSelectEvent: (event: Event) => {},
};
constructor(props) {
super(props);
this.state = {
@ -40,7 +37,16 @@ class EventSearchBar extends Component<
.search(query)
.limit(10)
.then(events => {
this.setState({ suggestions: events });
this.setState({
suggestions: occurrencesForEvents(events, {
startUnix: moment()
.add(-2, 'years')
.unix(),
endUnix: moment()
.add(2, 'years')
.unix(),
}),
});
});
};
@ -69,20 +75,18 @@ class EventSearchBar extends Component<
// 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}
/>
);
// return (
// <SearchBar
// query={query}
// suggestions={suggestions}
// placeholder={localized('Search all events')}
// suggestionKey={event => event.id}
// suggestionRenderer={this.renderEvent}
// onSearchQueryChanged={this.onSearchQueryChanged}
// onSelectSuggestion={this.onSelectEvent}
// onClearSearchQuery={this.onClearSearchQuery}
// onClearSearchSuggestions={this.onClearSearchSuggestions}
// />
// );
}
}
export default EventSearchBar;

View file

@ -0,0 +1,66 @@
import moment from 'moment';
import React from 'react';
import { DateUtils } from 'mailspring-exports';
import { DatePicker, RetinaImg, TimePicker } from 'mailspring-component-kit';
export const EventTimerangePicker: React.FunctionComponent<{
start: number;
end: number;
onChange: ({ start, end }) => void;
}> = ({ start, end, onChange }) => {
const onChangeStartTime = newTimestamp => {
const newStart = moment(newTimestamp);
let newEnd = moment(end);
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');
}
onChange({ start: newStart.unix(), end: newEnd.unix() });
};
const onChangeEndTime = (newTimestamp: number) => {
const newEnd = moment(newTimestamp);
let newStart = moment(start);
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');
}
onChange({ end: newEnd.unix(), start: newStart.unix() });
};
const onChangeDay = (newTimestamp: number) => {
const newDay = moment(newTimestamp);
const newStart = moment(start);
newStart.year(newDay.year());
newStart.dayOfYear(newDay.dayOfYear());
const newEnd = moment(end);
newEnd.year(newDay.year());
newEnd.dayOfYear(newDay.dayOfYear());
this.setState({ start: newStart.unix(), end: newEnd.unix() });
};
return (
<div className="row time">
<RetinaImg name="ic-eventcard-time@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
<span>
<TimePicker value={start * 1000} onChange={onChangeStartTime} />
to
<TimePicker value={end * 1000} onChange={onChangeEndTime} />
<span className="timezone">
{moment()
.tz(DateUtils.timeZone)
.format('z')}
</span>
&nbsp; on &nbsp;
<DatePicker value={start * 1000} onChange={onChangeDay} />
</span>
</div>
);
};

View file

@ -1,30 +0,0 @@
import React from 'react';
import { PropTypes, Utils } from 'mailspring-exports';
export default class FooterControls extends React.Component<{
footerComponents: React.ReactChildren;
}> {
static displayName = 'FooterControls';
static defaultProps = {
footerComponents: false,
};
shouldComponentUpdate(nextProps, nextState) {
return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
}
render() {
if (!this.props.footerComponents) {
return false;
}
return (
<div className="footer-controls">
<div className="spacer" style={{ order: 0, flex: 1 }}>
&nbsp;
</div>
{this.props.footerComponents}
</div>
);
}
}

View file

@ -2,18 +2,13 @@ import React from 'react';
import { Utils } from 'mailspring-exports';
import { RetinaImg } from 'mailspring-component-kit';
export default class HeaderControls extends React.Component<{
export class HeaderControls extends React.Component<{
title: string;
headerComponents: React.ReactNode;
nextAction: () => void;
prevAction: () => void;
}> {
static displayName = 'HeaderControls';
static defaultProps = {
headerComonents: false,
};
shouldComponentUpdate(nextProps, nextState) {
return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
}
@ -48,7 +43,7 @@ export default class HeaderControls extends React.Component<{
<span className="title">{this.props.title}</span>
{this._renderNextAction()}
</div>
{this.props.headerComponents}
{this.props.children}
</div>
);
}

View file

@ -1,90 +1,74 @@
import moment, { Moment } from 'moment';
import React from 'react';
import { Rx, DatabaseStore, AccountStore, Calendar, Account, Event } 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 CalendarSourceList from './calendar-source-list';
import CalendarDataSource from './calendar-data-source';
import { WEEK_VIEW, MONTH_VIEW } from './calendar-constants';
import MiniMonthView from './mini-month-view';
import {
Rx,
DatabaseStore,
AccountStore,
Calendar,
Account,
Actions,
localized,
DestroyModelTask,
} from 'mailspring-exports';
import {
ScrollRegion,
ResizableRegion,
KeyCommandsRegion,
MiniMonthView,
} from 'mailspring-component-kit';
import { WeekView } from './week-view';
import { MonthView } from './month-view';
import { EventSearchBar } from './event-search-bar';
import { CalendarSourceList } from './calendar-source-list';
import { CalendarDataSource, EventOccurrence } from './calendar-data-source';
import { CalendarView } from './calendar-constants';
import { Disposable } from 'rx-core';
import { CalendarEventArgs } from './calendar-event-container';
import { CalendarEventPopover } from './calendar-event-popover';
import { remote } from 'electron';
const DISABLED_CALENDARS = 'mailspring.disabledCalendars';
const VIEWS = {
[CalendarView.WEEK]: WeekView,
[CalendarView.MONTH]: MonthView,
};
export interface EventRendererProps {
focusedEvent: EventOccurrence;
selectedEvents: EventOccurrence[];
onEventClick: (e: React.MouseEvent<any>, event: EventOccurrence) => void;
onEventDoubleClick: (event: EventOccurrence) => void;
onEventFocused: (event: EventOccurrence) => void;
}
export interface MailspringCalendarViewProps extends EventRendererProps {
dataSource: CalendarDataSource;
disabledCalendars: string[];
focusedMoment: Moment;
onChangeView: (view: CalendarView) => void;
onChangeFocusedMoment: (moment: Moment) => void;
onCalendarMouseUp: (args: CalendarEventArgs) => void;
onCalendarMouseDown: (args: CalendarEventArgs) => void;
onCalendarMouseMove: (args: CalendarEventArgs) => void;
}
/*
* Mailspring Calendar
*/
interface MailspringCalendarProps {
/*
* The data source that powers all of the views of the MailspringCalendar
*/
dataSource: CalendarDataSource;
currentMoment?: Moment;
/*
* Any extra info you want to display on the top banner of calendar
* components
*/
bannerComponents?: {
day: React.ReactChild;
week: React.ReactChild;
month: React.ReactChild;
year: React.ReactChild;
};
/*
* Any extra header components for each of the supported View types of
* the MailspringCalendar
*/
headerComponents?: {
day: React.ReactChild;
week: React.ReactChild;
month: React.ReactChild;
year: React.ReactChild;
};
/*
* Any extra footer components for each of the supported View types of
* the MailspringCalendar
*/
footerComponents?: {
day: React.ReactChild;
week: React.ReactChild;
month: React.ReactChild;
year: React.ReactChild;
};
/*
* 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?: () => void;
onCalendarMouseDown?: () => void;
onCalendarMouseMove?: () => void;
onEventClick: (e: React.MouseEvent, event: Event) => void;
onEventDoubleClick: (event: Event) => void;
onEventFocused: (event: Event) => void;
selectedEvents: Event[];
}
interface MailspringCalendarProps {}
interface MailspringCalendarState {
currentView: string;
focusedEvent: Event | null;
view: CalendarView;
selectedEvents: EventOccurrence[];
focusedEvent: EventOccurrence | null;
accounts?: Account[];
calendars: Calendar[];
currentMoment: Moment;
focusedMoment: Moment;
disabledCalendars: string[];
}
export default class MailspringCalendar extends React.Component<
export class MailspringCalendar extends React.Component<
MailspringCalendarProps,
MailspringCalendarState
> {
@ -92,26 +76,21 @@ export default class MailspringCalendar extends React.Component<
static WeekView = WeekView;
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%',
};
_disposable?: Disposable;
_dataSource = new CalendarDataSource();
constructor(props) {
super(props);
this.state = {
calendars: [],
focusedEvent: null,
currentView: WEEK_VIEW,
currentMoment: props.currentMoment || this._now(),
selectedEvents: [],
view: CalendarView.WEEK,
focusedMoment: moment(),
disabledCalendars: AppEnv.config.get(DISABLED_CALENDARS) || [],
};
}
@ -129,49 +108,109 @@ export default class MailspringCalendar extends React.Component<
const calQueryObs = Rx.Observable.fromQuery(calQuery);
const accQueryObs = Rx.Observable.fromStore(AccountStore);
const configObs = Rx.Observable.fromConfig<string[] | undefined>(DISABLED_CALENDARS);
return Rx.Observable.combineLatest<any>([calQueryObs, accQueryObs, configObs]).subscribe(
([calendars, accountStore, disabledCalendars]: [Calendar[], any, string[] | undefined]) => {
return Rx.Observable.combineLatest(calQueryObs, accQueryObs, configObs).subscribe(
([calendars, accountStore, disabledCalendars]) => {
this.setState({
accounts: accountStore.accounts() as Account[],
calendars: calendars,
accounts: accountStore.accounts(),
disabledCalendars: disabledCalendars || [],
});
}
);
}
_now() {
return moment();
onChangeView = (view: CalendarView) => {
this.setState({ view });
};
onChangeFocusedMoment = (focusedMoment: Moment) => {
this.setState({ focusedMoment, focusedEvent: null });
};
_focusEvent = (event: EventOccurrence) => {
this.setState({ focusedMoment: moment(event.start * 1000), focusedEvent: event });
};
_openEventPopover(eventModel: EventOccurrence) {
const eventEl = document.getElementById(eventModel.id);
if (!eventEl) {
return;
}
Actions.openPopover(<CalendarEventPopover event={eventModel} />, {
originRect: eventEl.getBoundingClientRect(),
direction: 'right',
fallbackDirection: 'left',
});
}
_getCurrentViewComponent() {
const components = {};
components[WEEK_VIEW] = WeekView;
components[MONTH_VIEW] = MonthView;
return components[this.state.currentView];
}
_onEventClick = (e: React.MouseEvent, event: EventOccurrence) => {
let next = [...this.state.selectedEvents];
_changeCurrentView = currentView => {
this.setState({ currentView });
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,
});
};
_changeCurrentMoment = currentMoment => {
this.setState({ currentMoment, focusedEvent: null });
_onEventDoubleClick = (occurrence: EventOccurrence) => {
this._openEventPopover(occurrence);
};
_changeCurrentMomentFromValue = value => {
this.setState({ currentMoment: moment(value), focusedEvent: null });
_onEventFocused = (occurrence: EventOccurrence) => {
this._openEventPopover(occurrence);
};
_focusEvent = event => {
const value = event.start * 1000;
this.setState({ currentMoment: moment(value), focusedEvent: event });
_onDeleteSelectedEvents = () => {
if (this.state.selectedEvents.length === 0) {
return;
}
const response = remote.dialog.showMessageBoxSync({
type: 'warning',
buttons: [localized('Delete'), localized('Cancel')],
message: localized('Delete or decline these events?'),
detail: localized(
`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({
modelId: event.id,
modelName: event.constructor.name,
endpoint: '/events',
accountId: event.accountId,
});
Actions.queueTask(task);
}
}
};
_onCalendarMouseDown = () => {};
_onCalendarMouseMove = () => {};
_onCalendarMouseUp = () => {};
render() {
const CurrentView = this._getCurrentViewComponent();
const CurrentView = VIEWS[this.state.view];
return (
<div className="mailspring-calendar">
<KeyCommandsRegion
className="mailspring-calendar"
localHandlers={{
'core:remove-from-view': this._onDeleteSelectedEvents,
}}
>
<ResizableRegion
className="calendar-source-list"
initialWidth={200}
@ -192,31 +231,25 @@ export default class MailspringCalendar extends React.Component<
/>
</ScrollRegion>
<div style={{ width: '100%' }}>
<MiniMonthView
value={this.state.currentMoment.valueOf()}
onChange={this._changeCurrentMomentFromValue}
/>
<MiniMonthView value={this.state.focusedMoment} onChange={this.onChangeFocusedMoment} />
</div>
</ResizableRegion>
<CurrentView
dataSource={this.props.dataSource}
currentMoment={this.state.currentMoment}
dataSource={this._dataSource}
focusedMoment={this.state.focusedMoment}
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}
selectedEvents={this.state.selectedEvents}
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}
onChangeView={this.onChangeView}
onChangeFocusedMoment={this.onChangeFocusedMoment}
onCalendarMouseUp={this._onCalendarMouseUp}
onCalendarMouseDown={this._onCalendarMouseDown}
onCalendarMouseMove={this._onCalendarMouseMove}
onEventClick={this._onEventClick}
onEventDoubleClick={this._onEventDoubleClick}
onEventFocused={this._onEventFocused}
/>
</div>
</KeyCommandsRegion>
);
}
}

View file

@ -1,138 +0,0 @@
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<
{ value: number; onChange: (val: number) => void },
{ shownYear: number; shownMonth: number }
> {
static displayName = 'MiniMonthView';
static propTypes = {
value: PropTypes.number,
onChange: PropTypes.func,
};
static defaultProps = {
value: moment().valueOf(),
onChange: () => {},
};
today = moment();
constructor(props) {
super(props);
this.state = this._stateFromProps(props);
}
componentWillReceiveProps(newProps) {
this.setState(this._stateFromProps(newProps));
}
_stateFromProps(props) {
const m = props.value ? moment(props.value) : moment();
return {
shownYear: m.year(),
shownMonth: m.month(),
};
}
_shownMonthMoment() {
return moment([this.state.shownYear, this.state.shownMonth]);
}
_changeMonth = by => {
const newMonth = this.state.shownMonth + by;
const newMoment = this._shownMonthMoment().month(newMonth);
this.setState({
shownYear: newMoment.year(),
shownMonth: newMoment.month(),
});
};
_renderLegend() {
const weekdayGen = moment([2016]);
const legendEls = [];
for (let i = 0; i < 7; i++) {
const dayStr = weekdayGen.weekday(i).format('dd'); // Locale aware!
legendEls.push(
<span key={i} className="weekday">
{dayStr}
</span>
);
}
return <div className="legend">{legendEls}</div>;
}
_onClickDay = event => {
if (!event.target.dataset.timestamp) {
return;
}
const newVal = moment(parseInt(event.target.dataset.timestamp, 10)).valueOf();
this.props.onChange(newVal);
};
_isSameDay(m1, m2) {
return m1.dayOfYear() === m2.dayOfYear() && m1.year() === m2.year();
}
_renderDays() {
const dayIter = this._shownMonthMoment().date(1);
const startWeek = dayIter.week();
const curMonth = this.state.shownMonth;
const endWeek = moment(dayIter)
.date(dayIter.daysInMonth())
.week();
const weekEls = [];
const valDay = moment(this.props.value);
for (let week = startWeek; week <= endWeek; week++) {
dayIter.week(week); // Locale aware!
const dayEls = [];
for (let weekday = 0; weekday < 7; weekday++) {
dayIter.weekday(weekday); // Locale aware!
const dayStr = dayIter.format('D');
const className = classnames({
day: true,
today: this._isSameDay(dayIter, this.today),
'cur-day': this._isSameDay(dayIter, valDay),
'cur-month': dayIter.month() === curMonth,
});
dayEls.push(
<div className={className} key={`${week}-${weekday}`} data-timestamp={dayIter.valueOf()}>
{dayStr}
</div>
);
}
weekEls.push(
<div className="week" key={week}>
{dayEls}
</div>
);
}
return (
<div className="day-grid" onClick={this._onClickDay}>
{weekEls}
</div>
);
}
render() {
return (
<div className="mini-month-view">
<div className="header">
<div className="btn btn-icon" onClick={_.partial(this._changeMonth, -1)}>
&lsaquo;
</div>
<span className="month-title">{this._shownMonthMoment().format('MMMM YYYY')}</span>
<div className="btn btn-icon" onClick={_.partial(this._changeMonth, 1)}>
&rsaquo;
</div>
</div>
{this._renderLegend()}
{this._renderDays()}
</div>
);
}
}

View file

@ -1,11 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MailspringCalendarViewProps } from './mailspring-calendar';
import { CalendarView } from './calendar-constants';
export default class MonthView extends React.Component<{ changeView: (view: string) => void }> {
export class MonthView extends React.Component<MailspringCalendarViewProps> {
static displayName = 'MonthView';
_onClick = () => {
this.props.changeView('WeekView');
this.props.onChangeView(CalendarView.WEEK);
};
render() {

View file

@ -1,17 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class TopBanner extends React.Component<{ bannerComponents: React.ReactNode }> {
static displayName = 'TopBanner';
static propTypes = {
bannerComponents: PropTypes.node,
};
render() {
if (!this.props.bannerComponents) {
return false;
}
return <div className="top-banner">{this.props.bannerComponents}</div>;
}
}

View file

@ -1,6 +1,9 @@
import React from 'react';
import { Event, Utils } from 'mailspring-exports';
import CalendarEvent from './calendar-event';
import { CalendarEvent } from './calendar-event';
import { EventOccurrence } from './calendar-data-source';
import { OverlapByEventId } from './week-view-helpers';
import { EventRendererProps } from './mailspring-calendar';
/*
* Displays the all day events across the top bar of the week event view.
@ -9,16 +12,16 @@ import CalendarEvent from './calendar-event';
* we can use `shouldComponentUpdate` to selectively re-render these
* events.
*/
interface WeekViewAllDayEventsProps {
interface WeekViewAllDayEventsProps extends EventRendererProps {
end: number;
start: number;
height: number;
minorDim: number;
allDayEvents: Event[];
allDayOverlap: any;
allDayEvents: EventOccurrence[];
allDayOverlap: OverlapByEventId;
}
export default class WeekViewAllDayEvents extends React.Component<WeekViewAllDayEventsProps> {
export class WeekViewAllDayEvents extends React.Component<WeekViewAllDayEventsProps> {
static displayName = 'WeekViewAllDayEvents';
shouldComponentUpdate(nextProps, nextState) {
@ -26,23 +29,27 @@ export default class WeekViewAllDayEvents extends React.Component<WeekViewAllDay
}
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}
/>
);
});
const { height, allDayEvents, allDayOverlap, selectedEvents, focusedEvent } = this.props;
return (
<div className="all-day-events" style={{ height: this.props.height }}>
{eventComponents}
<div className="all-day-events" style={{ height: height }}>
{allDayEvents.map(e => (
<CalendarEvent
event={e}
order={allDayOverlap[e.id].order}
key={e.id}
selected={selectedEvents.includes(e)}
focused={focusedEvent ? focusedEvent.id === e.id : false}
scopeStart={this.props.start}
scopeEnd={this.props.end}
direction="horizontal"
fixedSize={this.props.minorDim}
concurrentEvents={allDayOverlap[e.id].concurrentEvents}
onClick={this.props.onEventClick}
onDoubleClick={this.props.onEventDoubleClick}
onFocused={this.props.onEventFocused}
/>
))}
</div>
);
}

View file

@ -1,8 +1,10 @@
import React from 'react';
import moment, { Moment } from 'moment';
import classnames from 'classnames';
import { PropTypes, Utils, Event } from 'mailspring-exports';
import CalendarEvent from './calendar-event';
import { Utils, Event } from 'mailspring-exports';
import { CalendarEvent } from './calendar-event';
import { EventOccurrence } from './calendar-data-source';
import { overlapForEvents } from './week-view-helpers';
/*
* This display a single column of events in the Week View.
@ -11,71 +13,63 @@ import CalendarEvent from './calendar-event';
* column-by-column basis.
*/
interface WeekViewEventColumnProps {
events: Event[];
events: EventOccurrence[];
day: Moment;
dayEnd: number;
focusedEvent: Event;
eventOverlap: any;
onEventClick: () => void;
onEventDoubleClick: () => void;
onEventFocused: () => void;
selectedEvents: Event[];
focusedEvent: EventOccurrence;
onEventClick: (e: React.MouseEvent<any>, event: EventOccurrence) => void;
onEventDoubleClick: (event: EventOccurrence) => void;
onEventFocused: (event: EventOccurrence) => void;
selectedEvents: EventOccurrence[];
}
export default class WeekViewEventColumn extends React.Component<WeekViewEventColumnProps> {
export class WeekViewEventColumn extends React.Component<WeekViewEventColumnProps> {
static displayName = 'WeekViewEventColumn';
shouldComponentUpdate(nextProps, nextState) {
return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
}
renderEvents() {
render() {
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,
weekend: day.day() === 0 || day.day() === 6,
});
const end = moment(this.props.day)
const overlap = overlapForEvents(events);
const end = moment(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 className={className} key={day.valueOf()} data-start={day.valueOf()} data-end={end}>
{events.map(e => (
<CalendarEvent
ref={`event-${e.id}`}
event={e}
selected={selectedEvents.includes(e)}
order={overlap[e.id].order}
focused={focusedEvent ? focusedEvent.id === e.id : false}
key={e.id}
scopeEnd={dayEnd}
scopeStart={day.unix()}
concurrentEvents={overlap[e.id].concurrentEvents}
onClick={onEventClick}
onDoubleClick={onEventDoubleClick}
onFocused={onEventFocused}
/>
))}
</div>
);
}

View file

@ -0,0 +1,132 @@
import { EventOccurrence } from './calendar-data-source';
import moment, { Moment } from 'moment';
import { Utils } from 'mailspring-exports';
// This pre-fetches from Utils to prevent constant disc access
const overlapsBounds = Utils.overlapsBounds;
export interface OverlapByEventId {
[id: string]: { concurrentEvents: number; order: null | number };
}
/*
* 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
*/
export function overlapForEvents(events: EventOccurrence[]) {
const eventsByTime: { [unix: number]: EventOccurrence[] } = {};
for (const event of events) {
if (!eventsByTime[event.start]) {
eventsByTime[event.start] = [];
}
if (!eventsByTime[event.end]) {
eventsByTime[event.end] = [];
}
eventsByTime[event.start].push(event);
eventsByTime[event.end].push(event);
}
const sortedTimes = Object.keys(eventsByTime)
.map(Number)
.sort();
const overlapById: OverlapByEventId = {};
let ongoingEvents: EventOccurrence[] = [];
for (const t of sortedTimes) {
// Process all event start/ends during this time to keep our
// "ongoingEvents" set correct.
for (const e of eventsByTime[t]) {
if (e.start === t) {
overlapById[e.id] = { concurrentEvents: 1, order: null };
ongoingEvents.push(e);
}
if (e.end === t) {
ongoingEvents = ongoingEvents.filter(o => o.id !== e.id);
}
}
// Write concurrency for all the events currently ongoing if they haven't
// been assigned values already
for (const e of ongoingEvents) {
const numEvents = findMaxConcurrent(ongoingEvents, overlapById);
overlapById[e.id].concurrentEvents = numEvents;
if (overlapById[e.id].order === null) {
// Don't re-assign the order.
const order = findAvailableOrder(ongoingEvents, overlapById);
overlapById[e.id].order = order;
}
}
}
return overlapById;
}
export function findMaxConcurrent(ongoing: EventOccurrence[], overlapById: OverlapByEventId) {
return Math.max(1, ongoing.length, ...ongoing.map(e => overlapById[e.id].concurrentEvents));
}
export function findAvailableOrder(ongoing: EventOccurrence[], overlapById: OverlapByEventId) {
const orders = ongoing.map(e => overlapById[e.id].order);
let order = 1;
while (true) {
if (!orders.includes(order)) {
return order;
}
order += 1;
}
}
export function maxConcurrentEvents(eventOverlap: OverlapByEventId) {
return Math.max(-1, ...Object.values(eventOverlap).map(o => o.concurrentEvents));
}
export function eventsGroupedByDay(events: EventOccurrence[], days: Moment[]) {
const map: { allDay: EventOccurrence[]; [dayUnix: string]: EventOccurrence[] } = { allDay: [] };
const unixDays = days.map(d => d.unix());
unixDays.forEach(day => {
map[`${day}`] = [];
});
events.forEach(event => {
if (event.isAllDay) {
map.allDay.push(event);
} else {
for (const day of unixDays) {
const bounds = {
start: day,
end: day + 24 * 60 * 60 - 1,
};
if (overlapsBounds(bounds, event)) {
map[`${day}`].push(event);
}
}
}
});
return map;
}
export const DAY_DUR = 24 * 60 * 60;
export const TICK_STEP = 30 * 60;
export const TICKS_PER_DAY = DAY_DUR / TICK_STEP;
export function* tickGenerator(type: 'major' | 'minor', tickHeight: number) {
const step = TICK_STEP * 2;
const skip = TICK_STEP * 2;
const stepStart = type === 'minor' ? TICK_STEP : 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 time = moment([2015, 1, 1]).add(stepStart, 'seconds');
for (let tsec = stepStart; tsec <= DAY_DUR; tsec += step) {
const y = (tsec / TICK_STEP) * tickHeight;
yield { time, y };
time.add(skip, 'seconds');
}
}

View file

@ -3,65 +3,43 @@ import _ from 'underscore';
import moment, { Moment } from 'moment-timezone';
import classnames from 'classnames';
import React from 'react';
import ReactDOM from 'react-dom';
import { PropTypes, Utils, Event } 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';
import { ScrollRegion, InjectedComponentSet } from 'mailspring-component-kit';
import { HeaderControls } from './header-controls';
import { EventOccurrence } 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';
import { Disposable } from 'rx-core';
import {
overlapForEvents,
maxConcurrentEvents,
eventsGroupedByDay,
TICKS_PER_DAY,
tickGenerator,
} from './week-view-helpers';
import { MailspringCalendarViewProps } from './mailspring-calendar';
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');
const DAY_PORTION_SHOWN_VERTICALLY = 11 / 24;
// This pre-fetches from Utils to prevent constant disc access
const overlapsBounds = Utils.overlapsBounds;
interface WeekViewProps {
dataSource: CalendarDataSource;
currentMoment: Moment;
focusedEvent: Event;
bannerComponents: React.ReactChildren;
headerComponents: React.ReactChildren;
footerComponents: React.ReactChildren;
disabledCalendars: string[];
changeCurrentView: () => void;
changeCurrentMoment: (moment: Moment) => void;
onCalendarMouseUp: () => void;
onCalendarMouseDown: () => void;
onCalendarMouseMove: () => void;
onEventClick: () => void;
onEventDoubleClick: () => void;
onEventFocused: () => void;
selectedEvents: Event[];
}
export default class WeekView extends React.Component<
WeekViewProps,
{ intervalHeight: number; events: Event[] }
export class WeekView extends React.Component<
MailspringCalendarViewProps,
{ intervalHeight: number; events: EventOccurrence[] }
> {
static displayName = 'WeekView';
static defaultProps = {
changeCurrentView: () => {},
bannerComponents: false,
headerComponents: false,
footerComponents: false,
};
_waitingForShift = 0;
_mounted = false;
_scrollbar = React.createRef<any>();
_sub?: Disposable;
_lastWrapHeight: number;
_legendWrapEl = React.createRef<HTMLDivElement>();
_calendarWrapEl = React.createRef<HTMLDivElement>();
_gridScrollRegion = React.createRef<ScrollRegion>();
constructor(props) {
super(props);
@ -74,20 +52,23 @@ export default class WeekView extends React.Component<
componentDidMount() {
this._mounted = true;
this._centerScrollRegion();
this._setIntervalHeight();
window.addEventListener('resize', this._setIntervalHeight, true);
const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap) as HTMLElement;
// Shift ourselves right by a week because we preload 7 days on either side
const wrap = this._calendarWrapEl.current;
wrap.scrollLeft += wrap.clientWidth;
this.updateSubscription();
this._setIntervalHeight();
}
componentDidUpdate(prevProps) {
this._setIntervalHeight();
const wrap = ReactDOM.findDOMNode(this.refs.calendarAreaWrap) as HTMLElement;
wrap.scrollLeft += this._waitingForShift;
this._waitingForShift = 0;
if (this._waitingForShift) {
const wrap = this._calendarWrapEl.current;
wrap.scrollLeft += this._waitingForShift;
this._waitingForShift = 0;
}
if (
prevProps.currentMoment !== this.props.currentMoment ||
prevProps.focusedMoment !== this.props.focusedMoment ||
prevProps.disabledCalendars !== this.props.disabledCalendars
) {
this.updateSubscription();
@ -96,8 +77,7 @@ export default class WeekView extends React.Component<
componentWillUnmount() {
this._mounted = false;
this._sub.dispose();
window.removeEventListener('resize', this._setIntervalHeight);
this._sub && this._sub.dispose();
}
// Indirection for testing purposes
@ -106,17 +86,14 @@ export default class WeekView extends React.Component<
}
updateSubscription() {
if (this._sub) {
this._sub.dispose();
}
const { start, end } = this._calculateMomentRange();
const { bufferedStart, bufferedEnd } = this._calculateMomentRange();
this._sub && this._sub.dispose();
this._sub = this.props.dataSource
.buildObservable({
disabledCalendars: this.props.disabledCalendars,
startTime: start.unix(),
endTime: end.unix(),
startUnix: bufferedStart.unix(),
endUnix: bufferedEnd.unix(),
})
.subscribe(state => {
this.setState(state);
@ -124,37 +101,39 @@ export default class WeekView extends React.Component<
}
_calculateMomentRange() {
const { currentMoment } = this.props;
let start;
const { focusedMoment } = this.props;
// NOTE: Since we initialize a new time from one of the properties of
// the props.currentMomet, we need to check for the timezone!
// the props.focusedMoment, 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
const tz = focusedMoment.tz();
const start = (tz ? moment.tz([focusedMoment.year()], tz) : moment([focusedMoment.year()]))
.weekday(0)
.week(currentMoment.week())
.subtract(BUFFER_DAYS, 'days');
.week(focusedMoment.week());
const end = moment(start)
.add(BUFFER_DAYS * 2 + DAYS_IN_VIEW, 'days')
const end = start
.clone()
.add(DAYS_IN_VIEW, 'days')
.subtract(1, 'millisecond');
return { start, end };
return {
visibleStart: start,
visibleEnd: end,
bufferedStart: start.clone().subtract(BUFFER_DAYS, 'days'),
bufferedEnd: moment(end)
.add(BUFFER_DAYS, 'days')
.subtract(1, 'millisecond'),
};
}
_renderDateLabel = (day, idx) => {
_renderDateLabel = (day: Moment, idx: number) => {
const className = classnames({
'day-label-wrap': true,
'is-today': this._isToday(day),
'is-hard-stop': day.weekday() == 1,
});
return (
<div className={className} key={idx}>
@ -171,246 +150,79 @@ export default class WeekView extends React.Component<
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 = [];
const { bufferedStart } = this._calculateMomentRange();
const days: Moment[] = [];
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));
days.push(moment(bufferedStart).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());
this.props.onChangeFocusedMoment(this._now());
};
_onClickNextWeek = () => {
const newMoment = moment(this.props.currentMoment).add(1, 'week');
this.props.changeCurrentMoment(newMoment);
const newMoment = moment(this.props.focusedMoment).add(1, 'week');
this.props.onChangeFocusedMoment(newMoment);
};
_onClickPrevWeek = () => {
const newMoment = moment(this.props.currentMoment).subtract(1, 'week');
this.props.changeCurrentMoment(newMoment);
const newMoment = moment(this.props.focusedMoment).subtract(1, 'week');
this.props.onChangeFocusedMoment(newMoment);
};
_gridHeight() {
return DAY_DUR / INTERVAL_TIME * this.state.intervalHeight;
}
_centerScrollRegion() {
const wrap = ReactDOM.findDOMNode(this.refs.eventGridWrap) as HTMLElement;
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');
}
const wrap = this._gridScrollRegion.current.viewportEl;
wrap.scrollTop = wrap.scrollHeight / 2 - wrap.clientHeight / 2;
}
_setIntervalHeight = () => {
if (!this._mounted) {
return;
} // Resize unmounting is delayed in tests
const wrap = ReactDOM.findDOMNode(this.refs.eventGridWrap) as HTMLElement;
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
) as HTMLElement).style.height = `${wrapHeight}px`;
const viewportHeight = this._gridScrollRegion.current.viewportEl.clientHeight;
this._legendWrapEl.current.style.height = `${viewportHeight}px`;
this.setState({
intervalHeight: Math.max(wrapHeight / numIntervals, MIN_INTERVAL_HEIGHT),
intervalHeight: Math.max(
viewportHeight / (TICKS_PER_DAY * DAY_PORTION_SHOWN_VERTICALLY),
MIN_INTERVAL_HEIGHT
),
});
};
_onScrollGrid = event => {
(ReactDOM.findDOMNode(this.refs.eventGridLegendWrap) as HTMLElement).scrollTop =
event.target.scrollTop;
};
_onScrollCalendarArea = (event: React.UIEvent) => {
console.log(event.currentTarget.scrollLeft);
// if (!event.currentTarget.scrollLeft || this._waitingForShift) {
// return;
// }
_onScrollCalendarArea = event => {
if (!event.currentTarget.scrollLeft || this._waitingForShift) {
return;
}
// const edgeWidth = (event.currentTarget.clientWidth / DAYS_IN_VIEW) * 2;
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();
}
// 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 };
for (const { time, y } of tickGenerator('major', this.state.intervalHeight)) {
labels.push(
<span className="legend-text" key={yPos} style={style}>
{hr}
<span className="legend-text" key={y} style={{ top: y === 0 ? y : y - 8 }}>
{time.format('LT')}
</span>
);
centering = 8; // center all except the 1st one.
}
return labels.slice(0, labels.length - 1);
}
@ -419,75 +231,57 @@ export default class WeekView extends React.Component<
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 eventsByDay = eventsGroupedByDay(this.state.events, days);
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 totalHeight = TICKS_PER_DAY * this.state.intervalHeight;
const { start: startMoment, end: endMoment } = this._calculateMomentRange();
const range = 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')}`;
const headerText = [
range.visibleStart.format('MMMM D'),
range.visibleEnd.format('MMMM D YYYY'),
].join(' - ');
const allDayOverlap = overlapForEvents(eventsByDay.allDay);
const allDayBarHeight = eventsByDay.allDay.length
? maxConcurrentEvents(allDayOverlap) * MIN_INTERVAL_HEIGHT + 1
: 0;
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} />
<div className="top-banner">
<InjectedComponentSet matching={{ role: 'Calendar:Week:Banner' }} direction="row" />
</div>
<HeaderControls
title={headerText}
ref="headerControls"
headerComponents={this._headerComponents()}
nextAction={this._onClickNextWeek}
prevAction={this._onClickPrevWeek}
/>
>
<button
key="today"
className="btn"
onClick={this._onClickToday}
style={{ position: 'absolute', left: 10 }}
>
Today
</button>
</HeaderControls>
<div className="calendar-body-wrap">
<div className="calendar-legend">
<div
className="date-label-legend"
style={{ height: this._allDayEventHeight(allDayOverlap) + 75 + 1 }}
>
<div className="date-label-legend" style={{ height: allDayBarHeight + 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 }}>
<div className="event-grid-legend-wrap" ref={this._legendWrapEl}>
<div className="event-grid-legend" style={{ height: totalHeight }}>
{this._renderEventGridLabels()}
</div>
</div>
@ -495,56 +289,69 @@ export default class WeekView extends React.Component<
<div
className="calendar-area-wrap"
ref="calendarAreaWrap"
onWheel={this._onScrollCalendarArea}
ref={this._calendarWrapEl}
onScroll={this._onScrollCalendarArea}
>
<div className="week-header" style={{ width: `${this._bufferRatio() * 100}%` }}>
<div className="date-labels">{days.map(this._renderDateLabel)}</div>
<WeekViewAllDayEvents
ref="weekViewAllDayEvents"
minorDim={MIN_INTERVAL_HEIGHT}
end={endMoment.unix()}
height={this._allDayEventHeight(allDayOverlap)}
start={startMoment.unix()}
height={allDayBarHeight}
start={range.bufferedStart.unix()}
end={range.bufferedEnd.unix()}
allDayEvents={eventsByDay.allDay}
allDayOverlap={allDayOverlap}
focusedEvent={this.props.focusedEvent}
selectedEvents={this.props.selectedEvents}
onEventClick={this.props.onEventClick}
onEventDoubleClick={this.props.onEventDoubleClick}
onEventFocused={this.props.onEventFocused}
/>
</div>
<ScrollRegion
className="event-grid-wrap"
ref="eventGridWrap"
getScrollbar={() => this.refs.scrollbar}
onScroll={this._onScrollGrid}
ref={this._gridScrollRegion}
scrollbarRef={this._scrollbar}
onScroll={event => (this._legendWrapEl.current.scrollTop = event.target.scrollTop)}
onViewportResize={this._setIntervalHeight}
style={{ width: `${this._bufferRatio() * 100}%` }}
>
<div className="event-grid" style={{ height: gridHeight }}>
{days.map(_.partial(this._renderEventColumn, eventsByDay))}
<div className="event-grid" style={{ height: totalHeight }}>
{days.map(day => (
<WeekViewEventColumn
day={day}
dayEnd={day.unix() + 24 * 60 * 60 - 1}
key={day.valueOf()}
events={eventsByDay[day.unix()]}
focusedEvent={this.props.focusedEvent}
selectedEvents={this.props.selectedEvents}
onEventClick={this.props.onEventClick}
onEventDoubleClick={this.props.onEventDoubleClick}
onEventFocused={this.props.onEventFocused}
/>
))}
<CurrentTimeIndicator
visible={
todayColumnIdx > BUFFER_DAYS && todayColumnIdx <= BUFFER_DAYS + DAYS_IN_VIEW
}
gridHeight={gridHeight}
gridHeight={totalHeight}
numColumns={BUFFER_DAYS * 2 + DAYS_IN_VIEW}
todayColumnIdx={todayColumnIdx}
/>
<EventGridBackground
height={gridHeight}
height={totalHeight}
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}
ref={this._scrollbar}
getScrollRegion={() => this._gridScrollRegion.current}
/>
</div>
<FooterControls footerComponents={this.props.footerComponents} />
</CalendarEventContainer>
</div>
);

View file

@ -1,15 +1,11 @@
import { EventedIFrame } from 'mailspring-component-kit';
import React from 'react';
import ReactDOM from 'react-dom';
import { PropTypes, Utils } from 'mailspring-exports';
import { Utils } from 'mailspring-exports';
export default class EmailFrame extends React.Component<{ content: string }> {
export class EmailFrame extends React.Component<{ content: string }> {
static displayName = 'EmailFrame';
static propTypes = {
content: PropTypes.string.isRequired,
};
_mounted = false;
_unlisten?: () => void;
_iframeComponent: EventedIFrame;
@ -54,7 +50,7 @@ export default class EmailFrame extends React.Component<{ content: string }> {
);
doc.close();
// autolink(doc, {async: true});
// autolink(doc.body, {async: true});
// autoscaleImages(doc);
// addInlineDownloadPrompts(doc);

View file

@ -1,18 +1,22 @@
import React from 'react';
import { WorkspaceStore, ComponentRegistry } from 'mailspring-exports';
import CalendarWrapper from './calendar-wrapper';
import QuickEventButton from './quick-event-button';
import { QuickEventButton } from './quick-event-button';
import { MailspringCalendar } from './core/mailspring-calendar';
const Notice = () =>
AppEnv.inDevMode() ? (
<span />
) : (
<div className="preview-notice">
Calendar is launching later this year! This preview is read-only and only supports Google
calendar.
</div>
);
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, {
ComponentRegistry.register(MailspringCalendar, {
location: WorkspaceStore.Location.Center,
});
ComponentRegistry.register(Notice, {
@ -24,6 +28,6 @@ export function activate() {
}
export function deactivate() {
ComponentRegistry.unregister(CalendarWrapper);
ComponentRegistry.unregister(MailspringCalendar);
ComponentRegistry.unregister(QuickEventButton);
}

View file

@ -1,9 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Actions } from 'mailspring-exports';
import QuickEventPopover from './quick-event-popover';
import { QuickEventPopover } from './quick-event-popover';
export default class QuickEventButton extends React.Component {
export class QuickEventButton extends React.Component<{}> {
static displayName = 'QuickEventButton';
onClick = event => {

View file

@ -1,13 +1,14 @@
import React from 'react';
import { Actions, Calendar, DatabaseStore, DateUtils, Event } from 'mailspring-exports';
import { Actions, Calendar, DatabaseStore, DateUtils, Event, localized } from 'mailspring-exports';
import { Moment } from 'moment';
interface QuickEventPopoverSttae {
interface QuickEventPopoverState {
start: Moment | null;
end: Moment | null;
leftoverText: string | null;
}
export default class QuickEventPopover extends React.Component<{}, QuickEventPopoverSttae> {
export class QuickEventPopover extends React.Component<{}, QuickEventPopoverState> {
constructor(props) {
super(props);
this.state = {
@ -18,7 +19,10 @@ export default class QuickEventPopover extends React.Component<{}, QuickEventPop
}
onInputKeyDown = event => {
const { key, target: { value } } = event;
const {
key,
target: { value },
} = event;
if (value.length > 0 && ['Enter', 'Return'].includes(key)) {
// This prevents onInputChange from being fired
event.stopPropagation();
@ -31,24 +35,29 @@ export default class QuickEventPopover extends React.Component<{}, QuickEventPop
this.setState(DateUtils.parseDateString(event.target.value));
};
createEvent = async ({ leftoverText, start, end }) => {
createEvent = async ({
leftoverText,
start,
end,
}: {
leftoverText: string;
start: Moment;
end: Moment;
}) => {
const allCalendars = await DatabaseStore.findAll<Calendar>(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) {
const editableCals = allCalendars.filter(c => !c.readOnly);
if (editableCals.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.'
localized(
"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,
calendarId: editableCals[0].id,
accountId: editableCals[0].accountId,
start: start.unix(),
end: end.unix(),
when: {
@ -86,7 +95,7 @@ export default class QuickEventPopover extends React.Component<{}, QuickEventPop
<input
tabIndex={0}
type="text"
placeholder="Coffee next Monday at 9AM'"
placeholder={localized("Coffee next Monday at 9AM'")}
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
/>

View file

@ -2,10 +2,6 @@
@import 'ui-variables';
@import 'ui-mixins';
.main-calendar {
height: 100%;
}
.fixed-popover .calendar-event-popover {
color: fadeout(@text-color, 20%);
background-color: @background-primary;

View file

@ -91,7 +91,7 @@ body.platform-win32 {
.default-header {
overflow: hidden;
text-overflow: ellipsis;
margin: 4px 5px 5px 6px;
margin: 4px 1px 3px 4px;
cursor: default;
opacity: 0.7;
flex: 1;
@ -141,6 +141,7 @@ body.platform-win32 {
overflow-x: auto;
overflow-y: hidden;
position: relative;
scroll-snap-type: x mandatory;
}
.calendar-legend {
@ -181,14 +182,14 @@ body.platform-win32 {
.legend-text {
font-size: 11px;
position: absolute;
color: #bfbfbf;
color: @text-color-very-subtle;
right: 10px;
}
.date-label-legend {
width: @legend-width;
position: relative;
border-bottom: 1px solid #dddddd;
border-bottom: 1px solid @border-color-divider;
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.15);
z-index: 3;
.legend-text {
@ -201,6 +202,10 @@ body.platform-win32 {
text-align: center;
flex: 1;
box-shadow: inset 1px 0 0 rgba(177, 177, 177, 0.15);
scroll-snap-align: start;
&.is-hard-stop {
scroll-snap-stop: always;
}
&.is-today {
.date-label {
color: @accent-primary;
@ -214,7 +219,7 @@ body.platform-win32 {
display: block;
font-size: 16px;
font-weight: 300;
color: #808080;
color: @text-color-subtle;
}
.weekday-label {
display: block;
@ -222,7 +227,7 @@ body.platform-win32 {
text-transform: uppercase;
margin-top: 3px;
font-size: 12px;
color: #ccd4d8;
color: @text-color-subtle;
}
.event-grid-wrap {
@ -277,6 +282,7 @@ body.platform-win32 {
}
.month-view {
color: #000;
}
.current-time-indicator {
@ -304,7 +310,7 @@ body.platform-win32 {
.top-banner {
color: rgba(33, 99, 146, 0.6);
background: #e0eff6;
background: @gray-lighter;
font-size: 12px;
line-height: 25px;
text-align: center;
@ -314,7 +320,7 @@ body.platform-win32 {
.header-controls {
padding: 10px;
display: flex;
color: #808080;
color: @text-color-subtle;
border-bottom: 1px solid @border-color-divider;
box-shadow: inset 0 -1px 1px rgba(191, 191, 191, 0.12);
flex-shrink: 0;
@ -338,99 +344,9 @@ body.platform-win32 {
}
}
.footer-controls {
padding: 10px;
min-height: 45px;
display: flex;
color: #808080;
background: @background-primary;
border-top: @border-color-divider;
box-shadow: 0 -3px 16px rgba(0, 0, 0, 0.11);
z-index: 2;
}
.center-controls {
text-align: center;
flex: 1;
order: 0;
}
}
.mini-month-view {
width: 100%;
height: 100%;
min-width: 200px;
min-height: 200px;
text-align: center;
display: flex;
flex-direction: column;
background: @background-primary;
border: 1px solid @border-color-divider;
border-radius: @border-radius-base;
.header {
display: flex;
background: @background-secondary;
padding: 4px 0 3px 0;
.month-title {
padding-top: 3px;
color: @text-color;
flex: 1;
}
border-bottom: 1px solid @border-color-divider;
.btn.btn-icon {
line-height: 27px;
height: 27px;
margin-top: -1px;
margin-right: 0;
&:active {
background: transparent;
}
}
}
.legend {
display: flex;
.weekday {
flex: 1;
}
padding: 3px 0;
border-bottom: 1px solid @border-color-divider;
}
.day-grid {
display: flex;
flex-direction: column;
flex: 1;
.week {
flex: 1;
display: flex;
min-height: 28px;
}
.day {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-height: 28px;
color: @text-color-very-subtle;
&.cur-month {
color: @text-color;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
cursor: pointer;
}
&.today {
border: 1px solid @accent-primary;
}
&.cur-day {
background: @accent-primary;
color: @text-color-inverse;
&:hover {
background: darken(@accent-primary, 5%);
}
}
}
}
}

View file

@ -1,8 +1,14 @@
import { EventedIFrame } from 'mailspring-component-kit';
import React from 'react';
import ReactDOM from 'react-dom';
import { PropTypes, Utils, QuotedHTMLTransformer, MessageStore, Message } from 'mailspring-exports';
import { autolink } from './autolinker';
import {
PropTypes,
Utils,
QuotedHTMLTransformer,
MessageStore,
Message,
Autolink,
} from 'mailspring-exports';
import { adjustImages } from './adjust-images';
import EmailFrameStylesStore from './email-frame-styles-store';
@ -118,7 +124,10 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
this._iframeDocObserver.observe(iframeEl.contentDocument.firstElementChild);
window.requestAnimationFrame(() => {
autolink(doc, { async: true });
Autolink(doc.body, {
async: true,
telAggressiveMatch: false,
});
adjustImages(doc);
for (const extension of MessageStore.extensions()) {

View file

@ -122,7 +122,7 @@ xdescribe('MessageItem', function() {
snippet: 'snippet one...',
subject: 'Subject One',
threadId: 'thread_12345',
accountId: window.TEST_ACCOUNT_ID,
accountId: TEST_ACCOUNT_ID,
});
// Generate the test component. Should be called after @message is configured

View file

@ -148,7 +148,7 @@ const CreatePageForForm = FormComponent => {
account.settings.imap_host.includes('imap.gmail.com')
) {
didWarnAboutGmailIMAP = true;
const buttonIndex = remote.dialog.showMessageBox({
const buttonIndex = remote.dialog.showMessageBoxSync({
type: 'warning',
buttons: [localized('Go Back'), localized('Continue')],
message: localized('Are you sure?'),

View file

@ -162,25 +162,6 @@
"mx-match": ["mx\\.zoho\\.com", "mx[0-9]*\\.zoho\\.com"],
"domain-match": ["zoho\\.com"]
},
"juno": {
"servers": {
"pop": [
{
"port": 995,
"hostname": "pop.juno.com",
"ssl": true
}
],
"smtp": [
{
"port": 465,
"hostname": "smtp.juno.com",
"starttls": true
}
]
},
"domain-match": ["juno\\.com"]
},
"mobileme": {
"servers": {
"imap": [
@ -1109,44 +1090,6 @@
},
"mx-match": ["mx1\\.comcast\\.net", "mx2\\.comcast\\.net"]
},
"verizon": {
"servers": {
"pop": [
{
"port": 995,
"hostname": "pop.verizon.net",
"ssl": true
}
],
"smtp": [
{
"port": 465,
"hostname": "smtp.verizon.net",
"ssl": true
}
]
},
"mx-match": ["relay\\.verizon\\.net"]
},
"rcn": {
"servers": {
"pop": [
{
"port": 110,
"hostname": "pop.rcn.com",
"ssl": true
}
],
"smtp": [
{
"port": 25,
"hostname": "smtp.rcn.com",
"ssl": true
}
]
},
"mx-match": ["mx\\.rcn\\.com"]
},
"ukrnet": {
"servers": {
"imap": [

View file

@ -7,13 +7,14 @@ import url from 'url';
import FormErrorMessage from './form-error-message';
import { LOCAL_SERVER_PORT } from './onboarding-helpers';
import AccountProviders from './account-providers';
interface OAuthSignInPageProps {
providerAuthPageUrl: string;
buildAccountFromAuthResponse: (rep: any) => Account | Promise<Account>;
onSuccess: (account: Account) => void;
onTryAgain: () => void;
providerConfig: object;
providerConfig: (typeof AccountProviders)[0];
serviceName: string;
}
@ -79,18 +80,16 @@ export default class OAuthSignInPage extends React.Component<
response.end('Unknown Request');
}
});
this._server.listen(LOCAL_SERVER_PORT, err => {
if (err) {
AppEnv.showErrorDialog({
title: localized('Unable to Start Local Server'),
message: localized(
`To listen for the Gmail Oauth response, Mailspring needs to start a webserver on port ${LOCAL_SERVER_PORT}. Please go back and try linking your account again. If this error persists, use the IMAP/SMTP option with a Gmail App Password.\n\n%@`,
err
),
});
return;
}
this._server.once('error', err => {
AppEnv.showErrorDialog({
title: localized('Unable to Start Local Server'),
message: localized(
`To listen for the Gmail Oauth response, Mailspring needs to start a webserver on port ${LOCAL_SERVER_PORT}. Please go back and try linking your account again. If this error persists, use the IMAP/SMTP option with a Gmail App Password.\n\n%@`,
err
),
});
});
this._server.listen(LOCAL_SERVER_PORT);
}
componentWillUnmount() {

View file

@ -33,19 +33,17 @@ const PageTopBar = props => {
backButton = null;
}
const style: any = {
top: 0,
left: 26,
right: 0,
height: 27,
zIndex: 100,
position: 'absolute',
WebkitAppRegion: 'drag',
};
return (
<div
className="dragRegion"
style={{
top: 0,
left: 26,
right: 0,
height: 27,
zIndex: 100,
position: 'absolute',
WebkitAppRegion: 'drag',
}}
>
<div className="dragRegion" style={style}>
{backButton}
</div>
);

View file

@ -20,7 +20,6 @@ xdescribe('Open tracking composer extension', function openTrackingComposerExten
beforeEach(() => {
this.draftBodyRootNode = nodeForHTML(beforeBody);
this.draft = new Message({
clientId: clientId,
accountId: accountId,
body: beforeBody,
});

View file

@ -23,7 +23,7 @@ class PreferencesAccountList extends Component<PreferencesAccountListProps> {
onRemoveAccount: PropTypes.func.isRequired,
};
_renderAccountStateIcon(account) {
_renderAccountStateIcon(account: Account) {
if (account.syncState !== 'running') {
return (
<div className="sync-error-icon">
@ -38,7 +38,7 @@ class PreferencesAccountList extends Component<PreferencesAccountListProps> {
return null;
}
_renderAccount = account => {
_renderAccount = (account: Account) => {
const label = account.label;
const accountSub = `${account.name || localized('No name provided')} <${account.emailAddress}>`;
const syncError = account.hasSyncStateError();

View file

@ -28,7 +28,7 @@ class PreferencesGeneral extends React.Component<{
};
_onResetAccountsAndSettings = () => {
const chosen = remote.dialog.showMessageBox({
const chosen = remote.dialog.showMessageBoxSync({
type: 'info',
message: localized('Are you sure?'),
buttons: [localized('Cancel'), localized('Reset')],

View file

@ -5,6 +5,7 @@ import {
localized,
localizedReactFragment,
IIdentity,
EMPTY_IDENTITY,
} from 'mailspring-exports';
import { OpenIdentityPageButton, BillingModal, RetinaImg } from 'mailspring-component-kit';
import { shell } from 'electron';
@ -158,7 +159,7 @@ class PreferencesIdentity extends React.Component<{}, { identity: IIdentity }> {
_getStateFromStores() {
return {
identity: IdentityStore.identity() || {},
identity: IdentityStore.identity() || { ...EMPTY_IDENTITY },
};
}

View file

@ -74,7 +74,7 @@ export default class PreferencesKeymaps extends React.Component<
}
_onDeleteUserKeymap() {
const chosen = remote.dialog.showMessageBox({
const chosen = remote.dialog.showMessageBoxSync({
type: 'info',
message: localized('Are you sure?'),
detail: localized('Delete your custom key bindings and reset to the template defaults?'),

View file

@ -26,6 +26,7 @@ export default class PrintWindow {
.join('');
const content = `
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src * mailspring:; script-src 'self' chrome-extension://react-developer-tools; style-src * 'unsafe-inline' mailspring:; img-src * data: mailspring: file:;">
@ -79,7 +80,7 @@ export default class PrintWindow {
contextIsolation: false,
},
});
this.browserWin.setMenu(null);
this.browserWin.removeMenu();
fs.writeFileSync(tmpMessagesPath, `window.printMessages = ${printMessages}`);
fs.writeFileSync(this.tmpFile, content);
}

View file

@ -7,31 +7,28 @@ win.addListener('page-title-updated', event => {
event.preventDefault();
});
global.printToPDF = () => {
remote.dialog.showSaveDialog(
global.printToPDF = async () => {
const { filePath } = await remote.dialog.showSaveDialog({
defaultPath: `${win.getTitle()}.pdf`,
});
if (!filePath) {
return;
}
webcontents.printToPDF(
{
defaultPath: `${win.getTitle()}.pdf`,
marginsType: 0,
pageSize: 'Letter',
printBackground: true,
landscape: false,
},
filename => {
if (!filename) {
(error, data) => {
if (error) {
remote.dialog.showErrorBox('An Error Occurred', `${error}`);
return;
}
webcontents.printToPDF(
{
marginsType: 0,
pageSize: 'Letter',
printBackground: true,
landscape: false,
},
(error, data) => {
if (error) {
remote.dialog.showErrorBox('An Error Occurred', `${error}`);
return;
}
fs.writeFileSync(filename, data);
}
);
fs.writeFileSync(filename, data);
}
);
};

View file

@ -1,6 +1,7 @@
/* eslint no-irregular-whitespace: 0 */
import fs from 'fs';
import { removeTrackingPixels } from '../lib/main';
import { Message } from 'mailspring-exports';
const readFixture = name => {
return fs
@ -19,7 +20,7 @@ describe('TrackingPixelsExtension', function trackingPixelsExtension() {
accountId: '1234',
isFromMe: () => true,
};
removeTrackingPixels(message);
removeTrackingPixels(message as Message);
expect(message.body).toEqual(expected);
});
@ -32,7 +33,7 @@ describe('TrackingPixelsExtension', function trackingPixelsExtension() {
accountId: '1234',
isFromMe: () => false,
};
removeTrackingPixels(message);
removeTrackingPixels(message as Message);
expect(message.body).toEqual(expected);
});
});

View file

@ -68,7 +68,7 @@ export default class SendLaterStatus extends Component<SendLaterStatusProps, Sen
SendDraftTask,
{ headerMessageId: draft.headerMessageId },
{ includeCompleted: true }
).pop(),
).pop() as SendDraftTask,
};
}

View file

@ -9,7 +9,7 @@ function getObservable() {
return Rx.Observable.combineLatest(
Rx.Observable.fromStore(FocusedContentStore),
ThreadListStore.selectionObservable(),
(store: FocusedContentStore, items: Thread[]) => ({
(store, items) => ({
focusedThread: store.focused('thread'),
items,
})

View file

@ -9,7 +9,7 @@ import {
} from 'mailspring-exports';
const _observableForThreadMessages = (id, initialModels) => {
const subscription = new QuerySubscription(
const subscription = new QuerySubscription<Message>(
DatabaseStore.findAll<Message>(Message, { threadId: id }),
{
initialModels: initialModels,

View file

@ -59,7 +59,7 @@ class ThreadListParticipants extends React.Component<{ thread: ThreadWithMessage
if (spacer) {
accumulate('...');
} else {
let short = '';
let short = contact.email;
if (contact.name && contact.name.length > 0) {
if (items.length > 1) {
short = contact.displayName({
@ -69,8 +69,6 @@ class ThreadListParticipants extends React.Component<{ thread: ThreadWithMessage
} else {
short = contact.displayName({ includeAccountLabel: false });
}
} else {
short = contact.email;
}
if (idx < items.length - 1 && !items[idx + 1].spacer) {
short += ', ';

View file

@ -49,7 +49,7 @@ class ThreadListStore extends MailspringStore {
};
selectionObservable = () => {
return Rx.Observable.fromListSelection(this);
return Rx.Observable.fromListSelection<Thread>(this);
};
// Inbound Events

View file

@ -191,7 +191,7 @@ describe('ThreadListParticipants', function() {
},
];
for (let scenario of scenarios) {
for (const scenario of scenarios) {
const thread = new Thread();
thread.__messages = scenario.in;
const participants = ReactTestUtils.renderIntoDocument(

View file

@ -23,26 +23,27 @@ import {
wrapInQuotes,
} from './search-bar-util';
class ThreadSearchBar extends Component<
{
query: string;
isSearching: boolean;
perspective: MailboxPerspective;
},
{
suggestions: {
token: string;
term: string;
description: string;
termSuggestions: string[] | ((term: string, accountIds: string[]) => Promise<any>);
}[];
focused: boolean;
selected?: {
description: any;
};
selectedIdx: number;
}
> {
interface ThreadSearchBarProps {
query: string;
isSearching: boolean;
perspective: MailboxPerspective;
}
interface ThreadSearchBarState {
suggestions: {
token: string;
term: string;
description: string;
termSuggestions: string[] | ((term: string, accountIds: string[]) => Promise<any>);
}[];
focused: boolean;
selected?: {
description: any;
};
selectedIdx: number;
}
class ThreadSearchBar extends Component<ThreadSearchBarProps, ThreadSearchBarState> {
static displayName = 'ThreadSearchBar';
static propTypes = {

View file

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import cld from '@paulcbetts/cld';
import cld from 'cld';
import { remote } from 'electron';
import {
localized,
@ -237,7 +237,7 @@ export class TranslateMessageHeader extends React.Component<
_onNeverForLanguage = () => {
if (!this.state.detected) return;
const response = remote.dialog.showMessageBox({
const response = remote.dialog.showMessageBoxSync({
type: 'warning',
buttons: [localized('Yes'), localized('Cancel')],
message: localized('Are you sure?'),

View file

@ -5,6 +5,7 @@ import {
Actions,
MailspringAPIRequest,
RegExpUtils,
FeatureLexicon,
} from 'mailspring-exports';
export const TranslatePopupOptions = {
@ -118,7 +119,7 @@ export const AllLanguages = {
ms: 'Malay',
};
export const TranslationsUsedLexicon = {
export const TranslationsUsedLexicon: FeatureLexicon = {
headerText: localized('All Translations Used'),
rechargeText: `${localized(
'You can translate up to %1$@ emails each %2$@ with Mailspring Basic.'

View file

@ -7,7 +7,7 @@ function isUndoSend(block) {
return (
block.tasks.length === 1 &&
block.tasks[0] instanceof SyncbackMetadataTask &&
block.tasks[0].value.isUndoSend
(block.tasks[0].value as any).isUndoSend
);
}

4591
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,15 +12,15 @@
"main": "./src/browser/main.js",
"dependencies": {
"@bengotow/slate-edit-list": "github:bengotow/slate-edit-list#b868e108",
"@paulcbetts/cld": "2.4.6",
"better-sqlite3": "bengotow/better-sqlite3#dcd5b6e73c9a5329fd72c85be3316131fcfb83ab",
"better-sqlite3": "^7.1.2",
"chromium-net-errors": "1.0.3",
"chrono-node": "^1.1.2",
"classnames": "1.2.1",
"cld": "2.6.0",
"collapse-whitespace": "^1.1.6",
"debug": "github:emorikawa/debug#nylas",
"deep-extend": "0.6.0",
"electron-spellchecker": "github:bengotow/electron-spellchecker#de319e18db19e497a6add6cc086b243d8e0461db",
"electron-spellchecker": "github:bengotow/electron-spellchecker#267df08",
"emoji-data": "^0.2.0",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.9.0",
@ -36,7 +36,7 @@
"jasmine-react-helpers": "^0.2",
"jasmine-reporters": "1.x.x",
"juice": "^5.2.0",
"keytar": "4.3.0",
"keytar": "5.5.0",
"less-cache": "1.1.0",
"lru-cache": "^4.0.1",
"mammoth": "1.4.7",
@ -58,7 +58,6 @@
"reflux": "0.1.13",
"rimraf": "2.5.2",
"rtlcss": "2.4.0",
"runas": "getflywheel/node-runas#ca4f0714",
"rx-lite": "4.0.8",
"slate": "github:bengotow/slate#cd6f40e8",
"slate-auto-replace": "0.12.1",

View file

@ -1,7 +1,7 @@
/* eslint quote-props: 0 */
import _ from 'underscore';
import { Model } from '../src/flux/models/model';
import Attributes from '../src/flux/attributes';
import * as Attributes from '../src/flux/attributes';
import DatabaseObjectRegistry from '../src/registries/database-object-registry';
class GoodTest extends Model {

View file

@ -5,14 +5,11 @@
*/
import { Model } from '../../src/flux/models/model';
import { Category } from '../../src/flux/models/category';
import Attributes from '../../src/flux/attributes';
import * as Attributes from '../../src/flux/attributes';
class TestModel extends Model {
static attributes = {
id: Attributes.String({
queryable: true,
modelKey: 'id',
}),
...Model.attributes,
clientId: Attributes.String({
queryable: true,

View file

@ -1,6 +1,6 @@
/* eslint quote-props: 0 */
import { Model } from '../../src/flux/models/model';
import Attributes from '../../src/flux/attributes';
import * as Attributes from '../../src/flux/attributes';
describe('Model', function modelSpecs() {
describe('constructor', () => {

View file

@ -1,6 +1,6 @@
/* eslint quote-props: 0 */
import ModelQuery from '../../src/flux/models/query';
import Attributes from '../../src/flux/attributes';
import * as Attributes from '../../src/flux/attributes';
import { Message } from '../../src/flux/models/message';
import { Thread } from '../../src/flux/models/thread';
import { Account } from '../../src/flux/models/account';

View file

@ -1,11 +1,10 @@
import fs from 'fs';
import path from 'path';
import { autolink } from '../lib/autolinker';
import { Autolink } from '../../src/services/autolinker';
describe('autolink', function autolinkSpec() {
const fixturesDir = path.join(__dirname, 'autolinker-fixtures');
fs
.readdirSync(fixturesDir)
fs.readdirSync(fixturesDir)
.filter(filename => filename.indexOf('-in.html') !== -1)
.forEach(filename => {
it(`should properly autolink a variety of email bodies ${filename}`, () => {
@ -17,7 +16,7 @@ describe('autolink', function autolinkSpec() {
const expected = fs.readFileSync(expectedPath).toString();
div.innerHTML = input;
autolink({ body: div });
Autolink(div);
expect(div.innerHTML).toEqual(expected);
});

Some files were not shown because too many files have changed in this diff Show more