You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

middleware.js 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. // @flow
  2. import md5 from 'js-md5';
  3. import RNCalendarEvents from 'react-native-calendar-events';
  4. import { APP_WILL_MOUNT } from '../base/app';
  5. import { ADD_KNOWN_DOMAINS, addKnownDomains } from '../base/known-domains';
  6. import { MiddlewareRegistry } from '../base/redux';
  7. import { APP_LINK_SCHEME, parseURIString } from '../base/util';
  8. import { APP_STATE_CHANGED } from '../mobile/background';
  9. import { setCalendarAuthorization, setCalendarEvents } from './actions';
  10. import { REFRESH_CALENDAR } from './actionTypes';
  11. import { CALENDAR_ENABLED } from './constants';
  12. const logger = require('jitsi-meet-logger').getLogger(__filename);
  13. /**
  14. * The number of days to fetch.
  15. */
  16. const FETCH_END_DAYS = 10;
  17. /**
  18. * The number of days to go back when fetching.
  19. */
  20. const FETCH_START_DAYS = -1;
  21. /**
  22. * The max number of events to fetch from the calendar.
  23. */
  24. const MAX_LIST_LENGTH = 10;
  25. CALENDAR_ENABLED
  26. && MiddlewareRegistry.register(store => next => action => {
  27. switch (action.type) {
  28. case ADD_KNOWN_DOMAINS: {
  29. // XXX Fetch new calendar entries only when an actual domain has
  30. // become known.
  31. const { getState } = store;
  32. const oldValue = getState()['features/base/known-domains'];
  33. const result = next(action);
  34. const newValue = getState()['features/base/known-domains'];
  35. oldValue === newValue || _fetchCalendarEntries(store, false, false);
  36. return result;
  37. }
  38. case APP_STATE_CHANGED: {
  39. const result = next(action);
  40. _maybeClearAccessStatus(store, action);
  41. return result;
  42. }
  43. case APP_WILL_MOUNT: {
  44. // For legacy purposes, we've allowed the deserialization of
  45. // knownDomains and now we're to translate it to base/known-domains.
  46. const state = store.getState()['features/calendar-sync'];
  47. if (state) {
  48. const { knownDomains } = state;
  49. Array.isArray(knownDomains)
  50. && knownDomains.length
  51. && store.dispatch(addKnownDomains(knownDomains));
  52. }
  53. _fetchCalendarEntries(store, false, false);
  54. return next(action);
  55. }
  56. case REFRESH_CALENDAR: {
  57. const result = next(action);
  58. _fetchCalendarEntries(
  59. store, action.isInteractive, action.forcePermission);
  60. return result;
  61. }
  62. }
  63. return next(action);
  64. });
  65. /**
  66. * Ensures calendar access if possible and resolves the promise if it's granted.
  67. *
  68. * @param {boolean} promptForPermission - Flag to tell the app if it should
  69. * prompt for a calendar permission if it wasn't granted yet.
  70. * @param {Function} dispatch - The Redux dispatch function.
  71. * @private
  72. * @returns {Promise}
  73. */
  74. function _ensureCalendarAccess(promptForPermission, dispatch) {
  75. return new Promise((resolve, reject) => {
  76. RNCalendarEvents.authorizationStatus()
  77. .then(status => {
  78. if (status === 'authorized') {
  79. resolve(true);
  80. } else if (promptForPermission) {
  81. RNCalendarEvents.authorizeEventStore()
  82. .then(result => {
  83. dispatch(setCalendarAuthorization(result));
  84. resolve(result === 'authorized');
  85. })
  86. .catch(reject);
  87. } else {
  88. resolve(false);
  89. }
  90. })
  91. .catch(reject);
  92. });
  93. }
  94. /**
  95. * Reads the user's calendar and updates the stored entries if need be.
  96. *
  97. * @param {Object} store - The redux store.
  98. * @param {boolean} maybePromptForPermission - Flag to tell the app if it should
  99. * prompt for a calendar permission if it wasn't granted yet.
  100. * @param {boolean|undefined} forcePermission - Whether to force to re-ask for
  101. * the permission or not.
  102. * @private
  103. * @returns {void}
  104. */
  105. function _fetchCalendarEntries(
  106. store,
  107. maybePromptForPermission,
  108. forcePermission) {
  109. const { dispatch, getState } = store;
  110. const promptForPermission
  111. = (maybePromptForPermission
  112. && !getState()['features/calendar-sync'].authorization)
  113. || forcePermission;
  114. _ensureCalendarAccess(promptForPermission, dispatch)
  115. .then(accessGranted => {
  116. if (accessGranted) {
  117. const startDate = new Date();
  118. const endDate = new Date();
  119. startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
  120. endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
  121. RNCalendarEvents.fetchAllEvents(
  122. startDate.getTime(),
  123. endDate.getTime(),
  124. [])
  125. .then(_updateCalendarEntries.bind(store))
  126. .catch(error =>
  127. logger.error('Error fetching calendar.', error));
  128. } else {
  129. logger.warn('Calendar access not granted.');
  130. }
  131. })
  132. .catch(reason => logger.error('Error accessing calendar.', reason));
  133. }
  134. /**
  135. * Retrieves a Jitsi Meet URL from an event if present.
  136. *
  137. * @param {Object} event - The event to parse.
  138. * @param {Array<string>} knownDomains - The known domain names.
  139. * @private
  140. * @returns {string}
  141. */
  142. function _getURLFromEvent(event, knownDomains) {
  143. const linkTerminatorPattern = '[^\\s<>$]';
  144. const urlRegExp
  145. = new RegExp(
  146. `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
  147. 'gi');
  148. const schemeRegExp
  149. = new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
  150. const fieldsToSearch = [
  151. event.title,
  152. event.url,
  153. event.location,
  154. event.notes,
  155. event.description
  156. ];
  157. for (const field of fieldsToSearch) {
  158. if (typeof field === 'string') {
  159. const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
  160. if (matches) {
  161. const url = parseURIString(matches[0]);
  162. if (url) {
  163. return url.toString();
  164. }
  165. }
  166. }
  167. }
  168. return null;
  169. }
  170. /**
  171. * Clears the calendar access status when the app comes back from the
  172. * background. This is needed as some users may never quit the app, but puts it
  173. * into the background and we need to try to request for a permission as often
  174. * as possible, but not annoyingly often.
  175. *
  176. * @param {Object} store - The redux store.
  177. * @param {Object} action - The Redux action.
  178. * @private
  179. * @returns {void}
  180. */
  181. function _maybeClearAccessStatus(store, { appState }) {
  182. appState === 'background'
  183. && store.dispatch(setCalendarAuthorization(undefined));
  184. }
  185. /**
  186. * Updates the calendar entries in Redux when new list is received.
  187. *
  188. * @param {Object} event - An event returned from the native calendar.
  189. * @param {Array<string>} knownDomains - The known domain list.
  190. * @private
  191. * @returns {CalendarEntry}
  192. */
  193. function _parseCalendarEntry(event, knownDomains) {
  194. if (event) {
  195. const url = _getURLFromEvent(event, knownDomains);
  196. if (url) {
  197. const startDate = Date.parse(event.startDate);
  198. const endDate = Date.parse(event.endDate);
  199. if (isNaN(startDate) || isNaN(endDate)) {
  200. logger.warn(
  201. 'Skipping invalid calendar event',
  202. event.title,
  203. event.startDate,
  204. event.endDate
  205. );
  206. } else {
  207. return {
  208. endDate,
  209. id: event.id,
  210. startDate,
  211. title: event.title,
  212. url
  213. };
  214. }
  215. }
  216. }
  217. return null;
  218. }
  219. /**
  220. * Updates the calendar entries in redux when new list is received. The feature
  221. * calendar-sync doesn't display all calendar events, it displays unique
  222. * title, URL, and start time tuples i.e. it doesn't display subsequent
  223. * occurrences of recurring events, and the repetitions of events coming from
  224. * multiple calendars.
  225. *
  226. * XXX The function's {@code this} is the redux store.
  227. *
  228. * @param {Array<CalendarEntry>} events - The new event list.
  229. * @private
  230. * @returns {void}
  231. */
  232. function _updateCalendarEntries(events) {
  233. if (!events || !events.length) {
  234. return;
  235. }
  236. // eslint-disable-next-line no-invalid-this
  237. const { dispatch, getState } = this;
  238. const knownDomains = getState()['features/base/known-domains'];
  239. const now = Date.now();
  240. const entryMap = new Map();
  241. for (const event of events) {
  242. const entry = _parseCalendarEntry(event, knownDomains);
  243. if (entry && entry.endDate > now) {
  244. // As was stated above, we don't display subsequent occurrences of
  245. // recurring events, and the repetitions of events coming from
  246. // multiple calendars.
  247. const key = md5.hex(JSON.stringify([
  248. // Obviously, we want to display different conference/meetings
  249. // URLs. URLs are the very reason why we implemented the feature
  250. // calendar-sync in the first place.
  251. entry.url,
  252. // We probably want to display one and the same URL to people if
  253. // they have it under different titles in their Calendar.
  254. // Because maybe they remember the title of the meeting, not the
  255. // URL so they expect to see the title without realizing that
  256. // they have the same URL already under a different title.
  257. entry.title,
  258. // XXX Eventually, given that the URL and the title are the
  259. // same, what sets one event apart from another is the start
  260. // time of the day (note the use of toTimeString() bellow)! The
  261. // day itself is not important because we don't want multiple
  262. // occurrences of a recurring event or repetitions of an even
  263. // from multiple calendars.
  264. new Date(entry.startDate).toTimeString()
  265. ]));
  266. const existingEntry = entryMap.get(key);
  267. // We want only the earliest occurrence (which hasn't ended in the
  268. // past, that is) of a recurring event.
  269. if (!existingEntry || existingEntry.startDate > entry.startDate) {
  270. entryMap.set(key, entry);
  271. }
  272. }
  273. }
  274. dispatch(
  275. setCalendarEvents(
  276. Array.from(entryMap.values())
  277. .sort((a, b) => a.startDate - b.startDate)
  278. .slice(0, MAX_LIST_LENGTH)));
  279. }