init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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';

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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'
}
});

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

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

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

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

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/calendar-sync');

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

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

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

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