[WiP] Calendar integration ui (#3395)

Calendar integration ui
This commit is contained in:
yanas 2018-08-27 10:13:59 -05:00 committed by GitHub
parent d62974b433
commit f2cb15ba44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 719 additions and 314 deletions

View File

@ -13,20 +13,31 @@
float: left; float: left;
} }
.navigate-section-list-tile { .navigate-section-list-tile {
height: 90px;
width: 260px;
border-radius: 4px;
background-color: #1754A9; background-color: #1754A9;
border-radius: 4px;
box-sizing: border-box;
display: inline-flex;
height: 100px;
margin-bottom: 8px;
margin-right: 8px; margin-right: 8px;
padding: 16px; padding: 16px;
display: inline-block; width: 100%;
box-sizing: border-box;
cursor: pointer; &.with-click-handler {
cursor: pointer;
}
&.with-click-handler:hover {
background-color: #1a5dbb;
}
} }
.navigate-section-tile-body { .navigate-section-tile-body {
@extend %navigate-section-list-tile-text; @extend %navigate-section-list-tile-text;
font-weight: normal; font-weight: normal;
} }
.navigate-section-list-tile-info {
flex: 1;
}
.navigate-section-tile-title { .navigate-section-tile-title {
@extend %navigate-section-list-tile-text; @extend %navigate-section-list-tile-text;
font-weight: bold; font-weight: bold;
@ -40,4 +51,8 @@
position: relative; position: relative;
margin-top: 36px; margin-top: 36px;
margin-bottom: 36px; margin-bottom: 36px;
width: 100%;
}
.navigate-section-list-empty {
text-align: center;
} }

View File

@ -45,6 +45,7 @@ body.welcome-page {
font-size: 1rem; font-size: 1rem;
font-weight: 400; font-weight: 400;
line-height: 24px; line-height: 24px;
margin-bottom: 20px;
} }
#enter_room { #enter_room {
@ -62,12 +63,30 @@ body.welcome-page {
width: 100%; width: 100%;
} }
} }
.tab-container {
font-size: 16px;
position: relative;
text-align: left;
width: 650px;
}
} }
.welcome-page-button { .welcome-page-button {
font-size: 16px; font-size: 16px;
} }
.welcome-page-settings {
color: $welcomePageDescriptionColor;
position: absolute;
right: 10px;
z-index: $zindex2;
* {
cursor: pointer;
}
}
.welcome-watermark { .welcome-watermark {
position: absolute; position: absolute;
width: 100%; width: 100%;

View File

@ -52,7 +52,7 @@ var interfaceConfig = {
'tileview' 'tileview'
], ],
SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile' ], SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile', 'calendar' ],
// Determines how the video would fit the screen. 'both' would fit the whole // Determines how the video would fit the screen. 'both' would fit the whole
// screen, 'height' would fit the original video height to the height of the // screen, 'height' would fit the original video height to the height of the

View File

@ -57,6 +57,8 @@
"video": "Video" "video": "Video"
}, },
"calendar": "Calendar", "calendar": "Calendar",
"connectCalendarText": "Connect your calendar to view all your meetings in __app__. Plus, add __app__ meetings to your calendar and start them with one click.",
"connectCalendarButton": "Connect your calendar",
"go": "GO", "go": "GO",
"join": "JOIN", "join": "JOIN",
"privacy": "Privacy", "privacy": "Privacy",
@ -639,13 +641,14 @@
"startWithVideoMuted": "Start with video muted" "startWithVideoMuted": "Start with video muted"
}, },
"calendarSync": { "calendarSync": {
"later": "Later", "addMeetingURL": "Add a meeting link",
"next": "Upcoming", "today": "Today",
"nextMeeting": "next meeting", "nextMeeting": "next meeting",
"now": "Now", "noEvents": "There are no upcoming events scheduled.",
"ongoingMeeting": "ongoing meeting", "ongoingMeeting": "ongoing meeting",
"permissionButton": "Open settings", "permissionButton": "Open settings",
"permissionMessage": "The Calendar permission is required to see your meetings in the app." "permissionMessage": "The Calendar permission is required to see your meetings in the app.",
"refresh": "Refresh calendar"
}, },
"recentList": { "recentList": {
"joinPastMeeting": "Join A Past Meeting" "joinPastMeeting": "Join A Past Meeting"

View File

@ -12,6 +12,11 @@ export type Item = {
*/ */
colorBase: string, colorBase: string,
/**
* An optional react element to append to the end of the Item.
*/
elementAfter?: ?ComponentType<any>,
/** /**
* Item title * Item title
*/ */

View File

@ -87,7 +87,7 @@ class NavigateSectionList extends Component<Props> {
*/ */
render() { render() {
const { const {
renderListEmptyComponent = this._renderListEmptyComponent, renderListEmptyComponent = this._renderListEmptyComponent(),
sections sections
} = this.props; } = this.props;
@ -128,11 +128,13 @@ class NavigateSectionList extends Component<Props> {
* @returns {Function} * @returns {Function}
*/ */
_onPress(url) { _onPress(url) {
return () => { const { disabled, onPress } = this.props;
const { disabled, onPress } = this.props;
!disabled && url && typeof onPress === 'function' && onPress(url); if (!disabled && url && typeof onPress === 'function') {
}; return () => onPress(url);
}
return null;
} }
_onRefresh: () => void; _onRefresh: () => void;

View File

@ -6,18 +6,37 @@ import Container from './Container';
import Text from './Text'; import Text from './Text';
import type { Item } from '../../Types'; import type { Item } from '../../Types';
/**
* The type of the React {@code Component} props of
* {@link NavigateSectionListItem}.
*/
type Props = { type Props = {
/**
* The icon to use for the action button.
*/
actionIconName: string,
/**
* The function to call when the action button is clicked.
*/
actionOnClick: ?Function,
/**
* The tooltip to attach to the action button of this list item.
*/
actionTooltip: string,
/** /**
* Function to be invoked when an item is pressed. The item's URL is passed. * Function to be invoked when an item is pressed. The item's URL is passed.
*/ */
onPress: Function, onPress: ?Function,
/** /**
* A item containing data to be rendered * A item containing data to be rendered
*/ */
item: Item item: Item
} };
/** /**
* Implements a React/Web {@link Component} for displaying an item in a * Implements a React/Web {@link Component} for displaying an item in a
@ -25,14 +44,16 @@ type Props = {
* *
* @extends Component * @extends Component
*/ */
export default class NavigateSectionListItem extends Component<Props> { export default class NavigateSectionListItem<P: Props>
extends Component<P> {
/** /**
* Renders the content of this component. * Renders the content of this component.
* *
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { lines, title } = this.props.item; const { elementAfter, lines, title } = this.props.item;
const { onPress } = this.props; const { onPress } = this.props;
/** /**
@ -52,22 +73,28 @@ export default class NavigateSectionListItem extends Component<Props> {
duration = lines[1]; duration = lines[1];
} }
const rootClassName = `navigate-section-list-tile ${
onPress ? 'with-click-handler' : 'without-click-handler'}`;
return ( return (
<Container <Container
className = 'navigate-section-list-tile' className = { rootClassName }
onClick = { onPress }> onClick = { onPress }>
<Text <Container className = 'navigate-section-list-tile-info'>
className = 'navigate-section-tile-title'> <Text
{ title } className = 'navigate-section-tile-title'>
</Text> { title }
<Text </Text>
className = 'navigate-section-tile-body'> <Text
{ date } className = 'navigate-section-tile-body'>
</Text> { date }
<Text </Text>
className = 'navigate-section-tile-body'> <Text
{ duration } className = 'navigate-section-tile-body'>
</Text> { duration }
</Text>
</Container>
{ elementAfter || null }
</Container> </Container>
); );
} }

View File

@ -7,6 +7,11 @@ import type { Section } from '../../Types';
type Props = { type Props = {
/**
* Rendered when the list is empty. Should be a rendered element.
*/
ListEmptyComponent: Object,
/** /**
* Used to extract a unique key for a given item at the specified index. * Used to extract a unique key for a given item at the specified index.
* Key is used for caching and as the react key to track item re-ordering. * Key is used for caching and as the react key to track item re-ordering.
@ -49,6 +54,7 @@ export default class SectionList extends Component<Props> {
*/ */
render() { render() {
const { const {
ListEmptyComponent,
renderSectionHeader, renderSectionHeader,
renderItem, renderItem,
sections, sections,
@ -56,30 +62,32 @@ export default class SectionList extends Component<Props> {
} = this.props; } = this.props;
/** /**
* If there are no recent items we dont want to display anything * If there are no recent items we don't want to display anything
*/ */
if (sections) { if (sections) {
return ( return (
<Container <Container
className = 'navigate-section-list'> className = 'navigate-section-list'>
{ {
sections.map((section, sectionIndex) => ( sections.length === 0
<Container ? ListEmptyComponent
key = { sectionIndex }> : sections.map((section, sectionIndex) => (
{ renderSectionHeader(section) } <Container
{ section.data key = { sectionIndex }>
.map((item, listIndex) => { { renderSectionHeader(section) }
const listItem = { { section.data
item .map((item, listIndex) => {
}; const listItem = {
item
};
return renderItem(listItem, return renderItem(listItem,
keyExtractor(section, keyExtractor(section,
listIndex)); listIndex));
}) } }) }
</Container> </Container>
) )
) )
} }
</Container> </Container>
); );

View File

@ -74,3 +74,16 @@ export const SET_CALENDAR_AUTH_STATE = Symbol('SET_CALENDAR_AUTH_STATE');
* @public * @public
*/ */
export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL'); export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL');
/**
* The type of Redux action which denotes whether a request is in flight to get
* updated calendar events.
*
* {
* type: SET_LOADING_CALENDAR_EVENTS,
* isLoadingEvents: string
* }
* @public
*/
export const SET_LOADING_CALENDAR_EVENTS
= Symbol('SET_LOADING_CALENDAR_EVENTS');

View File

@ -9,7 +9,8 @@ import {
SET_CALENDAR_AUTHORIZATION, SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS, SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION, SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL SET_CALENDAR_PROFILE_EMAIL,
SET_LOADING_CALENDAR_EVENTS
} from './actionTypes'; } from './actionTypes';
import { _getCalendarIntegration, isCalendarEnabled } from './functions'; import { _getCalendarIntegration, isCalendarEnabled } from './functions';
import { generateRoomWithoutSeparator } from '../welcome'; import { generateRoomWithoutSeparator } from '../welcome';
@ -173,6 +174,23 @@ export function setCalendarProfileEmail(newEmail: ?string) {
}; };
} }
/**
* Sends an to denote a request in is flight to get calendar events.
*
* @param {boolean} isLoadingEvents - Whether or not calendar events are being
* fetched.
* @returns {{
* type: SET_LOADING_CALENDAR_EVENTS,
* isLoadingEvents: boolean
* }}
*/
export function setLoadingCalendarEvents(isLoadingEvents: boolean) {
return {
type: SET_LOADING_CALENDAR_EVENTS,
isLoadingEvents
};
}
/** /**
* Sets the calendar integration type to be used by web and signals that the * Sets the calendar integration type to be used by web and signals that the
* integration is ready to be used. * integration is ready to be used.
@ -211,6 +229,7 @@ export function signIn(calendarType: string): Function {
.then(() => dispatch(integration.signIn())) .then(() => dispatch(integration.signIn()))
.then(() => dispatch(setIntegrationReady(calendarType))) .then(() => dispatch(setIntegrationReady(calendarType)))
.then(() => dispatch(updateProfile(calendarType))) .then(() => dispatch(updateProfile(calendarType)))
.then(() => dispatch(refreshCalendar()))
.catch(error => { .catch(error => {
logger.error( logger.error(
'Error occurred while signing into calendar integration', 'Error occurred while signing into calendar integration',

View File

@ -1,28 +1,23 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { appNavigate } from '../../app'; import { appNavigate } from '../../app';
import { getLocalizedDateFormatter, translate } from '../../base/i18n'; import { getLocalizedDateFormatter, translate } from '../../base/i18n';
import { NavigateSectionList } from '../../base/react'; import { NavigateSectionList } from '../../base/react';
import { openSettings } from '../../mobile/permissions';
import { refreshCalendar } from '../actions'; import { refreshCalendar } from '../actions';
import { isCalendarEnabled } from '../functions'; import { isCalendarEnabled } from '../functions';
import styles from './styles';
import AddMeetingUrlButton from './AddMeetingUrlButton';
/** /**
* The tyoe of the React {@code Component} props of {@link MeetingList}. * The type of the React {@code Component} props of
* {@link AbstractCalendarList}.
*/ */
type Props = { type Props = {
/**
* The current state of the calendar access permission.
*/
_authorization: ?string,
/** /**
* The calendar event list. * The calendar event list.
*/ */
@ -38,6 +33,11 @@ type Props = {
*/ */
dispatch: Function, dispatch: Function,
/**
*
*/
renderListEmptyComponent: Function,
/** /**
* The translate function. * The translate function.
*/ */
@ -45,9 +45,9 @@ type Props = {
}; };
/** /**
* Component to display a list of events from the (mobile) user's calendar. * Component to display a list of events from a connected calendar.
*/ */
class MeetingList extends Component<Props> { class AbstractCalendarList extends Component<Props> {
/** /**
* Default values for the component's props. * Default values for the component's props.
*/ */
@ -75,7 +75,7 @@ class MeetingList extends Component<Props> {
} }
/** /**
* Initializes a new {@code MeetingList} instance. * Initializes a new {@code CalendarList} instance.
* *
* @inheritdoc * @inheritdoc
*/ */
@ -83,13 +83,12 @@ class MeetingList extends Component<Props> {
super(props); super(props);
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onPress = this._onPress.bind(this); this._onPress = this._onPress.bind(this);
this._onRefresh = this._onRefresh.bind(this); this._onRefresh = this._onRefresh.bind(this);
this._toDateString = this._toDateString.bind(this); this._toDateString = this._toDateString.bind(this);
this._toDisplayableItem = this._toDisplayableItem.bind(this); this._toDisplayableItem = this._toDisplayableItem.bind(this);
this._toDisplayableList = this._toDisplayableList.bind(this); this._toDisplayableList = this._toDisplayableList.bind(this);
this._toTimeString = this._toTimeString.bind(this);
} }
/** /**
@ -98,7 +97,7 @@ class MeetingList extends Component<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { disabled } = this.props; const { disabled, renderListEmptyComponent } = this.props;
return ( return (
<NavigateSectionList <NavigateSectionList
@ -106,46 +105,11 @@ class MeetingList extends Component<Props> {
onPress = { this._onPress } onPress = { this._onPress }
onRefresh = { this._onRefresh } onRefresh = { this._onRefresh }
renderListEmptyComponent renderListEmptyComponent
= { this._getRenderListEmptyComponent() } = { renderListEmptyComponent }
sections = { this._toDisplayableList() } /> sections = { this._toDisplayableList() } />
); );
} }
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {?React$Component}
*/
_getRenderListEmptyComponent() {
const { _authorization, t } = this.props;
// If we don't provide a list specific renderListEmptyComponent, then
// the default empty component of the NavigateSectionList will be
// rendered, which (atm) is a simple "Pull to refresh" message.
if (_authorization !== 'denied') {
return undefined;
}
return (
<View style = { styles.noPermissionMessageView }>
<Text style = { styles.noPermissionMessageText }>
{ t('calendarSync.permissionMessage') }
</Text>
<TouchableOpacity
onPress = { openSettings }
style = { styles.noPermissionMessageButton } >
<Text style = { styles.noPermissionMessageButtonText }>
{ t('calendarSync.permissionButton') }
</Text>
</TouchableOpacity>
</View>
);
}
_onPress: string => Function; _onPress: string => Function;
/** /**
@ -174,7 +138,7 @@ class MeetingList extends Component<Props> {
_toDateString: Object => string; _toDateString: Object => string;
/** /**
* Generates a date (interval) string for a given event. * Generates a date string for a given event.
* *
* @param {Object} event - The event. * @param {Object} event - The event.
* @private * @private
@ -182,11 +146,9 @@ class MeetingList extends Component<Props> {
*/ */
_toDateString(event) { _toDateString(event) {
const startDateTime const startDateTime
= getLocalizedDateFormatter(event.startDate).format('lll'); = getLocalizedDateFormatter(event.startDate).format('MMM Do, YYYY');
const endTime
= getLocalizedDateFormatter(event.endDate).format('LT');
return `${startDateTime} - ${endTime}`; return `${startDateTime}`;
} }
_toDisplayableItem: Object => Object; _toDisplayableItem: Object => Object;
@ -200,10 +162,15 @@ class MeetingList extends Component<Props> {
*/ */
_toDisplayableItem(event) { _toDisplayableItem(event) {
return { return {
elementAfter: event.url ? undefined : (
<AddMeetingUrlButton
calendarId = { event.calendarId }
eventId = { event.id } />
),
key: `${event.id}-${event.startDate}`, key: `${event.id}-${event.startDate}`,
lines: [ lines: [
event.url, event.url,
this._toDateString(event) this._toTimeString(event)
], ],
title: event.title, title: event.title,
url: event.url url: event.url
@ -221,39 +188,60 @@ class MeetingList extends Component<Props> {
_toDisplayableList() { _toDisplayableList() {
const { _eventList, t } = this.props; const { _eventList, t } = this.props;
const now = Date.now(); const now = new Date();
const { createSection } = NavigateSectionList; const { createSection } = NavigateSectionList;
const nowSection = createSection(t('calendarSync.now'), 'now'); const TODAY_SECTION = 'today';
const nextSection = createSection(t('calendarSync.next'), 'next'); const sectionMap = new Map();
const laterSection = createSection(t('calendarSync.later'), 'later');
for (const event of _eventList) { for (const event of _eventList) {
const displayableEvent = this._toDisplayableItem(event); const displayableEvent = this._toDisplayableItem(event);
const startDate = new Date(event.startDate).getDate();
if (event.startDate < now && event.endDate > now) { if (startDate === now.getDate()) {
nowSection.data.push(displayableEvent); let todaySection = sectionMap.get(TODAY_SECTION);
} else if (event.startDate > now) {
if (nextSection.data.length if (!todaySection) {
&& nextSection.data[0].startDate !== event.startDate) { todaySection
laterSection.data.push(displayableEvent); = createSection(t('calendarSync.today'), TODAY_SECTION);
} else { sectionMap.set(TODAY_SECTION, todaySection);
nextSection.data.push(displayableEvent);
} }
todaySection.data.push(displayableEvent);
} else if (sectionMap.has(startDate)) {
const section = sectionMap.get(startDate);
if (section) {
section.data.push(displayableEvent);
}
} else {
const newSection
= createSection(this._toDateString(event), startDate);
sectionMap.set(startDate, newSection);
newSection.data.push(displayableEvent);
} }
} }
const sectionList = []; return Array.from(sectionMap.values());
}
for (const section of [ _toTimeString: Object => string;
nowSection,
nextSection,
laterSection
]) {
section.data.length && sectionList.push(section);
}
return sectionList; /**
* Generates a time (interval) string for a given event.
*
* @param {Object} event - The event.
* @private
* @returns {string}
*/
_toTimeString(event) {
const startDateTime
= getLocalizedDateFormatter(event.startDate).format('lll');
const endTime
= getLocalizedDateFormatter(event.endDate).format('LT');
return `${startDateTime} - ${endTime}`;
} }
} }
@ -262,19 +250,15 @@ class MeetingList extends Component<Props> {
* *
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @returns {{ * @returns {{
* _authorization: ?string,
* _eventList: Array<Object> * _eventList: Array<Object>
* }} * }}
*/ */
function _mapStateToProps(state: Object) { function _mapStateToProps(state: Object) {
const { authorization, events } = state['features/calendar-sync'];
return { return {
_authorization: authorization, _eventList: state['features/calendar-sync'].events
_eventList: events
}; };
} }
export default isCalendarEnabled() export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(MeetingList)) ? translate(connect(_mapStateToProps)(AbstractCalendarList))
: undefined; : undefined;

View File

@ -0,0 +1,23 @@
// @flow
import { Component } from 'react';
/**
* A React Component for adding a meeting URL to an existing calendar meeting.
*
* @extends Component
*/
class AddMeetingUrlButton extends Component<*> {
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
// Not yet implemented.
return null;
}
}
export default AddMeetingUrlButton;

View File

@ -0,0 +1,86 @@
// @flow
import Button from '@atlaskit/button';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { updateCalendarEvent } from '../actions';
/**
* The type of the React {@code Component} props of {@link AddMeetingUrlButton}.
*/
type Props = {
/**
* The calendar ID associated with the calendar event.
*/
calendarId: string,
/**
* Invoked to add a meeting URL to a calendar event.
*/
dispatch: Dispatch<*>,
/**
* The ID of the calendar event that will have a meeting URL added on click.
*/
eventId: string,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* A React Component for adding a meeting URL to an existing calendar event.
*
* @extends Component
*/
class AddMeetingUrlButton extends Component<Props> {
/**
* Initializes a new {@code AddMeetingUrlButton} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
return (
<Button
appearance = 'primary'
onClick = { this._onClick }
type = 'button'>
{ this.props.t('calendarSync.addMeetingURL') }
</Button>
);
}
_onClick: () => void;
/**
* Dispatches an action to adding a meeting URL to a calendar event.
*
* @returns {void}
*/
_onClick() {
const { calendarId, dispatch, eventId } = this.props;
dispatch(updateCalendarEvent(eventId, calendarId));
}
}
export default translate(connect()(AddMeetingUrlButton));

View File

@ -0,0 +1,126 @@
// @flow
import React, { Component } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { connect } from 'react-redux';
import { openSettings } from '../../mobile/permissions';
import { translate } from '../../base/i18n';
import { isCalendarEnabled } from '../functions';
import styles from './styles';
import AbstractCalendarList from './AbstractCalendarList';
/**
* The tyoe of the React {@code Component} props of {@link CalendarList}.
*/
type Props = {
/**
* The current state of the calendar access permission.
*/
_authorization: ?string,
/**
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/**
* The translate function.
*/
t: Function
};
/**
* Component to display a list of events from the (mobile) user's calendar.
*/
class CalendarList extends Component<Props> {
/**
* Initializes a new {@code CalendarList} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
const { disabled } = this.props;
return (
AbstractCalendarList
? <AbstractCalendarList
disabled = { disabled }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() } />
: null
);
}
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {?React$Component}
*/
_getRenderListEmptyComponent() {
const { _authorization, t } = this.props;
// If we don't provide a list specific renderListEmptyComponent, then
// the default empty component of the NavigateSectionList will be
// rendered, which (atm) is a simple "Pull to refresh" message.
if (_authorization !== 'denied') {
return undefined;
}
return (
<View style = { styles.noPermissionMessageView }>
<Text style = { styles.noPermissionMessageText }>
{ t('calendarSync.permissionMessage') }
</Text>
<TouchableOpacity
onPress = { openSettings }
style = { styles.noPermissionMessageButton } >
<Text style = { styles.noPermissionMessageButtonText }>
{ t('calendarSync.permissionButton') }
</Text>
</TouchableOpacity>
</View>
);
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {{
* _authorization: ?string,
* _eventList: Array<Object>
* }}
*/
function _mapStateToProps(state: Object) {
const { authorization } = state['features/calendar-sync'];
return {
_authorization: authorization
};
}
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(CalendarList))
: undefined;

View File

@ -0,0 +1,194 @@
// @flow
import Button from '@atlaskit/button';
import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { openSettingsDialog, SETTINGS_TABS } from '../../settings';
import { refreshCalendar } from '../actions';
import { isCalendarEnabled } from '../functions';
import AbstractCalendarList from './AbstractCalendarList';
declare var interfaceConfig: Object;
/**
* The type of the React {@code Component} props of {@link CalendarList}.
*/
type Props = {
/**
* Whether or not a calendar may be connected for fetching calendar events.
*/
_hasIntegrationSelected: boolean,
/**
* Whether or not events have been fetched from a calendar.
*/
_hasLoadedEvents: boolean,
/**
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The translate function.
*/
t: Function
};
/**
* Component to display a list of events from the user's calendar.
*/
class CalendarList extends Component<Props> {
/**
* Initializes a new {@code CalendarList} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onOpenSettings = this._onOpenSettings.bind(this);
this._onRefreshEvents = this._onRefreshEvents.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
const { disabled } = this.props;
return (
AbstractCalendarList
? <AbstractCalendarList
disabled = { disabled }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() } />
: null
);
}
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {React$Component}
*/
_getRenderListEmptyComponent() {
const { _hasIntegrationSelected, _hasLoadedEvents, t } = this.props;
if (_hasIntegrationSelected && _hasLoadedEvents) {
return (
<div className = 'navigate-section-list-empty'>
<div>{ t('calendarSync.noEvents') }</div>
<Button
appearance = 'primary'
className = 'calendar-button'
id = 'connect_calendar_button'
onClick = { this._onRefreshEvents }
type = 'button'>
{ t('calendarSync.refresh') }
</Button>
</div>
);
} else if (_hasIntegrationSelected && !_hasLoadedEvents) {
return (
<div className = 'navigate-section-list-empty'>
<Spinner
invertColor = { true }
isCompleting = { false }
size = 'medium' />
</div>
);
}
return (
<div className = 'navigate-section-list-empty'>
<p className = 'header-text-description'>
{ t('welcomepage.connectCalendarText', {
app: interfaceConfig.APP_NAME
}) }
</p>
<Button
appearance = 'primary'
className = 'calendar-button'
id = 'connect_calendar_button'
onClick = { this._onOpenSettings }
type = 'button'>
{ t('welcomepage.connectCalendarButton') }
</Button>
</div>
);
}
_onOpenSettings: () => void;
/**
* Opens {@code SettingsDialog}.
*
* @private
* @returns {void}
*/
_onOpenSettings() {
this.props.dispatch(openSettingsDialog(SETTINGS_TABS.CALENDAR));
}
_onRefreshEvents: () => void;
/**
* Gets an updated list of calendar events.
*
* @private
* @returns {void}
*/
_onRefreshEvents() {
this.props.dispatch(refreshCalendar(true));
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code CalendarList} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _hasIntegrationSelected: boolean,
* _hasLoadedEvents: boolean
* }}
*/
function _mapStateToProps(state) {
const {
events,
integrationType,
isLoadingEvents
} = state['features/calendar-sync'];
return {
_hasIntegrationSelected: Boolean(integrationType),
_hasLoadedEvents: Boolean(events) || !isLoadingEvents
};
}
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(CalendarList))
: undefined;

View File

@ -1,3 +1,3 @@
export { default as ConferenceNotification } from './ConferenceNotification'; export { default as ConferenceNotification } from './ConferenceNotification';
export { default as MeetingList } from './MeetingList'; export { default as CalendarList } from './CalendarList';
export { default as MicrosoftSignInButton } from './MicrosoftSignInButton'; export { default as MicrosoftSignInButton } from './MicrosoftSignInButton';

View File

@ -4,7 +4,7 @@ const NOTIFICATION_SIZE = 55;
/** /**
* The styles of the React {@code Component}s of the feature meeting-list i.e. * The styles of the React {@code Component}s of the feature meeting-list i.e.
* {@code MeetingList}. * {@code CalendarList}.
*/ */
export default createStyleSheet({ export default createStyleSheet({

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { setLoadingCalendarEvents } from './actions';
export * from './functions.any'; export * from './functions.any';
import { import {
@ -56,6 +57,8 @@ export function _fetchCalendarEntries(
return; return;
} }
dispatch(setLoadingCalendarEvents(true));
dispatch(integration.load()) dispatch(integration.load())
.then(() => dispatch(integration._isSignedIn())) .then(() => dispatch(integration._isSignedIn()))
.then(signedIn => { .then(signedIn => {
@ -72,7 +75,8 @@ export function _fetchCalendarEntries(
getState getState
}, events)) }, events))
.catch(error => .catch(error =>
logger.error('Error fetching calendar.', error)); logger.error('Error fetching calendar.', error))
.then(() => dispatch(setLoadingCalendarEvents(false)));
} }
/** /**

View File

@ -10,7 +10,8 @@ import {
SET_CALENDAR_AUTHORIZATION, SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS, SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION, SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL SET_CALENDAR_PROFILE_EMAIL,
SET_LOADING_CALENDAR_EVENTS
} from './actionTypes'; } from './actionTypes';
import { isCalendarEnabled } from './functions'; import { isCalendarEnabled } from './functions';
@ -96,6 +97,9 @@ isCalendarEnabled()
case SET_CALENDAR_PROFILE_EMAIL: case SET_CALENDAR_PROFILE_EMAIL:
return set(state, 'profileEmail', action.email); return set(state, 'profileEmail', action.email);
case SET_LOADING_CALENDAR_EVENTS:
return set(state, 'isLoadingEvents', action.isLoadingEvents);
} }
return state; return state;

View File

@ -1,159 +0,0 @@
import { createStyleSheet, BoxModel } from '../../base/styles';
const AVATAR_OPACITY = 0.4;
const AVATAR_SIZE = 65;
const OVERLAY_FONT_COLOR = 'rgba(255, 255, 255, 0.6)';
export const UNDERLAY_COLOR = 'rgba(255, 255, 255, 0.2)';
/**
* The styles of the React {@code Component}s of the feature recent-list i.e.
* {@code RecentList}.
*/
export default createStyleSheet({
/**
* The style of the actual avatar.
*/
avatar: {
alignItems: 'center',
backgroundColor: `rgba(23, 160, 219, ${AVATAR_OPACITY})`,
borderRadius: AVATAR_SIZE,
height: AVATAR_SIZE,
justifyContent: 'center',
width: AVATAR_SIZE
},
/**
* The style of the avatar container that makes the avatar rounded.
*/
avatarContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-around',
paddingTop: 5
},
/**
* Simple {@code Text} content of the avatar (the actual initials).
*/
avatarContent: {
backgroundColor: 'rgba(0, 0, 0, 0)',
color: OVERLAY_FONT_COLOR,
fontSize: 32,
fontWeight: '100',
textAlign: 'center'
},
/**
* List of styles of the avatar of a remote meeting (not the default
* server). The number of colors are limited because they should match
* nicely.
*/
avatarRemoteServer1: {
backgroundColor: `rgba(232, 105, 156, ${AVATAR_OPACITY})`
},
avatarRemoteServer2: {
backgroundColor: `rgba(255, 198, 115, ${AVATAR_OPACITY})`
},
avatarRemoteServer3: {
backgroundColor: `rgba(128, 128, 255, ${AVATAR_OPACITY})`
},
avatarRemoteServer4: {
backgroundColor: `rgba(105, 232, 194, ${AVATAR_OPACITY})`
},
avatarRemoteServer5: {
backgroundColor: `rgba(234, 255, 128, ${AVATAR_OPACITY})`
},
/**
* The style of the conference length (if rendered).
*/
confLength: {
color: OVERLAY_FONT_COLOR,
fontWeight: 'normal'
},
/**
* The top level container style of the list.
*/
container: {
flex: 1
},
/**
* Shows the container disabled.
*/
containerDisabled: {
opacity: 0.2
},
/**
* Second line of the list (date). May be extended with server name later.
*/
date: {
color: OVERLAY_FONT_COLOR
},
/**
* The style of the details container (right side) of the list.
*/
detailsContainer: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
marginLeft: 2 * BoxModel.margin
},
/**
* The container for an info line with an inline icon.
*/
infoWithIcon: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-start'
},
/**
* The style of an inline icon in an info line.
*/
inlineIcon: {
color: OVERLAY_FONT_COLOR,
marginRight: 5
},
/**
* First line of the list (room name).
*/
roomName: {
color: OVERLAY_FONT_COLOR,
fontSize: 18,
fontWeight: 'bold'
},
/**
* The style of one single row in the list.
*/
row: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
padding: 8,
paddingBottom: 0
},
/**
* The style of the server name component (if rendered).
*/
serverName: {
color: OVERLAY_FONT_COLOR,
fontWeight: 'normal'
}
});

View File

@ -2,15 +2,17 @@
import Button from '@atlaskit/button'; import Button from '@atlaskit/button';
import { FieldTextStateless } from '@atlaskit/field-text'; import { FieldTextStateless } from '@atlaskit/field-text';
import Tabs from '@atlaskit/tabs';
import { AtlasKitThemeProvider } from '@atlaskit/theme'; import { AtlasKitThemeProvider } from '@atlaskit/theme';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { DialogContainer } from '../../base/dialog'; import { DialogContainer } from '../../base/dialog';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { Watermarks } from '../../base/react'; import { Platform, Watermarks } from '../../base/react';
import { CalendarList } from '../../calendar-sync';
import { RecentList } from '../../recent-list'; import { RecentList } from '../../recent-list';
import { openSettingsDialog } from '../../settings'; import { SettingsButton } from '../../settings';
import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage'; import { AbstractWelcomePage, _mapStateToProps } from './AbstractWelcomePage';
@ -66,7 +68,6 @@ class WelcomePage extends AbstractWelcomePage {
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onFormSubmit = this._onFormSubmit.bind(this); this._onFormSubmit = this._onFormSubmit.bind(this);
this._onOpenSettings = this._onOpenSettings.bind(this);
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._setAdditionalContentRef this._setAdditionalContentRef
= this._setAdditionalContentRef.bind(this); = this._setAdditionalContentRef.bind(this);
@ -159,7 +160,7 @@ class WelcomePage extends AbstractWelcomePage {
{ t('welcomepage.go') } { t('welcomepage.go') }
</Button> </Button>
</div> </div>
<RecentList /> { this._renderTabs() }
</div> </div>
{ showAdditionalContent { showAdditionalContent
? <div ? <div
@ -187,16 +188,6 @@ class WelcomePage extends AbstractWelcomePage {
this._onJoin(); this._onJoin();
} }
/**
* Opens {@code SettingsDialog}.
*
* @private
* @returns {void}
*/
_onOpenSettings() {
this.props.dispatch(openSettingsDialog());
}
/** /**
* Overrides the super to account for the differences in the argument types * Overrides the super to account for the differences in the argument types
* provided by HTML and React Native text inputs. * provided by HTML and React Native text inputs.
@ -211,6 +202,47 @@ class WelcomePage extends AbstractWelcomePage {
super._onRoomChange(event.target.value); super._onRoomChange(event.target.value);
} }
/**
* Renders tabs to show previous meetings and upcoming calendar events. The
* tabs are purposefully hidden on mobile browsers.
*
* @returns {ReactElement|null}
*/
_renderTabs() {
const isMobileBrowser
= Platform.OS === 'android' || Platform.OS === 'ios';
if (isMobileBrowser) {
return null;
}
const { t } = this.props;
const tabs = [];
if (CalendarList) {
tabs.push({
label: t('welcomepage.calendar'),
content: <CalendarList />,
defaultSelected: true
});
}
tabs.push({
label: t('welcomepage.recentList'),
content: <RecentList />,
defaultSelected: !CalendarList
});
return (
<div className = 'tab-container' >
<div className = 'welcome-page-settings'>
<SettingsButton />
</div>
<Tabs tabs = { tabs } />
</div>);
}
/** /**
* Sets the internal reference to the HTMLDivElement used to hold the * Sets the internal reference to the HTMLDivElement used to hold the
* welcome page content. * welcome page content.

View File

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { PagedList } from '../../base/react'; import { PagedList } from '../../base/react';
import { MeetingList } from '../../calendar-sync'; import { CalendarList } from '../../calendar-sync';
import { RecentList } from '../../recent-list'; import { RecentList } from '../../recent-list';
import { setWelcomePageListsDefaultPage } from '../actions'; import { setWelcomePageListsDefaultPage } from '../actions';
@ -82,7 +82,7 @@ class WelcomePageLists extends Component<Props> {
title: t('welcomepage.recentList') title: t('welcomepage.recentList')
}, },
{ {
component: MeetingList, component: CalendarList,
icon: android ? 'event_note' : IOS_CALENDAR_ICON, icon: android ? 'event_note' : IOS_CALENDAR_ICON,
title: t('welcomepage.calendar') title: t('welcomepage.calendar')
} }