Merge pull request #2636 from zbettenbuk/calendar-permission-fix

Reorganize calendar access request flow
This commit is contained in:
virtuacoplenny 2018-03-26 07:57:24 -07:00 committed by GitHub
commit 01db70fd3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 428 additions and 123 deletions

View File

@ -539,11 +539,16 @@
"later": "Later",
"next": "Upcoming",
"nextMeeting": "next meeting",
"now": "Now"
"now": "Now",
"permissionButton": "Open settings",
"permissionMessage": "Calendar permission is required to list your meetings in the app."
},
"recentList": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
},
"sectionList": {
"pullToRefresh": "Pull to refresh"
}
}

View File

@ -10,6 +10,10 @@ import {
import styles, { UNDERLAY_COLOR } from './styles';
import { translate } from '../../../i18n';
import { Icon } from '../../../font-icons';
type Props = {
/**
@ -17,6 +21,11 @@ type Props = {
*/
disabled: boolean,
/**
* The translate function.
*/
t: Function,
/**
* Function to be invoked when an item is pressed. The item's URL is passed.
*/
@ -27,6 +36,11 @@ type Props = {
*/
onRefresh: Function,
/**
* Function to override the rendered default empty list component.
*/
renderListEmptyComponent: Function,
/**
* Sections to be rendered in the following format:
*
@ -53,7 +67,7 @@ type Props = {
* property and navigates to (probably) meetings, such as the recent list
* or the meeting list components.
*/
export default class NavigateSectionList extends Component<Props> {
class NavigateSectionList extends Component<Props> {
/**
* Constructor of the NavigateSectionList component.
*
@ -69,6 +83,8 @@ export default class NavigateSectionList extends Component<Props> {
this._renderItem = this._renderItem.bind(this);
this._renderItemLine = this._renderItemLine.bind(this);
this._renderItemLines = this._renderItemLines.bind(this);
this._renderListEmptyComponent
= this._renderListEmptyComponent.bind(this);
this._renderSection = this._renderSection.bind(this);
}
@ -80,12 +96,16 @@ export default class NavigateSectionList extends Component<Props> {
* @inheritdoc
*/
render() {
const { sections } = this.props;
const { renderListEmptyComponent, sections } = this.props;
return (
<SafeAreaView
style = { styles.container } >
<SectionList
ListEmptyComponent = {
renderListEmptyComponent
|| this._renderListEmptyComponent
}
keyExtractor = { this._getItemKey }
onRefresh = { this._onRefresh }
refreshing = { false }
@ -274,6 +294,34 @@ export default class NavigateSectionList extends Component<Props> {
return null;
}
_renderListEmptyComponent: () => Object
/**
* Renders a component to display when the list is empty.
*
* @private
* @param {Object} section - The section being rendered.
* @returns {React$Node}
*/
_renderListEmptyComponent() {
const { t, onRefresh } = this.props;
if (typeof onRefresh === 'function') {
return (
<View style = { styles.pullToRefresh }>
<Text style = { styles.pullToRefreshText }>
{ t('sectionList.pullToRefresh') }
</Text>
<Icon
name = 'menu-down'
style = { styles.pullToRefreshIcon } />
</View>
);
}
return null;
}
_renderSection: Object => Object
/**
@ -293,3 +341,5 @@ export default class NavigateSectionList extends Component<Props> {
);
}
}
export default translate(NavigateSectionList);

View File

@ -180,6 +180,25 @@ const SECTION_LIST_STYLES = {
fontWeight: 'normal'
},
pullToRefresh: {
alignItems: 'center',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
padding: 20
},
pullToRefreshIcon: {
backgroundColor: 'transparent',
color: OVERLAY_FONT_COLOR,
fontSize: 20
},
pullToRefreshText: {
backgroundColor: 'transparent',
color: OVERLAY_FONT_COLOR
},
touchableView: {
flexDirection: 'row'
}

View File

@ -1,5 +1,12 @@
// @flow
/**
* 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.
*/
export const CALENDAR_ACCESS_REQUESTED = Symbol('CALENDAR_ACCESS_REQUESTED');
/**
* Action to update the current calendar entry list in the store.
*/

View File

@ -1,10 +1,28 @@
// @flow
import {
CALENDAR_ACCESS_REQUESTED,
NEW_CALENDAR_ENTRY_LIST,
NEW_KNOWN_DOMAIN,
REFRESH_CALENDAR_ENTRY_LIST
} from './actionTypes';
/**
* Sends an action to signal that a calendar access has been requested. For
* more info see the {@link CALENDAR_ACCESS_REQUESTED}.
*
* @param {string | undefined} status - The result of the last calendar
* access request.
* @returns {{
* type: CALENDAR_ACCESS_REQUESTED
* }}
*/
export function updateCalendarAccessStatus(status: ?string) {
return {
status,
type: CALENDAR_ACCESS_REQUESTED
};
}
/**
* Sends an action to add a new known domain if not present yet.
*
@ -24,12 +42,16 @@ export function maybeAddNewKnownDomain(domainName: string) {
/**
* Sends an action to refresh the entry list (fetches new data).
*
* @param {boolean|undefined} forcePermission - Whether to force to re-ask
* for the permission or not.
* @returns {{
* type: REFRESH_CALENDAR_ENTRY_LIST
* type: REFRESH_CALENDAR_ENTRY_LIST,
* forcePermission: boolean
* }}
*/
export function refreshCalendarEntryList() {
export function refreshCalendarEntryList(forcePermission: boolean = false) {
return {
forcePermission,
type: REFRESH_CALENDAR_ENTRY_LIST
};
}

View File

@ -1,12 +1,16 @@
// @flow
import React, { Component } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { connect } from 'react-redux';
import styles from './styles';
import { refreshCalendarEntryList } from '../actions';
import { appNavigate } from '../../app';
import { getLocalizedDateFormatter, translate } from '../../base/i18n';
import { NavigateSectionList } from '../../base/react';
import { openSettings } from '../../mobile/permissions';
type Props = {
@ -28,6 +32,11 @@ type Props = {
*/
displayed: boolean,
/**
* The current state of the calendar access permission.
*/
_calendarAccessStatus: string,
/**
* The calendar event list.
*/
@ -43,8 +52,6 @@ type Props = {
* Component to display a list of events from the (mobile) user's calendar.
*/
class MeetingList extends Component<Props> {
_initialLoaded: boolean
/**
* Default values for the component's props.
*/
@ -60,6 +67,14 @@ class MeetingList extends Component<Props> {
constructor(props) {
super(props);
const { dispatch, displayed } = props;
if (displayed) {
dispatch(refreshCalendarEntryList());
}
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onPress = this._onPress.bind(this);
this._onRefresh = this._onRefresh.bind(this);
this._toDisplayableItem = this._toDisplayableItem.bind(this);
@ -73,16 +88,11 @@ class MeetingList extends Component<Props> {
* @inheritdoc
*/
componentWillReceiveProps(newProps) {
// This is a conditional logic to refresh the calendar entries (thus
// to request access to calendar) on component first receives a
// displayed=true prop - to avoid requesting calendar access on
// app start.
if (!this._initialLoaded
&& newProps.displayed
&& !this.props.displayed) {
const { displayed } = this.props;
if (newProps.displayed && !displayed) {
const { dispatch } = this.props;
this._initialLoaded = true;
dispatch(refreshCalendarEntryList());
}
}
@ -100,10 +110,45 @@ class MeetingList extends Component<Props> {
disabled = { disabled }
onPress = { this._onPress }
onRefresh = { this._onRefresh }
renderListEmptyComponent = {
this._getRenderListEmptyComponent
}
sections = { this._toDisplayableList() } />
);
}
_getRenderListEmptyComponent: () => Object
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {Component}
*/
_getRenderListEmptyComponent() {
const { _calendarAccessStatus, t } = this.props;
if (_calendarAccessStatus === 'denied') {
return (
<View style = { styles.noPermissionMessageView }>
<Text style = { styles.noPermissionMessageText }>
{ t('calendarSync.permissionMessage') }
</Text>
<TouchableOpacity
onPress = { openSettings }
style = { styles.noPermissionMessageButton } >
<Text style = { styles.noPermissionMessageButtonText }>
{ t('calendarSync.permissionButton') }
</Text>
</TouchableOpacity>
</View>
);
}
return null;
}
_onPress: string => Function
/**
@ -130,7 +175,7 @@ class MeetingList extends Component<Props> {
_onRefresh() {
const { dispatch } = this.props;
dispatch(refreshCalendarEntryList());
dispatch(refreshCalendarEntryList(true));
}
_toDisplayableItem: Object => Object
@ -219,12 +264,12 @@ class MeetingList extends Component<Props> {
* @returns {string}
*/
_toDateString(event) {
/* eslint-disable max-len */
const startDateTime = getLocalizedDateFormatter(event.startDate).format('lll');
const endTime = getLocalizedDateFormatter(event.endDate).format('LT');
const startDateTime
= getLocalizedDateFormatter(event.startDate).format('lll');
const endTime
= getLocalizedDateFormatter(event.endDate).format('LT');
return `${startDateTime} - ${endTime}`;
/* eslint-enable max-len */
}
}
@ -237,8 +282,11 @@ class MeetingList extends Component<Props> {
* }}
*/
export function _mapStateToProps(state: Object) {
const calendarSyncState = state['features/calendar-sync'];
return {
_eventList: state['features/calendar-sync'].events
_calendarAccessStatus: calendarSyncState.calendarAccessStatus,
_eventList: calendarSyncState.events
};
}

View File

@ -1,4 +1,4 @@
import { createStyleSheet } from '../../base/styles';
import { ColorPalette, createStyleSheet } from '../../base/styles';
const NOTIFICATION_SIZE = 55;
@ -8,6 +8,46 @@ const NOTIFICATION_SIZE = 55;
*/
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)'
},
/**
* 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.
*/

View File

@ -1,3 +1,4 @@
export * from './actions';
export * from './components';
import './middleware';

View File

@ -2,13 +2,18 @@
import Logger from 'jitsi-meet-logger';
import RNCalendarEvents from 'react-native-calendar-events';
import { APP_WILL_MOUNT } from '../app';
import { SET_ROOM } from '../base/conference';
import { MiddlewareRegistry } from '../base/redux';
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
import { APP_STATE_CHANGED } from '../mobile/background';
import { APP_WILL_MOUNT } from '../app';
import { maybeAddNewKnownDomain, updateCalendarEntryList } from './actions';
import {
maybeAddNewKnownDomain,
updateCalendarAccessStatus,
updateCalendarEntryList
} from './actions';
import { REFRESH_CALENDAR_ENTRY_LIST } from './actionTypes';
const FETCH_END_DAYS = 10;
@ -20,12 +25,15 @@ MiddlewareRegistry.register(store => next => action => {
const result = next(action);
switch (action.type) {
case APP_STATE_CHANGED:
_maybeClearAccessStatus(store, action);
break;
case APP_WILL_MOUNT:
_ensureDefaultServer(store);
_fetchCalendarEntries(store, false);
_fetchCalendarEntries(store, false, false);
break;
case REFRESH_CALENDAR_ENTRY_LIST:
_fetchCalendarEntries(store, true);
_fetchCalendarEntries(store, true, action.forcePermission);
break;
case SET_ROOM:
_parseAndAddDomain(store);
@ -34,34 +42,53 @@ MiddlewareRegistry.register(store => next => action => {
return result;
});
/**
* 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.
*
* @private
* @param {Object} store - The redux store.
* @param {Object} action - The Redux action.
* @returns {void}
*/
function _maybeClearAccessStatus(store, action) {
const { appState } = action;
if (appState === 'background') {
const { dispatch } = store;
dispatch(updateCalendarAccessStatus(undefined));
}
}
/**
* Ensures calendar access if possible and resolves the promise if it's granted.
*
* @private
* @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.
* @returns {Promise}
*/
function _ensureCalendarAccess(promptForPermission) {
function _ensureCalendarAccess(promptForPermission, dispatch) {
return new Promise((resolve, reject) => {
RNCalendarEvents.authorizationStatus()
.then(status => {
if (status === 'authorized') {
resolve();
resolve(true);
} else if (promptForPermission) {
RNCalendarEvents.authorizeEventStore()
.then(result => {
if (result === 'authorized') {
resolve();
} else {
reject(result);
}
dispatch(updateCalendarAccessStatus(result));
resolve(result === 'authorized');
})
.catch(error => {
reject(error);
});
} else {
reject(status);
resolve(false);
}
})
.catch(error => {
@ -91,64 +118,49 @@ function _ensureDefaultServer(store) {
*
* @private
* @param {Object} store - The redux store.
* @param {boolean} promptForPermission - Flag to tell the app if it should
* @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.
* @returns {void}
*/
function _fetchCalendarEntries(store, promptForPermission) {
_ensureCalendarAccess(promptForPermission)
.then(() => {
const startDate = new Date();
const endDate = new Date();
function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission
) {
const { dispatch } = store;
const state = store.getState()['features/calendar-sync'];
const { calendarAccessStatus } = state;
const promptForPermission
= (maybePromptForPermission && !calendarAccessStatus)
|| forcePermission;
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
_ensureCalendarAccess(promptForPermission, dispatch)
.then(accessGranted => {
if (accessGranted) {
const startDate = new Date();
const endDate = new Date();
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[]
)
.then(events => {
const { knownDomains } = store.getState()['features/calendar-sync'];
const eventList = [];
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
if (events && events.length) {
for (const event of events) {
const jitsiURL = _getURLFromEvent(event, knownDomains);
const now = Date.now();
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[]
)
.then(events => {
const { knownDomains } = state;
if (jitsiURL) {
const eventStartDate = Date.parse(event.startDate);
const eventEndDate = Date.parse(event.endDate);
if (isNaN(eventStartDate) || isNaN(eventEndDate)) {
logger.warn(
'Skipping calendar event due to invalid dates',
event.title,
event.startDate,
event.endDate
);
} else if (eventEndDate > now) {
eventList.push({
endDate: eventEndDate,
id: event.id,
startDate: eventStartDate,
title: event.title,
url: jitsiURL
});
}
}
}
}
store.dispatch(updateCalendarEntryList(eventList.sort((a, b) =>
a.startDate - b.startDate
).slice(0, MAX_LIST_LENGTH)));
})
.catch(error => {
logger.error('Error fetching calendar.', error);
});
_updateCalendarEntries(events, knownDomains, dispatch);
})
.catch(error => {
logger.error('Error fetching calendar.', error);
});
} else {
logger.warn('Calendar access not granted.');
}
})
.catch(reason => {
logger.error('Error accessing calendar.', reason);
@ -209,3 +221,70 @@ function _parseAndAddDomain(store) {
store.dispatch(maybeAddNewKnownDomain(locationURL.host));
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @private
* @param {Object} event - An event returned from the native calendar.
* @param {Array<string>} knownDomains - The known domain list.
* @returns {CalendarEntry}
*/
function _parseCalendarEntry(event, knownDomains) {
if (event) {
const jitsiURL = _getURLFromEvent(event, knownDomains);
if (jitsiURL) {
const eventStartDate = Date.parse(event.startDate);
const eventEndDate = Date.parse(event.endDate);
if (isNaN(eventStartDate) || isNaN(eventEndDate)) {
logger.warn(
'Skipping invalid calendar event',
event.title,
event.startDate,
event.endDate
);
} else {
return {
endDate: eventEndDate,
id: event.id,
startDate: eventStartDate,
title: event.title,
url: jitsiURL
};
}
}
}
return null;
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @private
* @param {Array<CalendarEntry>} events - The new event list.
* @param {Array<string>} knownDomains - The known domain list.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {void}
*/
function _updateCalendarEntries(events, knownDomains, dispatch) {
if (events && events.length) {
const eventList = [];
for (const event of events) {
const calendarEntry
= _parseCalendarEntry(event, knownDomains);
const now = Date.now();
if (calendarEntry && calendarEntry.endDate > now) {
eventList.push(calendarEntry);
}
}
dispatch(updateCalendarEntryList(eventList.sort((a, b) =>
a.startDate - b.startDate
).slice(0, MAX_LIST_LENGTH)));
}
}

View File

@ -3,13 +3,19 @@
import { ReducerRegistry } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import { NEW_CALENDAR_ENTRY_LIST, NEW_KNOWN_DOMAIN } from './actionTypes';
import {
CALENDAR_ACCESS_REQUESTED,
NEW_CALENDAR_ENTRY_LIST,
NEW_KNOWN_DOMAIN
} from './actionTypes';
/**
* ZB: this is an object, as further data is to come here, like:
* - known domain list
*/
const DEFAULT_STATE = {
/**
* Note: If features/calendar-sync ever gets persisted, do not persist the
* calendarAccessStatus value as it's needed to remain a runtime value to
* see if we need to re-request the calendar permission from the user.
*/
calendarAccessStatus: undefined,
events: [],
knownDomains: []
};
@ -26,6 +32,12 @@ ReducerRegistry.register(
STORE_NAME,
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case CALENDAR_ACCESS_REQUESTED:
return {
...state,
calendarAccessStatus: action.status
};
case NEW_CALENDAR_ENTRY_LIST:
return {
...state,

View File

@ -0,0 +1,31 @@
// @flow
import { Alert, Linking, NativeModules } from 'react-native';
import { Platform } from '../../base/react';
/**
* Opens the settings panel for the current platform.
*
* @private
* @returns {void}
*/
export function openSettings() {
switch (Platform.OS) {
case 'android':
NativeModules.AndroidSettings.open().catch(() => {
Alert.alert(
'Error opening settings',
'Please open settings and grant the required permissions',
[
{ text: 'OK' }
]
);
});
break;
case 'ios':
Linking.openURL('app-settings:');
break;
}
}

View File

@ -1 +1,3 @@
export * from './functions';
import './middleware';

View File

@ -1,9 +1,10 @@
/* @flow */
import { Alert, Linking, NativeModules } from 'react-native';
import { Alert } from 'react-native';
import { openSettings } from './functions';
import { isRoomValid } from '../../base/conference';
import { Platform } from '../../base/react';
import { MiddlewareRegistry } from '../../base/redux';
import { TRACK_CREATE_ERROR } from '../../base/tracks';
@ -64,35 +65,9 @@ function _alertPermissionErrorWithSettings(trackType) {
[
{ text: 'Cancel' },
{
onPress: _openSettings,
onPress: openSettings,
text: 'Settings'
}
],
{ cancelable: false });
}
/**
* Opens the settings panel for the current platform.
*
* @private
* @returns {void}
*/
function _openSettings() {
switch (Platform.OS) {
case 'android':
NativeModules.AndroidSettings.open().catch(() => {
Alert.alert(
'Error opening settings',
'Please open settings and grant the required permissions',
[
{ text: 'OK' }
]
);
});
break;
case 'ios':
Linking.openURL('app-settings:');
break;
}
}

View File

@ -14,6 +14,11 @@ type Props = {
*/
disabled: boolean,
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The i18n translate function
*/

View File

@ -2,9 +2,10 @@
import React from 'react';
import { View, TabBarIOS } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { MeetingList } from '../../calendar-sync';
import { MeetingList, refreshCalendarEntryList } from '../../calendar-sync';
import { RecentList } from '../../recent-list';
import AbstractPagedList from './AbstractPagedList';
@ -59,8 +60,7 @@ class PagedList extends AbstractPagedList {
selected = { pageIndex === 1 }
title = { t('welcomepage.calendar') } >
<MeetingList
disabled = { disabled }
displayed = { pageIndex === 1 } />
disabled = { disabled } />
</TabBarIOS.Item>
</TabBarIOS>
</View>
@ -81,8 +81,17 @@ class PagedList extends AbstractPagedList {
this.setState({
pageIndex: tabIndex
});
if (tabIndex === 1) {
/**
* This is a workaround as TabBarIOS doesn't invoke
* componentWillReciveProps on prop change of the
* MeetingList component.
*/
this.props.dispatch(refreshCalendarEntryList());
}
};
}
}
export default translate(PagedList);
export default translate(connect()(PagedList));