This commit is contained in:
98
react/features/calendar-sync/actionTypes.ts
Normal file
98
react/features/calendar-sync/actionTypes.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Resets the state of calendar integration so stored events and selected
|
||||
* calendar type are cleared.
|
||||
*
|
||||
* {
|
||||
* type: CLEAR_CALENDAR_INTEGRATION
|
||||
* }
|
||||
*/
|
||||
export const CLEAR_CALENDAR_INTEGRATION = 'CLEAR_CALENDAR_INTEGRATION';
|
||||
|
||||
/**
|
||||
* Action to refresh (re-fetch) the entry list.
|
||||
*
|
||||
* {
|
||||
* type: REFRESH_CALENDAR,
|
||||
* forcePermission: boolean,
|
||||
* isInteractive: boolean
|
||||
* }
|
||||
*/
|
||||
export const REFRESH_CALENDAR = 'REFRESH_CALENDAR';
|
||||
|
||||
/**
|
||||
* Action to signal that calendar access has already been requested since the
|
||||
* app started, so no new request should be done unless the user explicitly
|
||||
* tries to refresh the calendar view.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_AUTHORIZATION,
|
||||
* authorization: ?string
|
||||
* }
|
||||
*/
|
||||
export const SET_CALENDAR_AUTHORIZATION = 'SET_CALENDAR_AUTHORIZATION';
|
||||
|
||||
/**
|
||||
* Action to update the last error that occurred while trying to authenticate
|
||||
* with or fetch data from the calendar integration.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_ERROR,
|
||||
* error: ?Object
|
||||
* }
|
||||
*/
|
||||
export const SET_CALENDAR_ERROR = 'SET_CALENDAR_ERROR';
|
||||
|
||||
/**
|
||||
* Action to update the current calendar entry list in the store.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_EVENTS,
|
||||
* events: Array<Object>
|
||||
* }
|
||||
*/
|
||||
export const SET_CALENDAR_EVENTS = 'SET_CALENDAR_EVENTS';
|
||||
|
||||
/**
|
||||
* Action to update calendar type to be used for web.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_INTEGRATION,
|
||||
* integrationReady: boolean,
|
||||
* integrationType: string
|
||||
* }
|
||||
*/
|
||||
export const SET_CALENDAR_INTEGRATION = 'SET_CALENDAR_INTEGRATION';
|
||||
|
||||
/**
|
||||
* The type of Redux action which changes Calendar API auth state.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_AUTH_STATE
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const SET_CALENDAR_AUTH_STATE = 'SET_CALENDAR_AUTH_STATE';
|
||||
|
||||
/**
|
||||
* The type of Redux action which changes Calendar Profile email state.
|
||||
*
|
||||
* {
|
||||
* type: SET_CALENDAR_PROFILE_EMAIL,
|
||||
* email: string
|
||||
* }
|
||||
* @public
|
||||
*/
|
||||
export const SET_CALENDAR_PROFILE_EMAIL = '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
|
||||
= 'SET_LOADING_CALENDAR_EVENTS';
|
||||
60
react/features/calendar-sync/actions.any.ts
Normal file
60
react/features/calendar-sync/actions.any.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
REFRESH_CALENDAR,
|
||||
SET_CALENDAR_AUTHORIZATION,
|
||||
SET_CALENDAR_EVENTS
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Sends an action to refresh the entry list (fetches new data).
|
||||
*
|
||||
* @param {boolean} forcePermission - Whether to force to re-ask for
|
||||
* the permission or not.
|
||||
* @param {boolean} isInteractive - If true this refresh was caused by
|
||||
* direct user interaction, false otherwise.
|
||||
* @returns {{
|
||||
* type: REFRESH_CALENDAR,
|
||||
* forcePermission: boolean,
|
||||
* isInteractive: boolean
|
||||
* }}
|
||||
*/
|
||||
export function refreshCalendar(forcePermission = false, isInteractive = true) {
|
||||
return {
|
||||
type: REFRESH_CALENDAR,
|
||||
forcePermission,
|
||||
isInteractive
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to signal that a calendar access has been requested. For more
|
||||
* info, see {@link SET_CALENDAR_AUTHORIZATION}.
|
||||
*
|
||||
* @param {string | undefined} authorization - The result of the last calendar
|
||||
* authorization request.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_AUTHORIZATION,
|
||||
* authorization: ?string
|
||||
* }}
|
||||
*/
|
||||
export function setCalendarAuthorization(authorization?: string) {
|
||||
return {
|
||||
type: SET_CALENDAR_AUTHORIZATION,
|
||||
authorization
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to update the current calendar list in redux.
|
||||
*
|
||||
* @param {Array<Object>} events - The new list.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_EVENTS,
|
||||
* events: Array<Object>
|
||||
* }}
|
||||
*/
|
||||
export function setCalendarEvents(events: Array<Object>) {
|
||||
return {
|
||||
type: SET_CALENDAR_EVENTS,
|
||||
events
|
||||
};
|
||||
}
|
||||
45
react/features/calendar-sync/actions.native.ts
Normal file
45
react/features/calendar-sync/actions.native.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// @ts-expect-error
|
||||
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
|
||||
|
||||
import { getDefaultURL } from '../app/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
|
||||
import { refreshCalendar } from './actions';
|
||||
import UpdateCalendarEventDialog from './components/UpdateCalendarEventDialog.native';
|
||||
import { addLinkToCalendarEntry } from './functions.native';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Asks confirmation from the user to add a Jitsi link to the calendar event.
|
||||
*
|
||||
* @param {string} eventId - The event id.
|
||||
* @returns {{
|
||||
* type: OPEN_DIALOG,
|
||||
* component: React.Component,
|
||||
* componentProps: (Object | undefined)
|
||||
* }}
|
||||
*/
|
||||
export function openUpdateCalendarEventDialog(eventId: string) {
|
||||
return openDialog(UpdateCalendarEventDialog, { eventId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates calendar event by generating new invite URL and editing the event
|
||||
* adding some descriptive text and location.
|
||||
*
|
||||
* @param {string} eventId - The event id.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function updateCalendarEvent(eventId: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const defaultUrl = getDefaultURL(getState);
|
||||
const roomName = generateRoomWithoutSeparator();
|
||||
|
||||
addLinkToCalendarEntry(getState(), eventId, `${defaultUrl}/${roomName}`)
|
||||
.finally(() => {
|
||||
dispatch(refreshCalendar(false, false));
|
||||
});
|
||||
};
|
||||
}
|
||||
292
react/features/calendar-sync/actions.web.ts
Normal file
292
react/features/calendar-sync/actions.web.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// @ts-expect-error
|
||||
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
|
||||
|
||||
import { createCalendarConnectedEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import { loadGoogleAPI } from '../google-api/actions';
|
||||
|
||||
import {
|
||||
CLEAR_CALENDAR_INTEGRATION,
|
||||
SET_CALENDAR_AUTH_STATE,
|
||||
SET_CALENDAR_ERROR,
|
||||
SET_CALENDAR_INTEGRATION,
|
||||
SET_CALENDAR_PROFILE_EMAIL,
|
||||
SET_LOADING_CALENDAR_EVENTS
|
||||
} from './actionTypes';
|
||||
import { refreshCalendar, setCalendarEvents } from './actions.web';
|
||||
import { _getCalendarIntegration, isCalendarEnabled } from './functions.web';
|
||||
import logger from './logger';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Sets the initial state of calendar integration by loading third party APIs
|
||||
* and filling out any data that needs to be fetched.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function bootstrapCalendarIntegration() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
|
||||
if (!isCalendarEnabled(state)) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const {
|
||||
googleApiApplicationClientID
|
||||
} = state['features/base/config'];
|
||||
const {
|
||||
integrationReady,
|
||||
integrationType
|
||||
} = state['features/calendar-sync'];
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
if (googleApiApplicationClientID) {
|
||||
return dispatch(loadGoogleAPI());
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (!integrationType || integrationReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integrationToLoad
|
||||
= _getCalendarIntegration(integrationType);
|
||||
|
||||
if (!integrationToLoad) {
|
||||
dispatch(clearCalendarIntegration());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return dispatch(integrationToLoad._isSignedIn())
|
||||
.then((signedIn: boolean) => {
|
||||
if (signedIn) {
|
||||
dispatch(setIntegrationReady(integrationType));
|
||||
dispatch(updateProfile(integrationType));
|
||||
} else {
|
||||
dispatch(clearCalendarIntegration());
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state of calendar integration so stored events and selected
|
||||
* calendar type are cleared.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CLEAR_CALENDAR_INTEGRATION
|
||||
* }}
|
||||
*/
|
||||
export function clearCalendarIntegration() {
|
||||
return {
|
||||
type: CLEAR_CALENDAR_INTEGRATION
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks confirmation from the user to add a Jitsi link to the calendar event.
|
||||
*
|
||||
* NOTE: Currently there is no confirmation prompted on web, so this is just
|
||||
* a relaying method to avoid flow problems.
|
||||
*
|
||||
* @param {string} eventId - The event id.
|
||||
* @param {string} calendarId - The calendar id.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openUpdateCalendarEventDialog(
|
||||
eventId: string, calendarId: string) {
|
||||
return updateCalendarEvent(eventId, calendarId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to update the current calendar api auth state in redux.
|
||||
* This is used only for microsoft implementation to store it auth state.
|
||||
*
|
||||
* @param {number} newState - The new state.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_AUTH_STATE,
|
||||
* msAuthState: Object
|
||||
* }}
|
||||
*/
|
||||
export function setCalendarAPIAuthState(newState?: Object) {
|
||||
return {
|
||||
type: SET_CALENDAR_AUTH_STATE,
|
||||
msAuthState: newState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to update the calendar error state in redux.
|
||||
*
|
||||
* @param {Object} error - An object with error details.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_ERROR,
|
||||
* error: Object
|
||||
* }}
|
||||
*/
|
||||
export function setCalendarError(error?: Object) {
|
||||
return {
|
||||
type: SET_CALENDAR_ERROR,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to update the current calendar profile email state in redux.
|
||||
*
|
||||
* @param {number} newEmail - The new email.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_PROFILE_EMAIL,
|
||||
* email: string
|
||||
* }}
|
||||
*/
|
||||
export function setCalendarProfileEmail(newEmail?: string) {
|
||||
return {
|
||||
type: SET_CALENDAR_PROFILE_EMAIL,
|
||||
email: newEmail
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* integration is ready to be used.
|
||||
*
|
||||
* @param {string|undefined} integrationType - The calendar type.
|
||||
* @returns {{
|
||||
* type: SET_CALENDAR_INTEGRATION,
|
||||
* integrationReady: boolean,
|
||||
* integrationType: string
|
||||
* }}
|
||||
*/
|
||||
export function setIntegrationReady(integrationType: string) {
|
||||
return {
|
||||
type: SET_CALENDAR_INTEGRATION,
|
||||
integrationReady: true,
|
||||
integrationType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals signing in to the specified calendar integration.
|
||||
*
|
||||
* @param {string} calendarType - The calendar integration which should be
|
||||
* signed into.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function signIn(calendarType: string) {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const integration = _getCalendarIntegration(calendarType);
|
||||
|
||||
if (!integration) {
|
||||
return Promise.reject('No supported integration found');
|
||||
}
|
||||
|
||||
return dispatch(integration.load())
|
||||
.then(() => dispatch(integration.signIn()))
|
||||
.then(() => dispatch(setIntegrationReady(calendarType)))
|
||||
.then(() => dispatch(updateProfile(calendarType)))
|
||||
.then(() => dispatch(refreshCalendar()))
|
||||
.then(() => sendAnalytics(createCalendarConnectedEvent()))
|
||||
.catch((error: any) => {
|
||||
logger.error(
|
||||
'Error occurred while signing into calendar integration',
|
||||
error);
|
||||
|
||||
return Promise.reject(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates calendar event by generating new invite URL and editing the event
|
||||
* adding some descriptive text and location.
|
||||
*
|
||||
* @param {string} id - The event id.
|
||||
* @param {string} calendarId - The id of the calendar to use.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function updateCalendarEvent(id: string, calendarId: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
|
||||
const { integrationType = '' } = getState()['features/calendar-sync'];
|
||||
const integration = _getCalendarIntegration(integrationType);
|
||||
|
||||
if (!integration) {
|
||||
return Promise.reject('No integration found');
|
||||
}
|
||||
|
||||
const { locationURL } = getState()['features/base/connection'];
|
||||
const newRoomName = generateRoomWithoutSeparator();
|
||||
let href = locationURL?.href ?? '';
|
||||
|
||||
href.endsWith('/') || (href += '/');
|
||||
|
||||
const roomURL = `${href}${newRoomName}`;
|
||||
|
||||
return dispatch(integration.updateCalendarEvent(
|
||||
id, calendarId, roomURL))
|
||||
.then(() => {
|
||||
// make a copy of the array
|
||||
const events
|
||||
= getState()['features/calendar-sync'].events.slice(0);
|
||||
|
||||
const eventIx = events.findIndex(
|
||||
e => e.id === id && e.calendarId === calendarId);
|
||||
|
||||
// clone the event we will modify
|
||||
const newEvent = Object.assign({}, events[eventIx]);
|
||||
|
||||
newEvent.url = roomURL;
|
||||
events[eventIx] = newEvent;
|
||||
|
||||
return dispatch(setCalendarEvents(events));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to get current profile data linked to the current calendar
|
||||
* integration that is in use.
|
||||
*
|
||||
* @param {string} calendarType - The calendar integration to which the profile
|
||||
* should be updated.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function updateProfile(calendarType: string) {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const integration = _getCalendarIntegration(calendarType);
|
||||
|
||||
if (!integration) {
|
||||
return Promise.reject('No integration found');
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return dispatch(integration.getCurrentEmail())
|
||||
.then((email: string) => {
|
||||
dispatch(setCalendarProfileEmail(email));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* A React Component for adding a meeting URL to an existing calendar meeting.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class AddMeetingUrlButton extends Component<void> {
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
// Not yet implemented.
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AddMeetingUrlButton;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createCalendarClickedEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IStore } from '../../app/types';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import Icon from '../../base/icons/components/Icon';
|
||||
import { IconPlus } from '../../base/icons/svg';
|
||||
import Tooltip from '../../base/tooltip/components/Tooltip';
|
||||
import { updateCalendarEvent } from '../actions.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link AddMeetingUrlButton}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The calendar ID associated with the calendar event.
|
||||
*/
|
||||
calendarId: string;
|
||||
|
||||
/**
|
||||
* Invoked to add a meeting URL to a calendar event.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The ID of the calendar event that will have a meeting URL added on click.
|
||||
*/
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component for adding a meeting URL to an existing calendar event.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class AddMeetingUrlButton extends Component<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code AddMeetingUrlButton} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._onKeyPress = this._onKeyPress.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<Tooltip content = { this.props.t('calendarSync.addMeetingURL') }>
|
||||
<div
|
||||
className = 'button add-button'
|
||||
onClick = { this._onClick }
|
||||
onKeyPress = { this._onKeyPress }
|
||||
role = 'button'>
|
||||
<Icon src = { IconPlus } />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an action to adding a meeting URL to a calendar event.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
const { calendarId, dispatch, eventId } = this.props;
|
||||
|
||||
sendAnalytics(createCalendarClickedEvent('add.url'));
|
||||
|
||||
dispatch(updateCalendarEvent(eventId, calendarId));
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(AddMeetingUrlButton));
|
||||
143
react/features/calendar-sync/components/CalendarList.native.tsx
Normal file
143
react/features/calendar-sync/components/CalendarList.native.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import {
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import AbstractPage from '../../base/react/components/AbstractPage';
|
||||
import { openSettings } from '../../mobile/permissions/functions';
|
||||
import { refreshCalendar } from '../actions.native';
|
||||
|
||||
import CalendarListContent from './CalendarListContent.native';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link CalendarList}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The current state of the calendar access permission.
|
||||
*/
|
||||
_authorization?: string;
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a list of events from the (mobile) user's calendar.
|
||||
*/
|
||||
class CalendarList extends AbstractPage<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code CalendarList} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._getRenderListEmptyComponent
|
||||
= this._getRenderListEmptyComponent.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API method for {@code Component}s rendered in
|
||||
* {@link AbstractPagedList}. When invoked, refreshes the calendar entries
|
||||
* in the app.
|
||||
*
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @param {boolean} isInteractive - If true this refresh was caused by
|
||||
* direct user interaction, false otherwise.
|
||||
* @public
|
||||
* @returns {void}
|
||||
*/
|
||||
static refresh(dispatch: IStore['dispatch'], isInteractive: boolean) {
|
||||
dispatch(refreshCalendar(false, isInteractive));
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
return (
|
||||
CalendarListContent
|
||||
? <View
|
||||
style = {
|
||||
(disabled
|
||||
? styles.calendarSyncDisabled
|
||||
: styles.calendarSync) as ViewStyle }>
|
||||
<CalendarListContent
|
||||
disabled = { disabled }
|
||||
listEmptyComponent
|
||||
= { this._getRenderListEmptyComponent() } />
|
||||
</View>
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style = { styles.noPermissionMessageView as ViewStyle }>
|
||||
<Text style = { styles.noPermissionMessageText as ViewStyle }>
|
||||
{ t('calendarSync.permissionMessage') }
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress = { openSettings }
|
||||
style = { styles.noPermissionMessageButton as ViewStyle } >
|
||||
<Text style = { styles.noPermissionMessageButtonText as ViewStyle }>
|
||||
{ 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: IReduxState) {
|
||||
const { authorization } = state['features/calendar-sync'];
|
||||
|
||||
return {
|
||||
_authorization: authorization
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(CalendarList));
|
||||
269
react/features/calendar-sync/components/CalendarList.web.tsx
Normal file
269
react/features/calendar-sync/components/CalendarList.web.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createCalendarClickedEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import Icon from '../../base/icons/components/Icon';
|
||||
import { IconCalendar } from '../../base/icons/svg';
|
||||
import AbstractPage from '../../base/react/components/AbstractPage';
|
||||
import Spinner from '../../base/ui/components/web/Spinner';
|
||||
import { openSettingsDialog } from '../../settings/actions.web';
|
||||
import { SETTINGS_TABS } from '../../settings/constants';
|
||||
import { refreshCalendar } from '../actions.web';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
import CalendarListContent from './CalendarListContent.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link CalendarList}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The error object containing details about any error that has occurred
|
||||
* while interacting with calendar integration.
|
||||
*/
|
||||
_calendarError?: { error: string; };
|
||||
|
||||
/**
|
||||
* 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: IStore['dispatch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a list of events from the user's calendar.
|
||||
*/
|
||||
class CalendarList extends AbstractPage<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code CalendarList} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
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._onKeyPressOpenSettings = this._onKeyPressOpenSettings.bind(this);
|
||||
this._onRefreshEvents = this._onRefreshEvents.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
return (
|
||||
CalendarListContent
|
||||
? <CalendarListContent
|
||||
disabled = { Boolean(disabled) }
|
||||
listEmptyComponent
|
||||
= { this._getRenderListEmptyComponent() } />
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component for showing the error message related to calendar
|
||||
* sync.
|
||||
*
|
||||
* @private
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_getErrorMessage() {
|
||||
const { _calendarError = { error: undefined }, t } = this.props;
|
||||
|
||||
let errorMessageKey = 'calendarSync.error.generic';
|
||||
let showRefreshButton = true;
|
||||
let showSettingsButton = true;
|
||||
|
||||
if (_calendarError.error === ERRORS.GOOGLE_APP_MISCONFIGURED) {
|
||||
errorMessageKey = 'calendarSync.error.appConfiguration';
|
||||
showRefreshButton = false;
|
||||
showSettingsButton = false;
|
||||
} else if (_calendarError.error === ERRORS.AUTH_FAILED) {
|
||||
errorMessageKey = 'calendarSync.error.notSignedIn';
|
||||
showRefreshButton = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = 'meetings-list-empty'>
|
||||
<p className = 'description'>
|
||||
{ t(errorMessageKey) }
|
||||
</p>
|
||||
<div className = 'calendar-action-buttons'>
|
||||
{ showSettingsButton
|
||||
&& <div
|
||||
className = 'button'
|
||||
onClick = { this._onOpenSettings }>
|
||||
{ t('calendarSync.permissionButton') }
|
||||
</div>
|
||||
}
|
||||
{ showRefreshButton
|
||||
&& <div
|
||||
className = 'button'
|
||||
onClick = { this._onRefreshEvents }>
|
||||
{ t('calendarSync.refresh') }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
_calendarError,
|
||||
_hasIntegrationSelected,
|
||||
_hasLoadedEvents,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (_calendarError) {
|
||||
return this._getErrorMessage();
|
||||
} else if (_hasIntegrationSelected && _hasLoadedEvents) {
|
||||
return (
|
||||
<div className = 'meetings-list-empty'>
|
||||
<p className = 'description'>
|
||||
{ t('calendarSync.noEvents') }
|
||||
</p>
|
||||
<div
|
||||
className = 'button'
|
||||
onClick = { this._onRefreshEvents }>
|
||||
{ t('calendarSync.refresh') }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (_hasIntegrationSelected && !_hasLoadedEvents) {
|
||||
return (
|
||||
<div className = 'meetings-list-empty'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = 'meetings-list-empty'>
|
||||
<div className = 'meetings-list-empty-image'>
|
||||
<img
|
||||
alt = { t('welcomepage.logo.calendar') }
|
||||
src = './images/calendar.svg' />
|
||||
</div>
|
||||
<div className = 'description'>
|
||||
{ t('welcomepage.connectCalendarText', {
|
||||
app: interfaceConfig.APP_NAME,
|
||||
provider: interfaceConfig.PROVIDER_NAME
|
||||
}) }
|
||||
</div>
|
||||
<div
|
||||
className = 'meetings-list-empty-button'
|
||||
onClick = { this._onOpenSettings }
|
||||
onKeyPress = { this._onKeyPressOpenSettings }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<Icon
|
||||
className = 'meetings-list-empty-icon'
|
||||
src = { IconCalendar } />
|
||||
<span>{ t('welcomepage.connectCalendarButton') }</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@code SettingsDialog}.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenSettings() {
|
||||
sendAnalytics(createCalendarClickedEvent('connect'));
|
||||
|
||||
this.props.dispatch(openSettingsDialog(SETTINGS_TABS.CALENDAR));
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyPressOpenSettings(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onOpenSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 {{
|
||||
* _calendarError: Object,
|
||||
* _hasIntegrationSelected: boolean,
|
||||
* _hasLoadedEvents: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
error,
|
||||
events,
|
||||
integrationType,
|
||||
isLoadingEvents
|
||||
} = state['features/calendar-sync'];
|
||||
|
||||
return {
|
||||
_calendarError: error,
|
||||
_hasIntegrationSelected: Boolean(integrationType),
|
||||
_hasLoadedEvents: Boolean(events) || !isLoadingEvents
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(CalendarList));
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createCalendarClickedEvent, createCalendarSelectedEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { appNavigate } from '../../app/actions.native';
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { getLocalizedDateFormatter } from '../../base/i18n/dateUtil';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import NavigateSectionList from '../../base/react/components/native/NavigateSectionList';
|
||||
import { openUpdateCalendarEventDialog, refreshCalendar } from '../actions.native';
|
||||
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link CalendarListContent}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The calendar event list.
|
||||
*/
|
||||
_eventList: Array<any>;
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
listEmptyComponent: React.ReactElement<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a list of events from a connected calendar.
|
||||
*/
|
||||
class CalendarListContent extends Component<IProps> {
|
||||
/**
|
||||
* Default values for the component's props.
|
||||
*/
|
||||
static defaultProps = {
|
||||
_eventList: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code CalendarListContent} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onPress = this._onPress.bind(this);
|
||||
this._onRefresh = this._onRefresh.bind(this);
|
||||
this._onSecondaryAction = this._onSecondaryAction.bind(this);
|
||||
this._toDateString = this._toDateString.bind(this);
|
||||
this._toDisplayableItem = this._toDisplayableItem.bind(this);
|
||||
this._toDisplayableList = this._toDisplayableList.bind(this);
|
||||
this._toTimeString = this._toTimeString.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
sendAnalytics(createCalendarSelectedEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { disabled, listEmptyComponent } = this.props;
|
||||
|
||||
return (
|
||||
<NavigateSectionList
|
||||
disabled = { disabled }
|
||||
onPress = { this._onPress }
|
||||
onRefresh = { this._onRefresh }
|
||||
onSecondaryAction = { this._onSecondaryAction }
|
||||
renderListEmptyComponent
|
||||
= { listEmptyComponent }
|
||||
sections = { this._toDisplayableList() } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the list's navigate action.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The url string to navigate to.
|
||||
* @param {string} analyticsEventName - Тhe name of the analytics event
|
||||
* associated with this action.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPress(url: string, analyticsEventName = 'meeting.tile') {
|
||||
sendAnalytics(createCalendarClickedEvent(analyticsEventName));
|
||||
|
||||
this.props.dispatch(appNavigate(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to execute when the list is doing a pull-to-refresh.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRefresh() {
|
||||
this.props.dispatch(refreshCalendar(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the list's secondary action.
|
||||
*
|
||||
* @private
|
||||
* @param {string} id - The ID of the item on which the secondary action was
|
||||
* performed.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSecondaryAction(id: string) {
|
||||
this.props.dispatch(openUpdateCalendarEventDialog(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a date string for a given event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_toDateString(event: any) {
|
||||
const startDateTime
|
||||
= getLocalizedDateFormatter(event.startDate).format('MMM Do, YYYY');
|
||||
|
||||
return `${startDateTime}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a displayable object from an event.
|
||||
*
|
||||
* @param {Object} event - The calendar event.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_toDisplayableItem(event: any) {
|
||||
return {
|
||||
id: event.id,
|
||||
key: `${event.id}-${event.startDate}`,
|
||||
lines: [
|
||||
event.url,
|
||||
this._toTimeString(event)
|
||||
],
|
||||
title: event.title,
|
||||
url: event.url
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the event list to a displayable list with sections.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_toDisplayableList() {
|
||||
const { _eventList, t } = this.props;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const { createSection } = NavigateSectionList;
|
||||
const TODAY_SECTION = 'today';
|
||||
const sectionMap = new Map();
|
||||
|
||||
for (const event of _eventList) {
|
||||
const displayableEvent = this._toDisplayableItem(event);
|
||||
const startDate = new Date(event.startDate).getDate();
|
||||
|
||||
if (startDate === now.getDate()) {
|
||||
let todaySection = sectionMap.get(TODAY_SECTION);
|
||||
|
||||
if (!todaySection) {
|
||||
todaySection
|
||||
= createSection(t('calendarSync.today'), TODAY_SECTION);
|
||||
sectionMap.set(TODAY_SECTION, todaySection);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(sectionMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a time (interval) string for a given event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_toTimeString(event: any) {
|
||||
const startDateTime
|
||||
= getLocalizedDateFormatter(event.startDate).format('lll');
|
||||
const endTime
|
||||
= getLocalizedDateFormatter(event.endDate).format('LT');
|
||||
|
||||
return `${startDateTime} - ${endTime}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_eventList: state['features/calendar-sync'].events
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(CalendarListContent));
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createCalendarClickedEvent, createCalendarSelectedEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { appNavigate } from '../../app/actions.web';
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import MeetingsList from '../../base/react/components/web/MeetingsList';
|
||||
|
||||
import AddMeetingUrlButton from './AddMeetingUrlButton.web';
|
||||
import JoinButton from './JoinButton.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link CalendarListContent}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The calendar event list.
|
||||
*/
|
||||
_eventList: Array<Object>;
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
listEmptyComponent: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a list of events from a connected calendar.
|
||||
*/
|
||||
class CalendarListContent extends Component<IProps> {
|
||||
/**
|
||||
* Default values for the component's props.
|
||||
*/
|
||||
static defaultProps = {
|
||||
_eventList: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code CalendarListContent} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onJoinPress = this._onJoinPress.bind(this);
|
||||
this._onPress = this._onPress.bind(this);
|
||||
this._toDisplayableItem = this._toDisplayableItem.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
sendAnalytics(createCalendarSelectedEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { disabled, listEmptyComponent } = this.props;
|
||||
const { _eventList = [] } = this.props;
|
||||
const meetings = _eventList.map(this._toDisplayableItem);
|
||||
|
||||
return (
|
||||
<MeetingsList
|
||||
disabled = { disabled }
|
||||
listEmptyComponent = { listEmptyComponent }
|
||||
meetings = { meetings }
|
||||
onPress = { this._onPress } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the list's navigate action.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - The click event.
|
||||
* @param {string} url - The url string to navigate to.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onJoinPress(event: React.KeyboardEvent, url: string) {
|
||||
event.stopPropagation();
|
||||
|
||||
this._onPress(url, 'meeting.join');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the list's navigate action.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The url string to navigate to.
|
||||
* @param {string} analyticsEventName - Тhe name of the analytics event
|
||||
* associated with this action.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPress(url: string, analyticsEventName = 'meeting.tile') {
|
||||
sendAnalytics(createCalendarClickedEvent(analyticsEventName));
|
||||
|
||||
this.props.dispatch(appNavigate(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a displayable object from an event.
|
||||
*
|
||||
* @param {Object} event - The calendar event.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_toDisplayableItem(event: any) {
|
||||
return {
|
||||
elementAfter: event.url
|
||||
? <JoinButton
|
||||
onPress = { this._onJoinPress }
|
||||
url = { event.url } />
|
||||
: (<AddMeetingUrlButton
|
||||
calendarId = { event.calendarId }
|
||||
eventId = { event.id } />),
|
||||
date: event.startDate,
|
||||
time: [ event.startDate, event.endDate ],
|
||||
description: event.url,
|
||||
title: event.title,
|
||||
url: event.url
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {{
|
||||
* _eventList: Array<Object>
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_eventList: state['features/calendar-sync'].events
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(CalendarListContent);
|
||||
21
react/features/calendar-sync/components/JoinButton.native.ts
Normal file
21
react/features/calendar-sync/components/JoinButton.native.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* A React Component for joining an existing calendar meeting.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class JoinButton extends Component<void> {
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
// Not yet implemented.
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default JoinButton;
|
||||
96
react/features/calendar-sync/components/JoinButton.web.tsx
Normal file
96
react/features/calendar-sync/components/JoinButton.web.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import Icon from '../../base/icons/components/Icon';
|
||||
import { IconPlus } from '../../base/icons/svg';
|
||||
import Tooltip from '../../base/tooltip/components/Tooltip';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link JoinButton}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The function called when the button is pressed.
|
||||
*/
|
||||
onPress: Function;
|
||||
|
||||
/**
|
||||
* The meeting URL associated with the {@link JoinButton} instance.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component for joining an existing calendar meeting.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class JoinButton extends Component<IProps> {
|
||||
|
||||
/**
|
||||
* Initializes a new {@code JoinButton} instance.
|
||||
*
|
||||
* @param {*} props - The read-only properties with which the new instance
|
||||
* is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._onKeyPress = this._onKeyPress.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content = { t('calendarSync.joinTooltip') }>
|
||||
<div
|
||||
className = 'button join-button'
|
||||
onClick = { this._onClick }
|
||||
onKeyPress = { this._onKeyPress }
|
||||
role = 'button'>
|
||||
<Icon
|
||||
size = '14'
|
||||
src = { IconPlus } />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when the component is clicked.
|
||||
*
|
||||
* @param {Object} event - The DOM click event.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick(event?: React.MouseEvent) {
|
||||
this.props.onPress(event, this.props.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(JoinButton);
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link MicrosoftSignInButton}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The callback to invoke when {@code MicrosoftSignInButton} is clicked.
|
||||
*/
|
||||
onClick: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* The text to display within {@code MicrosoftSignInButton}.
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React Component showing a button to sign in with Microsoft.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class MicrosoftSignInButton extends Component<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<div
|
||||
className = 'microsoft-sign-in'
|
||||
onClick = { this.props.onClick }>
|
||||
<img
|
||||
alt = { this.props.t('welcomepage.logo.microsoftLogo') }
|
||||
className = 'microsoft-logo'
|
||||
src = 'images/microsoftLogo.svg' />
|
||||
<div className = 'microsoft-cta'>
|
||||
{ this.props.text }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(MicrosoftSignInButton);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import ConfirmDialog from '../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../base/i18n/functions';
|
||||
import { updateCalendarEvent } from '../actions';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The ID of the event to be updated.
|
||||
*/
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the add Jitsi link confirm dialog.
|
||||
*/
|
||||
class UpdateCalendarEventDialog extends Component<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code UpdateCalendarEventDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
descriptionKey = 'calendarSync.confirmAddLink'
|
||||
onSubmit = { this._onSubmit } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the confirm button.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True (to note that the modal should be closed).
|
||||
*/
|
||||
_onSubmit() {
|
||||
this.props.dispatch(updateCalendarEvent(this.props.eventId));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(UpdateCalendarEventDialog));
|
||||
182
react/features/calendar-sync/components/styles.ts
Normal file
182
react/features/calendar-sync/components/styles.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { ColorPalette } from '../../base/styles/components/styles/ColorPalette';
|
||||
import { createStyleSheet } from '../../base/styles/functions.any';
|
||||
import BaseTheme from '../../base/ui/components/BaseTheme';
|
||||
|
||||
const NOTIFICATION_SIZE = 55;
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Component}s of the feature meeting-list i.e.
|
||||
* {@code CalendarList}.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
/**
|
||||
* Button style of the open settings button.
|
||||
*/
|
||||
noPermissionMessageButton: {
|
||||
backgroundColor: ColorPalette.blue,
|
||||
borderColor: ColorPalette.blue,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
margin: 15,
|
||||
paddingHorizontal: 20
|
||||
},
|
||||
|
||||
/**
|
||||
* Text style of the open settings button.
|
||||
*/
|
||||
noPermissionMessageButtonText: {
|
||||
color: ColorPalette.white
|
||||
},
|
||||
|
||||
/**
|
||||
* Text style of the no permission message.
|
||||
*/
|
||||
noPermissionMessageText: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* Top level view of the no permission message.
|
||||
*/
|
||||
noPermissionMessageView: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: 20
|
||||
},
|
||||
|
||||
/**
|
||||
* The top level container of the notification.
|
||||
*/
|
||||
notificationContainer: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute'
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the container when the notification is displayed
|
||||
* on the side (narrow view).
|
||||
*/
|
||||
notificationContainerSide: {
|
||||
top: 100
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the container when the notification is displayed
|
||||
* on the top (wide view).
|
||||
*/
|
||||
notificationContainerTop: {
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
|
||||
/**
|
||||
* The top level container of the notification.
|
||||
*/
|
||||
notificationContent: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 10
|
||||
},
|
||||
|
||||
/**
|
||||
* Color for upcoming meeting notification.
|
||||
*/
|
||||
notificationContentNext: {
|
||||
backgroundColor: '#eeb231'
|
||||
},
|
||||
|
||||
/**
|
||||
* Color for already ongoing meeting notifications.
|
||||
*/
|
||||
notificationContentPast: {
|
||||
backgroundColor: 'red'
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the content when the notification is displayed
|
||||
* on the side (narrow view).
|
||||
*/
|
||||
notificationContentSide: {
|
||||
borderBottomRightRadius: NOTIFICATION_SIZE,
|
||||
borderTopRightRadius: NOTIFICATION_SIZE
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the content when the notification is displayed
|
||||
* on the top (wide view).
|
||||
*/
|
||||
notificationContentTop: {
|
||||
borderBottomLeftRadius: NOTIFICATION_SIZE / 2,
|
||||
borderBottomRightRadius: NOTIFICATION_SIZE / 2,
|
||||
paddingHorizontal: 20
|
||||
},
|
||||
|
||||
/**
|
||||
* The icon of the notification.
|
||||
*/
|
||||
notificationIcon: {
|
||||
color: 'white',
|
||||
fontSize: '1.5rem'
|
||||
},
|
||||
|
||||
/**
|
||||
* The container that contains the icon.
|
||||
*/
|
||||
notificationIconContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* A single line of text of the notification.
|
||||
*/
|
||||
notificationText: {
|
||||
color: 'white',
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
|
||||
/**
|
||||
* The container for all the lines if the notification.
|
||||
*/
|
||||
notificationTextContainer: {
|
||||
flexDirection: 'column',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* The touchable component.
|
||||
*/
|
||||
touchableView: {
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
calendarSync: {
|
||||
backgroundColor: BaseTheme.palette.uiBackground,
|
||||
flex: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
|
||||
calendarSyncDisabled: {
|
||||
backgroundColor: BaseTheme.palette.uiBackground,
|
||||
flex: 1,
|
||||
opacity: 0.8,
|
||||
overflow: 'hidden'
|
||||
}
|
||||
});
|
||||
35
react/features/calendar-sync/constants.ts
Normal file
35
react/features/calendar-sync/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* An enumeration of support calendar integration types.
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const CALENDAR_TYPE = {
|
||||
GOOGLE: 'google',
|
||||
MICROSOFT: 'microsoft'
|
||||
};
|
||||
|
||||
/**
|
||||
* An enumeration of known errors that can occur while interacting with the
|
||||
* calendar integration.
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ERRORS = {
|
||||
AUTH_FAILED: 'sign_in_failed',
|
||||
GOOGLE_APP_MISCONFIGURED: 'idpiframe_initialization_failed'
|
||||
};
|
||||
|
||||
/**
|
||||
* The number of days to fetch.
|
||||
*/
|
||||
export const FETCH_END_DAYS = 10;
|
||||
|
||||
/**
|
||||
* The number of days to go back when fetching.
|
||||
*/
|
||||
export const FETCH_START_DAYS = -1;
|
||||
|
||||
/**
|
||||
* The max number of events to fetch from the calendar.
|
||||
*/
|
||||
export const MAX_LIST_LENGTH = 10;
|
||||
205
react/features/calendar-sync/functions.any.ts
Normal file
205
react/features/calendar-sync/functions.any.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import md5 from 'js-md5';
|
||||
|
||||
import { APP_LINK_SCHEME, parseURIString } from '../base/util/uri';
|
||||
|
||||
import { setCalendarEvents } from './actions';
|
||||
import { MAX_LIST_LENGTH } from './constants';
|
||||
|
||||
const ALLDAY_EVENT_LENGTH = 23 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Returns true of the calendar entry is to be displayed in the app, false
|
||||
* otherwise.
|
||||
*
|
||||
* @param {Object} entry - The calendar entry.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isDisplayableCalendarEntry(entry: { allDay: boolean; attendees: Object[];
|
||||
endDate: number; startDate: number; }) {
|
||||
// Entries are displayable if:
|
||||
// - Ends in the future (future or ongoing events)
|
||||
// - Is not an all day event and there is only one attendee (these events
|
||||
// are usually placeholder events that don't need to be shown.)
|
||||
return entry.endDate > Date.now()
|
||||
&& !((entry.allDay
|
||||
|| entry.endDate - entry.startDate > ALLDAY_EVENT_LENGTH)
|
||||
&& (!entry.attendees || entry.attendees.length < 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the calendar entries in redux when new list is received. The feature
|
||||
* calendar-sync doesn't display all calendar events, it displays unique
|
||||
* title, URL, and start time tuples, and it doesn't display subsequent
|
||||
* occurrences of recurring events, and the repetitions of events coming from
|
||||
* multiple calendars.
|
||||
*
|
||||
* XXX The function's {@code this} is the redux store.
|
||||
*
|
||||
* @param {Array<CalendarEntry>} events - The new event list.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
export function _updateCalendarEntries(events: Array<Object>) {
|
||||
if (!events?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-this
|
||||
const { dispatch, getState } = this;
|
||||
const knownDomains = getState()['features/base/known-domains'];
|
||||
const entryMap = new Map();
|
||||
|
||||
for (const event of events) {
|
||||
const entry = _parseCalendarEntry(event, knownDomains);
|
||||
|
||||
if (entry && _isDisplayableCalendarEntry(entry)) {
|
||||
// As was stated above, we don't display subsequent occurrences of
|
||||
// recurring events, and the repetitions of events coming from
|
||||
// multiple calendars.
|
||||
const key = md5.hex(JSON.stringify([
|
||||
|
||||
// Obviously, we want to display different conference/meetings
|
||||
// URLs. URLs are the very reason why we implemented the feature
|
||||
// calendar-sync in the first place.
|
||||
entry.url,
|
||||
|
||||
// We probably want to display one and the same URL to people if
|
||||
// they have it under different titles in their Calendar.
|
||||
// Because maybe they remember the title of the meeting, not the
|
||||
// URL so they expect to see the title without realizing that
|
||||
// they have the same URL already under a different title.
|
||||
entry.title,
|
||||
|
||||
// XXX Eventually, given that the URL and the title are the
|
||||
// same, what sets one event apart from another is the start
|
||||
// time of the day (note the use of toTimeString() below)! The
|
||||
// day itself is not important because we don't want multiple
|
||||
// occurrences of a recurring event or repetitions of an even
|
||||
// from multiple calendars.
|
||||
new Date(entry.startDate).toTimeString()
|
||||
]));
|
||||
const existingEntry = entryMap.get(key);
|
||||
|
||||
// We want only the earliest occurrence (which hasn't ended in the
|
||||
// past, that is) of a recurring event.
|
||||
if (!existingEntry || existingEntry.startDate > entry.startDate) {
|
||||
entryMap.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(
|
||||
setCalendarEvents(
|
||||
Array.from(entryMap.values())
|
||||
.sort((a, b) => a.startDate - b.startDate)
|
||||
.slice(0, MAX_LIST_LENGTH)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a string against a positive pattern and a negative pattern. Returns
|
||||
* the string if it matches the positive pattern and doesn't provide any match
|
||||
* against the negative pattern. Null otherwise.
|
||||
*
|
||||
* @param {string} str - The string to check.
|
||||
* @param {string} positivePattern - The positive pattern.
|
||||
* @param {string} negativePattern - The negative pattern.
|
||||
* @returns {string}
|
||||
*/
|
||||
function _checkPattern(str: string, positivePattern: string, negativePattern: string) {
|
||||
const positiveRegExp = new RegExp(positivePattern, 'gi');
|
||||
let positiveMatch = positiveRegExp.exec(str);
|
||||
|
||||
while (positiveMatch !== null) {
|
||||
const url = positiveMatch[0];
|
||||
|
||||
if (!new RegExp(negativePattern, 'gi').exec(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
positiveMatch = positiveRegExp.exec(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the calendar entries in Redux when new list is received.
|
||||
*
|
||||
* @param {Object} event - An event returned from the native calendar.
|
||||
* @param {Array<string>} knownDomains - The known domain list.
|
||||
* @private
|
||||
* @returns {CalendarEntry}
|
||||
*/
|
||||
function _parseCalendarEntry(event: any, knownDomains: string[]) {
|
||||
if (event) {
|
||||
const url = _getURLFromEvent(event, knownDomains);
|
||||
const startDate = Date.parse(event.startDate);
|
||||
const endDate = Date.parse(event.endDate);
|
||||
|
||||
// we want to hide all events that
|
||||
// - has no start or end date
|
||||
// - for web, if there is no url and we cannot edit the event (has
|
||||
// no calendarId)
|
||||
if (isNaN(startDate)
|
||||
|| isNaN(endDate)
|
||||
|| (navigator.product !== 'ReactNative'
|
||||
&& !url
|
||||
&& !event.calendarId)) {
|
||||
// Ignore the event.
|
||||
} else {
|
||||
return {
|
||||
allDay: event.allDay,
|
||||
attendees: event.attendees,
|
||||
calendarId: event.calendarId,
|
||||
endDate,
|
||||
id: event.id,
|
||||
startDate,
|
||||
title: event.title,
|
||||
url
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a Jitsi Meet URL from an event if present.
|
||||
*
|
||||
* @param {Object} event - The event to parse.
|
||||
* @param {Array<string>} knownDomains - The known domain names.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getURLFromEvent(event: { description: string; location: string; notes: string; title: string;
|
||||
url: string; }, knownDomains: string[]) {
|
||||
const linkTerminatorPattern = '[^\\s<>$]';
|
||||
const urlRegExp
|
||||
= `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`;
|
||||
const schemeRegExp = `${APP_LINK_SCHEME}${linkTerminatorPattern}+`;
|
||||
const excludePattern = '/static/';
|
||||
const fieldsToSearch = [
|
||||
event.title,
|
||||
event.url,
|
||||
event.location,
|
||||
event.notes,
|
||||
event.description
|
||||
];
|
||||
|
||||
for (const field of fieldsToSearch) {
|
||||
if (typeof field === 'string') {
|
||||
const match
|
||||
= _checkPattern(field, urlRegExp, excludePattern)
|
||||
|| _checkPattern(field, schemeRegExp, excludePattern);
|
||||
|
||||
if (match) {
|
||||
const url = parseURIString(match);
|
||||
|
||||
if (url) {
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
151
react/features/calendar-sync/functions.native.ts
Normal file
151
react/features/calendar-sync/functions.native.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import RNCalendarEvents from 'react-native-calendar-events';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { CALENDAR_ENABLED } from '../base/flags/constants';
|
||||
import { getFeatureFlag } from '../base/flags/functions';
|
||||
import { getShareInfoText } from '../invite/functions';
|
||||
|
||||
import { setCalendarAuthorization } from './actions.native';
|
||||
import { FETCH_END_DAYS, FETCH_START_DAYS } from './constants';
|
||||
import { _updateCalendarEntries } from './functions.native';
|
||||
import logger from './logger';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
/**
|
||||
* Adds a Jitsi link to a calendar entry.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {string} id - The ID of the calendar entry.
|
||||
* @param {string} link - The link to add info with.
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
export function addLinkToCalendarEntry(
|
||||
state: IReduxState, id: string, link: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getShareInfoText(state, link, true).then((shareInfoText: string) => {
|
||||
RNCalendarEvents.findEventById(id).then((event: any) => {
|
||||
const updateText
|
||||
= event.description
|
||||
? `${event.description}\n\n${shareInfoText}`
|
||||
: shareInfoText;
|
||||
const updateObject = {
|
||||
id: event.id,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
notes: updateText
|
||||
},
|
||||
android: {
|
||||
description: updateText
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
RNCalendarEvents.saveEvent(event.title, updateObject)
|
||||
.then(resolve, reject);
|
||||
}, reject);
|
||||
}, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the calendar feature is enabled by the app. For
|
||||
* example, Apple through its App Store requires
|
||||
* {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
|
||||
* rejects the app. It could also be disabled with a feature flag.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState}
|
||||
* function.
|
||||
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
|
||||
* otherwise, {@code false}.
|
||||
*/
|
||||
export function isCalendarEnabled(stateful: IStateful) {
|
||||
const flag = getFeatureFlag(stateful, CALENDAR_ENABLED);
|
||||
|
||||
if (typeof flag !== 'undefined') {
|
||||
return flag;
|
||||
}
|
||||
|
||||
const { calendarEnabled = true } = NativeModules.AppInfo;
|
||||
|
||||
return calendarEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's calendar and updates the stored entries if need be.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
|
||||
* prompt for a calendar permission if it wasn't granted yet.
|
||||
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
|
||||
* the permission or not.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
export function _fetchCalendarEntries(
|
||||
store: IStore,
|
||||
maybePromptForPermission: boolean,
|
||||
forcePermission?: boolean) {
|
||||
const { dispatch, getState } = store;
|
||||
const promptForPermission
|
||||
= (maybePromptForPermission
|
||||
&& !getState()['features/calendar-sync'].authorization)
|
||||
|| forcePermission;
|
||||
|
||||
_ensureCalendarAccess(promptForPermission, dispatch)
|
||||
.then(accessGranted => {
|
||||
if (accessGranted) {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
|
||||
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
|
||||
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
|
||||
|
||||
RNCalendarEvents.fetchAllEvents(
|
||||
|
||||
// @ts-ignore
|
||||
startDate.getTime(),
|
||||
endDate.getTime(),
|
||||
[])
|
||||
.then(_updateCalendarEntries.bind(store))
|
||||
.catch(error =>
|
||||
logger.error('Error fetching calendar.', error));
|
||||
} else {
|
||||
logger.warn('Calendar access not granted.');
|
||||
}
|
||||
})
|
||||
.catch(reason => logger.error('Error accessing calendar.', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures calendar access if possible and resolves the promise if it's granted.
|
||||
*
|
||||
* @param {boolean} promptForPermission - Flag to tell the app if it should
|
||||
* prompt for a calendar permission if it wasn't granted yet.
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function _ensureCalendarAccess(promptForPermission: boolean | undefined, dispatch: IStore['dispatch']) {
|
||||
return new Promise((resolve, reject) => {
|
||||
RNCalendarEvents.checkPermissions()
|
||||
.then(status => {
|
||||
if (status === 'authorized') {
|
||||
resolve(true);
|
||||
} else if (promptForPermission) {
|
||||
RNCalendarEvents.requestPermissions()
|
||||
.then(result => {
|
||||
dispatch(setCalendarAuthorization(result));
|
||||
resolve(result === 'authorized');
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
114
react/features/calendar-sync/functions.web.ts
Normal file
114
react/features/calendar-sync/functions.web.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { toState } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
clearCalendarIntegration,
|
||||
setCalendarError,
|
||||
setLoadingCalendarEvents
|
||||
} from './actions.web';
|
||||
export * from './functions.any';
|
||||
import {
|
||||
CALENDAR_TYPE,
|
||||
ERRORS,
|
||||
FETCH_END_DAYS,
|
||||
FETCH_START_DAYS
|
||||
} from './constants';
|
||||
import { _updateCalendarEntries } from './functions.web';
|
||||
import logger from './logger';
|
||||
import { googleCalendarApi } from './web/googleCalendar';
|
||||
import { microsoftCalendarApi } from './web/microsoftCalendar';
|
||||
|
||||
/**
|
||||
* Determines whether the calendar feature is enabled by the web.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState}
|
||||
* function.
|
||||
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
|
||||
* otherwise, {@code false}.
|
||||
*/
|
||||
export function isCalendarEnabled(stateful: IStateful) {
|
||||
const {
|
||||
enableCalendarIntegration,
|
||||
googleApiApplicationClientID,
|
||||
microsoftApiApplicationClientID
|
||||
} = toState(stateful)['features/base/config'] || {};
|
||||
|
||||
return Boolean(enableCalendarIntegration && (googleApiApplicationClientID || microsoftApiApplicationClientID));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's calendar and updates the stored entries if need be.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {boolean} _maybePromptForPermission - Flag to tell the app if it should
|
||||
* prompt for a calendar permission if it wasn't granted yet.
|
||||
* @param {boolean|undefined} _forcePermission - Whether to force to re-ask for
|
||||
* the permission or not.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
export function _fetchCalendarEntries(
|
||||
store: IStore,
|
||||
_maybePromptForPermission: boolean,
|
||||
_forcePermission?: boolean) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
const { integrationType = '' } = getState()['features/calendar-sync'];
|
||||
const integration = _getCalendarIntegration(integrationType);
|
||||
|
||||
if (!integration) {
|
||||
logger.debug('No calendar type available');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setLoadingCalendarEvents(true));
|
||||
|
||||
dispatch(integration.load())
|
||||
.then(() => dispatch(integration._isSignedIn()))
|
||||
.then((signedIn: boolean) => {
|
||||
if (signedIn) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
error: ERRORS.AUTH_FAILED
|
||||
});
|
||||
})
|
||||
.then(() => dispatch(integration.getCalendarEntries(
|
||||
FETCH_START_DAYS, FETCH_END_DAYS)))
|
||||
.then((events: Object[]) => _updateCalendarEntries.call({
|
||||
dispatch,
|
||||
getState
|
||||
}, events))
|
||||
.then(() => {
|
||||
dispatch(setCalendarError());
|
||||
}, (error: any) => {
|
||||
logger.error('Error fetching calendar.', error);
|
||||
|
||||
if (error.error === ERRORS.AUTH_FAILED) {
|
||||
dispatch(clearCalendarIntegration());
|
||||
}
|
||||
|
||||
dispatch(setCalendarError(error));
|
||||
})
|
||||
.then(() => dispatch(setLoadingCalendarEvents(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the calendar API implementation by specified type.
|
||||
*
|
||||
* @param {string} calendarType - The calendar type API as defined in
|
||||
* the constant {@link CALENDAR_TYPE}.
|
||||
* @private
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
export function _getCalendarIntegration(calendarType: string) {
|
||||
switch (calendarType) {
|
||||
case CALENDAR_TYPE.GOOGLE:
|
||||
return googleCalendarApi;
|
||||
case CALENDAR_TYPE.MICROSOFT:
|
||||
return microsoftCalendarApi;
|
||||
}
|
||||
}
|
||||
3
react/features/calendar-sync/logger.ts
Normal file
3
react/features/calendar-sync/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/calendar-sync');
|
||||
75
react/features/calendar-sync/middleware.ts
Normal file
75
react/features/calendar-sync/middleware.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { SET_CONFIG } from '../base/config/actionTypes';
|
||||
import { ADD_KNOWN_DOMAINS } from '../base/known-domains/actionTypes';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { equals } from '../base/redux/functions';
|
||||
import { APP_STATE_CHANGED } from '../mobile/background/actionTypes';
|
||||
|
||||
import { REFRESH_CALENDAR } from './actionTypes';
|
||||
import { setCalendarAuthorization } from './actions';
|
||||
import { _fetchCalendarEntries, isCalendarEnabled } from './functions';
|
||||
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const { getState } = store;
|
||||
|
||||
if (!isCalendarEnabled(getState)) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case ADD_KNOWN_DOMAINS: {
|
||||
// XXX Fetch new calendar entries only when an actual domain has
|
||||
// become known.
|
||||
const oldValue = getState()['features/base/known-domains'];
|
||||
const result = next(action);
|
||||
const newValue = getState()['features/base/known-domains'];
|
||||
|
||||
equals(oldValue, newValue)
|
||||
|| _fetchCalendarEntries(store, false, false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
case APP_STATE_CHANGED: {
|
||||
const result = next(action);
|
||||
|
||||
_maybeClearAccessStatus(store, action);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
case SET_CONFIG: {
|
||||
const result = next(action);
|
||||
|
||||
_fetchCalendarEntries(store, false, false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
case REFRESH_CALENDAR: {
|
||||
const result = next(action);
|
||||
|
||||
_fetchCalendarEntries(
|
||||
store, action.isInteractive, action.forcePermission);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Clears the calendar access status when the app comes back from the
|
||||
* background. This is needed as some users may never quit the app, but puts it
|
||||
* into the background and we need to try to request for a permission as often
|
||||
* as possible, but not annoyingly often.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {Object} action - The Redux action.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _maybeClearAccessStatus(store: IStore, { appState }: { appState: string; }) {
|
||||
appState === 'background' && store.dispatch(setCalendarAuthorization(undefined));
|
||||
}
|
||||
108
react/features/calendar-sync/reducer.tsx
Normal file
108
react/features/calendar-sync/reducer.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { set } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
CLEAR_CALENDAR_INTEGRATION,
|
||||
SET_CALENDAR_AUTHORIZATION,
|
||||
SET_CALENDAR_AUTH_STATE,
|
||||
SET_CALENDAR_ERROR,
|
||||
SET_CALENDAR_EVENTS,
|
||||
SET_CALENDAR_INTEGRATION,
|
||||
SET_CALENDAR_PROFILE_EMAIL,
|
||||
SET_LOADING_CALENDAR_EVENTS
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* The default state of the calendar feature.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
const DEFAULT_STATE = {
|
||||
authorization: undefined,
|
||||
events: [],
|
||||
integrationReady: false,
|
||||
integrationType: undefined,
|
||||
msAuthState: undefined
|
||||
};
|
||||
|
||||
export interface ICalendarSyncState {
|
||||
authorization?: string;
|
||||
error?: { error: string; };
|
||||
events: Array<{
|
||||
calendarId: string;
|
||||
endDate: string;
|
||||
id: string;
|
||||
startDate: string;
|
||||
url: string;
|
||||
}>;
|
||||
integrationReady: boolean;
|
||||
integrationType?: string;
|
||||
isLoadingEvents?: boolean;
|
||||
msAuthState?: any;
|
||||
profileEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant for the Redux subtree of the calendar feature.
|
||||
*
|
||||
* NOTE: This feature can be disabled and in that case, accessing this subtree
|
||||
* directly will return undefined and will need a bunch of repetitive type
|
||||
* checks in other features. Make sure you take care of those checks, or
|
||||
* consider using the {@code isCalendarEnabled} value to gate features if
|
||||
* needed.
|
||||
*/
|
||||
const STORE_NAME = 'features/calendar-sync';
|
||||
|
||||
/**
|
||||
* NOTE: Never persist the authorization value as it's needed to remain a
|
||||
* runtime value to see if we need to re-request the calendar permission from
|
||||
* the user.
|
||||
*/
|
||||
PersistenceRegistry.register(STORE_NAME, {
|
||||
integrationType: true,
|
||||
msAuthState: true
|
||||
});
|
||||
|
||||
ReducerRegistry.register<ICalendarSyncState>(STORE_NAME, (state = DEFAULT_STATE, action): ICalendarSyncState => {
|
||||
switch (action.type) {
|
||||
case CLEAR_CALENDAR_INTEGRATION:
|
||||
return DEFAULT_STATE;
|
||||
|
||||
case SET_CALENDAR_AUTH_STATE: {
|
||||
if (!action.msAuthState) {
|
||||
// received request to delete the state
|
||||
return set(state, 'msAuthState', undefined);
|
||||
}
|
||||
|
||||
return set(state, 'msAuthState', {
|
||||
...state.msAuthState,
|
||||
...action.msAuthState
|
||||
});
|
||||
}
|
||||
|
||||
case SET_CALENDAR_AUTHORIZATION:
|
||||
return set(state, 'authorization', action.authorization);
|
||||
|
||||
case SET_CALENDAR_ERROR:
|
||||
return set(state, 'error', action.error);
|
||||
|
||||
case SET_CALENDAR_EVENTS:
|
||||
return set(state, 'events', action.events);
|
||||
|
||||
case SET_CALENDAR_INTEGRATION:
|
||||
return {
|
||||
...state,
|
||||
integrationReady: action.integrationReady,
|
||||
integrationType: action.integrationType
|
||||
};
|
||||
|
||||
case SET_CALENDAR_PROFILE_EMAIL:
|
||||
return set(state, 'profileEmail', action.email);
|
||||
|
||||
case SET_LOADING_CALENDAR_EVENTS:
|
||||
return set(state, 'isLoadingEvents', action.isLoadingEvents);
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
72
react/features/calendar-sync/web/googleCalendar.ts
Normal file
72
react/features/calendar-sync/web/googleCalendar.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { IStore } from '../../app/types';
|
||||
import {
|
||||
getCalendarEntries,
|
||||
loadGoogleAPI,
|
||||
signIn,
|
||||
updateCalendarEvent,
|
||||
updateProfile
|
||||
} from '../../google-api/actions'; // @ts-ignore
|
||||
import googleApi from '../../google-api/googleApi.web';
|
||||
|
||||
/**
|
||||
* A stateless collection of action creators that implements the expected
|
||||
* interface for interacting with the Google API in order to get calendar data.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const googleCalendarApi = {
|
||||
/**
|
||||
* Retrieves the current calendar events.
|
||||
*
|
||||
* @param {number} fetchStartDays - The number of days to go back
|
||||
* when fetching.
|
||||
* @param {number} fetchEndDays - The number of days to fetch.
|
||||
* @returns {function(): Promise<CalendarEntries>}
|
||||
*/
|
||||
getCalendarEntries,
|
||||
|
||||
/**
|
||||
* Returns the email address for the currently logged in user.
|
||||
*
|
||||
* @returns {function(Dispatch<any>): Promise<string|never>}
|
||||
*/
|
||||
getCurrentEmail() {
|
||||
return updateProfile();
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes the google api if needed.
|
||||
*
|
||||
* @returns {function(Dispatch<any>, Function): Promise<void>}
|
||||
*/
|
||||
load() {
|
||||
return (dispatch: IStore['dispatch']) => dispatch(loadGoogleAPI());
|
||||
},
|
||||
|
||||
/**
|
||||
* Prompts the participant to sign in to the Google API Client Library.
|
||||
*
|
||||
* @returns {function(Dispatch<any>): Promise<string|never>}
|
||||
*/
|
||||
signIn,
|
||||
|
||||
/**
|
||||
* Returns whether or not the user is currently signed in.
|
||||
*
|
||||
* @returns {function(): Promise<boolean>}
|
||||
*/
|
||||
_isSignedIn() {
|
||||
return () => googleApi.isSignedIn();
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates calendar event by generating new invite URL and editing the event
|
||||
* adding some descriptive text and location.
|
||||
*
|
||||
* @param {string} id - The event id.
|
||||
* @param {string} calendarId - The id of the calendar to use.
|
||||
* @param {string} location - The location to save to the event.
|
||||
* @returns {function(Dispatch<any>): Promise<string|never>}
|
||||
*/
|
||||
updateCalendarEvent
|
||||
};
|
||||
606
react/features/calendar-sync/web/microsoftCalendar.ts
Normal file
606
react/features/calendar-sync/web/microsoftCalendar.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
import base64js from 'base64-js';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import { findWindows } from 'windows-iana';
|
||||
import { IanaName } from 'windows-iana/dist/enums';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { parseURLParams } from '../../base/util/parseURLParams';
|
||||
import { parseStandardURIString } from '../../base/util/uri';
|
||||
import { getShareInfoText } from '../../invite/functions';
|
||||
import { setCalendarAPIAuthState } from '../actions.web';
|
||||
|
||||
|
||||
/**
|
||||
* Constants used for interacting with the Microsoft API.
|
||||
*
|
||||
* @private
|
||||
* @type {object}
|
||||
*/
|
||||
const MS_API_CONFIGURATION = {
|
||||
/**
|
||||
* The URL to use when authenticating using Microsoft API.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
AUTH_ENDPOINT:
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
|
||||
|
||||
CALENDAR_ENDPOINT: '/me/calendars',
|
||||
|
||||
/**
|
||||
* The Microsoft API scopes to request access for calendar.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
MS_API_SCOPES: 'openid profile Calendars.ReadWrite',
|
||||
|
||||
/**
|
||||
* See https://docs.microsoft.com/en-us/azure/active-directory/develop/
|
||||
* v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
|
||||
* needed for passing in the proper domain_hint value when trying to refresh
|
||||
* a token silently.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
|
||||
|
||||
/**
|
||||
* The redirect URL to be used by the Microsoft API on successful
|
||||
* authentication.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
|
||||
};
|
||||
|
||||
/**
|
||||
* Store the window from an auth request. That way it can be reused if a new
|
||||
* request comes in and it can be used to indicate a request is in progress.
|
||||
*
|
||||
* @private
|
||||
* @type {Object|null}
|
||||
*/
|
||||
let popupAuthWindow: Window | null = null;
|
||||
|
||||
/**
|
||||
* A stateless collection of action creators that implements the expected
|
||||
* interface for interacting with the Microsoft API in order to get calendar
|
||||
* data.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const microsoftCalendarApi = {
|
||||
/**
|
||||
* Retrieves the current calendar events.
|
||||
*
|
||||
* @param {number} fetchStartDays - The number of days to go back
|
||||
* when fetching.
|
||||
* @param {number} fetchEndDays - The number of days to fetch.
|
||||
* @returns {function(Dispatch<any>, Function): Promise<CalendarEntries>}
|
||||
*/
|
||||
getCalendarEntries(fetchStartDays?: number, fetchEndDays?: number) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']): Promise<any> => {
|
||||
const state = getState()['features/calendar-sync'] || {};
|
||||
const token = state.msAuthState?.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return Promise.reject('Not authorized, please sign in!');
|
||||
}
|
||||
|
||||
const client = Client.init({
|
||||
authProvider: done => done(null, token)
|
||||
});
|
||||
|
||||
return client
|
||||
.api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
|
||||
.get()
|
||||
.then(response => {
|
||||
const calendarIds = response.value.map((en: any) => en.id);
|
||||
const getEventsPromises = calendarIds.map((id: string) =>
|
||||
requestCalendarEvents(
|
||||
client, id, fetchStartDays, fetchEndDays));
|
||||
|
||||
return Promise.all(getEventsPromises);
|
||||
})
|
||||
|
||||
// get .value of every element from the array of results,
|
||||
// which is an array of events and flatten it to one array
|
||||
// of events
|
||||
.then(result => [].concat(...result))
|
||||
.then(entries => entries.map(e => formatCalendarEntry(e)));
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the email address for the currently logged in user.
|
||||
*
|
||||
* @returns {function(Dispatch<*, Function>): Promise<string>}
|
||||
*/
|
||||
getCurrentEmail(): Function {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { msAuthState = {} }
|
||||
= getState()['features/calendar-sync'] || {};
|
||||
const email = msAuthState.userSigninName || '';
|
||||
|
||||
return Promise.resolve(email);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the application ID to use for interacting with the Microsoft API.
|
||||
*
|
||||
* @returns {function(): Promise<void>}
|
||||
*/
|
||||
load() {
|
||||
return () => Promise.resolve();
|
||||
},
|
||||
|
||||
/**
|
||||
* Prompts the participant to sign in to the Microsoft API Client Library.
|
||||
*
|
||||
* @returns {function(Dispatch<any>, Function): Promise<void>}
|
||||
*/
|
||||
signIn() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
// Ensure only one popup window at a time.
|
||||
if (popupAuthWindow) {
|
||||
popupAuthWindow.focus();
|
||||
|
||||
return Promise.reject('Sign in already in progress.');
|
||||
}
|
||||
|
||||
const signInDeferred = Promise.withResolvers();
|
||||
const guids = {
|
||||
authState: uuidV4(),
|
||||
authNonce: uuidV4()
|
||||
};
|
||||
|
||||
dispatch(setCalendarAPIAuthState(guids));
|
||||
|
||||
const { microsoftApiApplicationClientID }
|
||||
= getState()['features/base/config'];
|
||||
const authUrl = getAuthUrl(
|
||||
microsoftApiApplicationClientID ?? '',
|
||||
guids.authState,
|
||||
guids.authNonce);
|
||||
const h = 600;
|
||||
const w = 480;
|
||||
|
||||
popupAuthWindow = window.open(
|
||||
authUrl,
|
||||
'Auth M$',
|
||||
`width=${w}, height=${h}, top=${
|
||||
(screen.height / 2) - (h / 2)}, left=${
|
||||
(screen.width / 2) - (w / 2)}`);
|
||||
|
||||
const windowCloseCheck = setInterval(() => {
|
||||
if (popupAuthWindow?.closed) {
|
||||
signInDeferred.reject(
|
||||
'Popup closed before completing auth.');
|
||||
popupAuthWindow = null;
|
||||
window.removeEventListener('message', handleAuth);
|
||||
clearInterval(windowCloseCheck);
|
||||
} else if (!popupAuthWindow) {
|
||||
// This case probably happened because the user completed
|
||||
// auth.
|
||||
clearInterval(windowCloseCheck);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
* Callback with scope access to other variables that are part of
|
||||
* the sign in request.
|
||||
*
|
||||
* @param {Object} event - The event from the post message.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleAuth({ data }: any) {
|
||||
if (!data || data.type !== 'ms-login') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleAuth);
|
||||
|
||||
popupAuthWindow?.close();
|
||||
popupAuthWindow = null;
|
||||
|
||||
const params = getParamsFromHash(data.url);
|
||||
const tokenParts = getValidatedTokenParts(
|
||||
params, guids, microsoftApiApplicationClientID ?? '');
|
||||
|
||||
if (!tokenParts) {
|
||||
signInDeferred.reject('Invalid token received');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setCalendarAPIAuthState({
|
||||
authState: undefined,
|
||||
accessToken: tokenParts.accessToken,
|
||||
idToken: tokenParts.idToken,
|
||||
tokenExpires: params.tokenExpires,
|
||||
userDomainType: tokenParts.userDomainType,
|
||||
userSigninName: tokenParts.userSigninName
|
||||
}));
|
||||
|
||||
signInDeferred.resolve(undefined);
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleAuth);
|
||||
|
||||
return signInDeferred.promise;
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether or not the user is currently signed in.
|
||||
*
|
||||
* @returns {function(Dispatch<any>, Function): Promise<boolean>}
|
||||
*/
|
||||
_isSignedIn() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const now = new Date().getTime();
|
||||
const state
|
||||
= getState()['features/calendar-sync'].msAuthState || {};
|
||||
const tokenExpires = parseInt(state.tokenExpires, 10);
|
||||
const isExpired = now > tokenExpires && !isNaN(tokenExpires);
|
||||
|
||||
if (state.accessToken && isExpired) {
|
||||
// token expired, let's refresh it
|
||||
return dispatch(refreshAuthToken())
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
return Promise.resolve(state.accessToken && !isExpired);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates calendar event by generating new invite URL and editing the event
|
||||
* adding some descriptive text and location.
|
||||
*
|
||||
* @param {string} id - The event id.
|
||||
* @param {string} calendarId - The id of the calendar to use.
|
||||
* @param {string} location - The location to save to the event.
|
||||
* @returns {function(Dispatch<any>): Promise<string|never>}
|
||||
*/
|
||||
updateCalendarEvent(id: string, calendarId: string, location: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState()['features/calendar-sync'] || {};
|
||||
const token = state.msAuthState?.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return Promise.reject('Not authorized, please sign in!');
|
||||
}
|
||||
|
||||
return getShareInfoText(getState(), location, true/* use html */)
|
||||
.then(text => {
|
||||
const client = Client.init({
|
||||
authProvider: done => done(null, token)
|
||||
});
|
||||
|
||||
return client
|
||||
.api(`/me/events/${id}`)
|
||||
.get()
|
||||
.then(description => {
|
||||
const body = description.body;
|
||||
|
||||
if (description.bodyPreview) {
|
||||
body.content
|
||||
= `${description.bodyPreview}<br><br>`;
|
||||
}
|
||||
|
||||
// replace all new lines from the text with html
|
||||
// <br> to make it pretty
|
||||
body.content += text.split('\n').join('<br>');
|
||||
|
||||
return client
|
||||
.api(`/me/calendar/events/${id}`)
|
||||
.patch({
|
||||
body,
|
||||
location: {
|
||||
'displayName': location
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the Microsoft calendar entries to a known format.
|
||||
*
|
||||
* @param {Object} entry - The Microsoft calendar entry.
|
||||
* @private
|
||||
* @returns {{
|
||||
* calendarId: string,
|
||||
* description: string,
|
||||
* endDate: string,
|
||||
* id: string,
|
||||
* location: string,
|
||||
* startDate: string,
|
||||
* title: string
|
||||
* }}
|
||||
*/
|
||||
function formatCalendarEntry(entry: any) {
|
||||
return {
|
||||
calendarId: entry.calendarId,
|
||||
description: entry.body.content,
|
||||
endDate: entry.end.dateTime,
|
||||
id: entry.id,
|
||||
location: entry.location.displayName,
|
||||
startDate: entry.start.dateTime,
|
||||
title: entry.subject
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs and returns the URL to use for renewing an auth token.
|
||||
*
|
||||
* @param {string} appId - The Microsoft application id to log into.
|
||||
* @param {string} userDomainType - The domain type of the application as
|
||||
* provided by Microsoft.
|
||||
* @param {string} userSigninName - The email of the user signed into the
|
||||
* integration with Microsoft.
|
||||
* @private
|
||||
* @returns {string} - The auth URL.
|
||||
*/
|
||||
function getAuthRefreshUrl(appId: string, userDomainType: string, userSigninName: string) {
|
||||
return [
|
||||
getAuthUrl(appId, 'undefined', 'undefined'),
|
||||
'prompt=none',
|
||||
`domain_hint=${userDomainType}`,
|
||||
`login_hint=${userSigninName}`
|
||||
].join('&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs and returns the auth URL to use for login.
|
||||
*
|
||||
* @param {string} appId - The Microsoft application id to log into.
|
||||
* @param {string} authState - The authState guid to use.
|
||||
* @param {string} authNonce - The authNonce guid to use.
|
||||
* @private
|
||||
* @returns {string} - The auth URL.
|
||||
*/
|
||||
function getAuthUrl(appId: string, authState: string, authNonce: string) {
|
||||
const authParams = [
|
||||
'response_type=id_token+token',
|
||||
`client_id=${appId}`,
|
||||
`redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
|
||||
`scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
|
||||
`state=${authState}`,
|
||||
`nonce=${authNonce}`,
|
||||
'response_mode=fragment'
|
||||
].join('&');
|
||||
|
||||
return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a url from an auth redirect into an object of parameters passed
|
||||
* into the url.
|
||||
*
|
||||
* @param {string} url - The string to parse.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getParamsFromHash(url: string) {
|
||||
// @ts-ignore
|
||||
const params = parseURLParams(parseStandardURIString(url), true, 'hash');
|
||||
|
||||
// Get the number of seconds the token is valid for, subtract 5 minutes
|
||||
// to account for differences in clock settings and convert to ms.
|
||||
const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
|
||||
const now = new Date();
|
||||
const expireDate = new Date(now.getTime() + expiresIn);
|
||||
|
||||
params.tokenExpires = expireDate.getTime().toString();
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the parameters from a Microsoft auth redirect into an object of
|
||||
* token parts. The value "null" will be returned if the params do not produce
|
||||
* a valid token.
|
||||
*
|
||||
* @param {Object} tokenInfo - The token object.
|
||||
* @param {Object} guids - The guids for authState and authNonce that should
|
||||
* match in the token.
|
||||
* @param {Object} appId - The Microsoft application this token is for.
|
||||
* @private
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function getValidatedTokenParts(tokenInfo: any, guids: any, appId: string) {
|
||||
// Make sure the token matches the request source by matching the GUID.
|
||||
if (tokenInfo.state !== guids.authState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idToken = tokenInfo.id_token;
|
||||
|
||||
// A token must exist to be valid.
|
||||
if (!idToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenParts = idToken.split('.');
|
||||
|
||||
if (tokenParts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(b64utoutf8(tokenParts[1]));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.nonce !== guids.authNonce
|
||||
|| payload.aud !== appId
|
||||
|| payload.iss
|
||||
!== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Adjust by 5 minutes to allow for inconsistencies in system clocks.
|
||||
const notBefore = new Date((payload.nbf - 300) * 1000);
|
||||
const expires = new Date((payload.exp + 300) * 1000);
|
||||
|
||||
if (now < notBefore || now > expires) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: tokenInfo.access_token,
|
||||
idToken,
|
||||
userDisplayName: payload.name,
|
||||
userDomainType:
|
||||
payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
|
||||
? 'consumers' : 'organizations',
|
||||
userSigninName: payload.preferred_username
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renews an existing auth token so it can continue to be used.
|
||||
*
|
||||
* @private
|
||||
* @returns {function(Dispatch<any>, Function): Promise<void>}
|
||||
*/
|
||||
function refreshAuthToken() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { microsoftApiApplicationClientID }
|
||||
= getState()['features/base/config'];
|
||||
const { msAuthState = {} }
|
||||
= getState()['features/calendar-sync'] || {};
|
||||
|
||||
const refreshAuthUrl = getAuthRefreshUrl(
|
||||
microsoftApiApplicationClientID ?? '',
|
||||
msAuthState.userDomainType,
|
||||
msAuthState.userSigninName);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.setAttribute('id', 'auth-iframe');
|
||||
iframe.setAttribute('name', 'auth-iframe');
|
||||
iframe.setAttribute('style', 'display: none');
|
||||
iframe.setAttribute('src', refreshAuthUrl);
|
||||
|
||||
const signInPromise = new Promise(resolve => {
|
||||
iframe.onload = () => {
|
||||
resolve(iframe.contentWindow?.location.hash);
|
||||
};
|
||||
});
|
||||
|
||||
// The check for body existence is done for flow, which also runs
|
||||
// against native where document.body may not be defined.
|
||||
if (!document.body) {
|
||||
return Promise.reject(
|
||||
'Cannot refresh auth token in this environment');
|
||||
}
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
return signInPromise.then(hash => {
|
||||
const params = getParamsFromHash(hash as string);
|
||||
|
||||
dispatch(setCalendarAPIAuthState({
|
||||
accessToken: params.access_token,
|
||||
idToken: params.id_token,
|
||||
tokenExpires: params.tokenExpires
|
||||
}));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves calendar entries from a specific calendar.
|
||||
*
|
||||
* @param {Object} client - The Microsoft-graph-client initialized.
|
||||
* @param {string} calendarId - The calendar ID to use.
|
||||
* @param {number} fetchStartDays - The number of days to go back
|
||||
* when fetching.
|
||||
* @param {number} fetchEndDays - The number of days to fetch.
|
||||
* @returns {Promise<any> | Promise}
|
||||
* @private
|
||||
*/
|
||||
function requestCalendarEvents( // eslint-disable-line max-params
|
||||
client: any,
|
||||
calendarId: string,
|
||||
fetchStartDays?: number,
|
||||
fetchEndDays?: number): Promise<any> {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
|
||||
startDate.setDate(startDate.getDate() + Number(fetchStartDays));
|
||||
endDate.setDate(endDate.getDate() + Number(fetchEndDays));
|
||||
|
||||
const filter = `Start/DateTime ge '${
|
||||
startDate.toISOString()}' and End/DateTime lt '${
|
||||
endDate.toISOString()}'`;
|
||||
|
||||
const ianaTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const windowsTimeZone = findWindows(ianaTimeZone as IanaName);
|
||||
|
||||
return client
|
||||
.api(`/me/calendars/${calendarId}/events`)
|
||||
.filter(filter)
|
||||
.header('Prefer', `outlook.timezone="${windowsTimeZone}"`)
|
||||
.select('id,subject,start,end,location,body')
|
||||
.orderby('createdDateTime DESC')
|
||||
.get()
|
||||
.then((result: any) => result.value.map((item: Object) => {
|
||||
return {
|
||||
...item,
|
||||
calendarId
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Base64URL encoded string to a UTF-8 encoded string including CJK or Latin.
|
||||
*
|
||||
* @param {string} str - The string that needs conversion.
|
||||
* @private
|
||||
* @returns {string} - The converted string.
|
||||
*/
|
||||
function b64utoutf8(str: string) {
|
||||
let s = str;
|
||||
|
||||
// Convert from Base64URL to Base64.
|
||||
|
||||
if (s.length % 4 === 2) {
|
||||
s += '==';
|
||||
} else if (s.length % 4 === 3) {
|
||||
s += '=';
|
||||
}
|
||||
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Convert Base64 to a byte array.
|
||||
|
||||
const bytes = base64js.toByteArray(s);
|
||||
|
||||
// Convert bytes to hex.
|
||||
|
||||
s = bytes.reduce((str_: any, byte: any) => str_ + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
// Convert a hexadecimal string to a URLComponent string
|
||||
|
||||
s = s.replace(/(..)/g, '%$1');
|
||||
|
||||
// Decodee the URI component
|
||||
|
||||
return decodeURIComponent(s);
|
||||
}
|
||||
Reference in New Issue
Block a user