diff --git a/messages/en.json b/messages/en.json index 8e0b3c20e..ef64d48e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -19,6 +19,18 @@ "description": "Title of dialog that shows when cancelling a new observation", "message": "Discard observation?" }, + "hooks.updateNotifier.dialogMessage": { + "description": "Message shown in dialog shown to the user when Mapeo is updated", + "message": "Mapeo was successfully updated from version {previousVersion} to {currentVersion} on {updateDateTime}" + }, + "hooks.updateNotifier.dialogOK": { + "description": "Button label to dismiss dialog shown to the user when Mapeo is updated", + "message": "OK" + }, + "hooks.updateNotifier.dialogTitle": { + "description": "Title of dialog shown to the user when Mapeo is updated", + "message": "Mapeo Updated" + }, "screens.AboutMapeo.androidBuild": { "description": "Label for Android build number", "message": "Android build number" diff --git a/package-lock.json b/package-lock.json index fd60da017..fb1dd786b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27269,6 +27269,11 @@ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, + "p-is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-4.0.0.tgz", + "integrity": "sha512-4G3B+86qsIAX/+ip/yhHX9WUcyFKYkQYtE5bGkjpZyGK0Re53RbHky2UKt6RQVkDbUXb8EJRb4iga2SaI360nQ==" + }, "p-limit": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", diff --git a/package.json b/package.json index 2fad7d8e1..0dbf5b4da 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "mapeo-offline-map": "^2.0.0", "mapeo-schema": "^2.0.2", "nodejs-mobile-react-native": "^0.6.1", + "p-is-promise": "^4.0.0", "patch-package": "^6.2.2", "path": "^0.12.7", "prop-types": "^15.7.2", diff --git a/src/frontend/App.js b/src/frontend/App.js index 16efe8644..c2fdba2d0 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -14,6 +14,7 @@ import { IntlProvider } from "./context/IntlContext"; import AppProvider from "./context/AppProvider"; import bugsnag from "./lib/logger"; import IS_E2E from "./lib/is-e2e"; +import useUpdateNotifierEffect from "./hooks/useUpdateNotifierEffect"; // Turn off warnings about require cycles YellowBox.ignoreWarnings(["Require cycle:"]); @@ -101,6 +102,11 @@ class ErrorBoundary extends React.Component< } } +const UpdateNotifier = () => { + useUpdateNotifierEffect(); + return null; +}; + /* IntlProvider needs to be first so that error messages are translated */ const App = () => ( @@ -114,6 +120,7 @@ const App = () => ( persistNavigationState={persistNavigationState} loadNavigationState={loadNavigationState} /> + diff --git a/src/frontend/hooks/useDeviceInfo.js b/src/frontend/hooks/useDeviceInfo.js new file mode 100644 index 000000000..7dff3b4ac --- /dev/null +++ b/src/frontend/hooks/useDeviceInfo.js @@ -0,0 +1,42 @@ +// @flow +import React from "react"; +import DeviceInfo from "react-native-device-info"; +import isPromise from "p-is-promise"; +import debug from "debug"; + +type State = "loading" | "ready" | "error"; + +const log = debug("mapeo:useDeviceInfo"); + +export default function useDeviceInfo(prop: string) { + const methodName = "get" + prop.replace(/^[a-z]/, m => m.toUpperCase()); + const result = React.useMemo(() => DeviceInfo[methodName](), [methodName]); + const resultIsPromise = isPromise(result); + + const [value, setValue] = React.useState(resultIsPromise ? null : result); + const [state, setState] = React.useState( + resultIsPromise ? "loading" : "ready" + ); + const [context, setContext] = React.useState(); + + React.useEffect(() => { + if (typeof result !== "object") return; + result + .then(v => { + setState("ready"); + setValue(v); + }) + .catch(e => { + log(`Error reading DeviceInfo.${methodName}(): ${e.message}`); + setState("error"); + setContext(e); + setValue(undefined); + }); + }, [result, methodName]); + + return React.useMemo(() => ({ value, state, context }), [ + value, + state, + context, + ]); +} diff --git a/src/frontend/hooks/useUpdateNotifierEffect.js b/src/frontend/hooks/useUpdateNotifierEffect.js new file mode 100644 index 000000000..8d337b034 --- /dev/null +++ b/src/frontend/hooks/useUpdateNotifierEffect.js @@ -0,0 +1,101 @@ +// @flow +import React from "react"; +import { Alert } from "react-native"; +import { useIntl, defineMessages } from "react-intl"; +import createPersistedState from "./usePersistedState"; +import useDeviceInfo from "./useDeviceInfo"; +import { formats } from "../context/IntlContext"; + +// Array of [versionName, installTime (millseconds since unix epoc)] +type SavedVersionInfo = [string, number][]; + +const m = defineMessages({ + dialogTitle: { + id: "hooks.updateNotifier.dialogTitle", + description: "Title of dialog shown to the user when Mapeo is updated", + defaultMessage: "Mapeo Updated \u2705", + }, + dialogMessage: { + id: "hooks.updateNotifier.dialogMessage", + description: + "Message shown in dialog shown to the user when Mapeo is updated", + defaultMessage: + "Mapeo was successfully updated from version {previousVersion} to {currentVersion} on {updateDateTime}", + }, + dialogOk: { + id: "hooks.updateNotifier.dialogOK", + description: + "Button label to dismiss dialog shown to the user when Mapeo is updated", + defaultMessage: "OK", + }, +}); + +// Changing this will cause the upgrade notification to not show on the next +// update, so it should not be changed without a migration path +const STORE_KEY = "@MapeoVersion@1"; + +const usePersistedState = createPersistedState(STORE_KEY); + +export default function useUpdateNotifierEffect() { + const { formatMessage, formatDate } = useIntl(); + const [ + savedVersionInfo, + savedVersionStatus, + setSavedVersionInfo, + ] = usePersistedState([]); + const readableVersion = useDeviceInfo("readableVersion"); + const lastUpdateTime = useDeviceInfo("lastUpdateTime"); + + React.useEffect(() => { + if ( + !( + savedVersionStatus === "idle" && + readableVersion.state === "ready" && + lastUpdateTime.state === "ready" + ) + ) + return; + + const currentVersion = readableVersion.value; + const currentInstallTime = lastUpdateTime.value; + // Shouldn't get here, but this makes Flow type checking work + if ( + typeof currentVersion !== "string" || + typeof currentInstallTime !== "number" + ) + return; + + const previousVersionInfo = savedVersionInfo[savedVersionInfo.length - 1]; + const versionHasChanged = + previousVersionInfo && previousVersionInfo[0] !== currentVersion; + + if (versionHasChanged || typeof previousVersionInfo === "undefined") { + setSavedVersionInfo([ + ...savedVersionInfo, + [currentVersion, currentInstallTime], + ]); + } + + if (!versionHasChanged) return; + + Alert.alert( + formatMessage(m.dialogTitle), + formatMessage(m.dialogMessage, { + previousVersion: previousVersionInfo[0], + currentVersion, + updateDateTime: formatDate(currentInstallTime, formats.date.long), + }), + [{ text: formatMessage(m.dialogOk) }] + ); + }, [ + savedVersionStatus, + readableVersion.state, + lastUpdateTime.state, + readableVersion.value, + lastUpdateTime.value, + savedVersionInfo, + setSavedVersionInfo, + formatMessage, + formatDate, + ]); +} diff --git a/src/frontend/screens/Settings/AboutMapeo.js b/src/frontend/screens/Settings/AboutMapeo.js index b60066c27..a91cc1865 100644 --- a/src/frontend/screens/Settings/AboutMapeo.js +++ b/src/frontend/screens/Settings/AboutMapeo.js @@ -1,10 +1,10 @@ // @flow import React from "react"; import { FormattedMessage, defineMessages, useIntl } from "react-intl"; -import DeviceInfo from "react-native-device-info"; import HeaderTitle from "../../sharedComponents/HeaderTitle"; import { List, ListItem, ListItemText } from "../../sharedComponents/List"; +import useDeviceInfo from "../../hooks/useDeviceInfo"; const m = defineMessages({ aboutMapeoTitle: { @@ -50,22 +50,6 @@ const m = defineMessages({ }, }); -// Quick custom hook to -function useDeviceInfo(prop) { - const { formatMessage } = useIntl(); - const methodName = "get" + prop.replace(/^[a-z]/, m => m.toUpperCase()); - const result = React.useMemo(() => DeviceInfo[methodName](), [methodName]); - const [value, setValue] = React.useState( - // result type is "object" if it's a Promise - typeof result === "object" ? "…" : result - ); - React.useEffect(() => { - if (typeof result !== "object") return; - result.then(setValue).catch(e => setValue(formatMessage(m.unknown))); - }, [formatMessage, result]); - return value; -} - const DeviceInfoListItem = ({ label, deviceProp, @@ -73,10 +57,17 @@ const DeviceInfoListItem = ({ label: string, deviceProp: string, }) => { - const value = useDeviceInfo(deviceProp); + const { formatMessage } = useIntl(); + const { value, state } = useDeviceInfo(deviceProp); + const displayValue = + state === "loading" + ? "…" + : state === "ready" + ? value + : formatMessage(m.unknown); return ( - + ); };