diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c29d4b6..5be673384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [5.6.0](https://github.com/digidem/mapeo-mobile/compare/v5.5.0...v5.6.0) (2023-01-16) + +### Features + +- Re-introduce improved maps management experimental feature ([93a0753](https://github.com/digidem/mapeo-mobile/commit/93a0753403b729763fc0a8aeafeca6e66827d615), [1649e3f](https://github.com/digidem/mapeo-mobile/commit/1649e3f50355d8325a7ea9115273d99572e05d4f), [7ee2863](https://github.com/digidem/mapeo-mobile/commit/7ee2863508c3b6d09aa7f85f7f3940fb990269b1)) + ## [5.5.0](https://github.com/digidem/mapeo-mobile/compare/v5.4.7...v5.5.0) (2022-11-17) ### Features diff --git a/android/app/src/main/java/com/mapeo/AppInfoPackage.java b/android/app/src/main/java/com/mapeo/AppInfoPackage.java index 93bdbcafe..cc8c9d031 100644 --- a/android/app/src/main/java/com/mapeo/AppInfoPackage.java +++ b/android/app/src/main/java/com/mapeo/AppInfoPackage.java @@ -21,6 +21,7 @@ public List createNativeModules(ReactApplicationContext reactConte List modules = new ArrayList<>(); modules.add(new AppInfoModule(reactContext)); + modules.add(new FlagSecureModule(reactContext)); return modules; } diff --git a/android/app/src/main/java/com/mapeo/FlagSecureModule.java b/android/app/src/main/java/com/mapeo/FlagSecureModule.java new file mode 100644 index 000000000..77b600d0e --- /dev/null +++ b/android/app/src/main/java/com/mapeo/FlagSecureModule.java @@ -0,0 +1,54 @@ +// Based off https://github.com/kristiansorens/react-native-flag-secure-android +package com.mapeo; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import android.app.Activity; +import android.view.WindowManager; + +public class FlagSecureModule extends ReactContextBaseJavaModule { + FlagSecureModule(ReactApplicationContext context) { + super(context); + } + + @Override + public String getName() { + return "FlagSecureModule"; + } + + @ReactMethod + public void activate() { + final Activity activity = getCurrentActivity(); + + if (activity != null) { + activity.runOnUiThread(new Runnable() { + @Override + public void run() + { + activity.getWindow().setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ); + } + }); + } + + + } + + @ReactMethod + public void deactivate() { + final Activity activity = getCurrentActivity(); + + if (activity != null) { + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + }); + } + } +} diff --git a/jest.config.js b/jest.config.js index 74cab08a8..aca37b4f8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,11 +15,15 @@ const modulesToTransform = [ "expo-document-picker", "@unimodules", "@react-native-picker/picker", + "p-defer", ]; module.exports = { preset: "react-native", - setupFilesAfterEnv: ["@testing-library/jest-native/extend-expect"], + setupFilesAfterEnv: [ + "@testing-library/jest-native/extend-expect", + "@react-native-mapbox-gl/maps/setup-jest", + ], testPathIgnorePatterns: ["/node_modules/", "/e2e/", "/src/backend/"], modulePathIgnorePatterns: [ "/nodejs-assets/", diff --git a/messages/de.json b/messages/de.json index 26664512c..ee4264b5f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -573,6 +573,10 @@ "description": "Title of message when the server has not responded for 10 seconds", "message": "Etwas stimmt nicht mit der Mapeo Datenbank" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Fehler beim Importieren" + }, "screens.Settings.ProjectConfig.LeavePracticeMode.leavePracticeMode": { "message": "Bereit, den Übungsmodus zu verlassen?" }, diff --git a/messages/en.json b/messages/en.json index 1c6407463..d3cef1d34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -31,6 +31,14 @@ "description": "Title of dialog shown to the user when Mapeo is updated", "message": "Mapeo Updated ✅" }, + "hooks.useMapStyles.defaultMapName": { + "description": "The name of the default background map", + "message": "Default" + }, + "hooks.useMapStyles.offlineMapName": { + "description": "The name of the legacy offline background map", + "message": "Offline Map" + }, "screens.AboutMapeo.androidBuild": { "description": "Label for Android build number", "message": "Android build number" @@ -183,6 +191,9 @@ "screens.AppPasscode.NewPasscode.InputPasscodeScreen.subTitleSet": { "message": "This passcode will be required to open the Mapeo App" }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.title": { + "message": "Set Passcode" + }, "screens.AppPasscode.NewPasscode.Splash.continue": { "message": "Continue" }, @@ -207,6 +218,9 @@ "screens.AppPasscode.TurnOffPasscode.description": { "message": "App Passcode adds an additional layer of security by requiring that you enter a passcode in order to open the Mapeo app." }, + "screens.AppPasscode.TurnOffPasscode.title": { + "message": "App Passcode" + }, "screens.AppPasscode.TurnOffPasscode.turnOff": { "message": "Turn Off" }, @@ -811,6 +825,10 @@ "screens.Settings.Experiments.BGMaps.goTo": { "message": "Try it now" }, + "screens.Settings.Experiments.BGMaps.shortLink": { + "description": "Used as a link to the gitbooks documentation for adding background maps", + "message": "here." + }, "screens.Settings.Experiments.BGMaps.useBGMap": { "message": "Use Background Maps (Feature)" }, @@ -931,6 +949,12 @@ "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMapWarning": { "message": "This area will no longer be available offline. Cannot be undone." }, + "screens.Settings.MapSettings.addBGMap": { + "message": "Add Background Map" + }, + "screens.Settings.MapSettings.backgroundMapTitle": { + "message": "Background Maps" + }, "screens.Settings.MapSettings.backgroundMaps": { "message": "Background Maps" }, @@ -941,13 +965,25 @@ "description": "Confirm delete map modal button", "message": "Yes, Delete" }, + "screens.Settings.MapSettings.defaultMap": { + "description": "Name of default map", + "message": "Default Map" + }, "screens.Settings.MapSettings.deleteMapTitle": { "description": "Title for the delete map modal", "message": "Delete Map" }, - "screens.Settings.MapSettings.importError": { - "description": "Error importing map warning", - "message": "Error Importing Map, please try a different file." + "screens.Settings.MapSettings.fileErrorDescription": { + "description": "Description for file error in bottom sheet content", + "message": "Error importing file, please try a different file." + }, + "screens.Settings.MapSettings.importErrorDescription": { + "description": "Description for import error in bottom sheet content", + "message": "Unable to import {styleNames}. Re-import the map {fileCount, plural, one {file} other {files}} to try again." + }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Import Error" }, "screens.Settings.MapSettings.importFromFile": { "message": "Import from File" @@ -955,6 +991,10 @@ "screens.Settings.MapSettings.mapSettings": { "message": "Map Settings" }, + "screens.Settings.MapSettings.processingFile": { + "description": "Description for when a file is being processed for import", + "message": "Processing file..." + }, "screens.Settings.MapSettings.subtitle": { "message": "Add, remove, and view map details" }, @@ -1316,10 +1356,22 @@ "sharedComponents.BGMapCard.currentMap": { "message": "Current Map" }, + "sharedComponents.BGMapCard.errorOccurred": { + "description": "Message describing that error occurred for map import", + "message": "Error occurred" + }, + "sharedComponents.BGMapCard.importInProgress": { + "description": "Progress bar message about the import being in progress", + "message": "Import in progress…" + }, "sharedComponents.BGMapCard.unamedStyle": { "description": "The name for the default map style", "message": "Unnamed Style" }, + "sharedComponents.BGMapCard.waitingForImport": { + "description": "Progress bar message indicating that import is waiting to start", + "message": "Waiting for import…" + }, "sharedComponents.BGMapSelector.close": { "message": "Close" }, diff --git a/messages/es.json b/messages/es.json index 7cc7ca814..2f68b5859 100644 --- a/messages/es.json +++ b/messages/es.json @@ -31,6 +31,14 @@ "description": "Title of dialog shown to the user when Mapeo is updated", "message": "Mapeo actualizado ✅" }, + "hooks.useMapStyles.defaultMapName": { + "description": "The name of the default background map", + "message": "Predeterminado" + }, + "hooks.useMapStyles.offlineMapName": { + "description": "The name of the legacy offline background map", + "message": "Mapa sin conexión" + }, "screens.AboutMapeo.androidBuild": { "description": "Label for Android build number", "message": "Número de compilación de Android" @@ -73,7 +81,7 @@ "message": "Cancelar" }, "screens.AddToProjectScreen.DeviceFoundStep.coordinatorOptionDescription": { - "message": "Los Coordinadores pueden añadir y eliminar dispositivos de los proyectos" + "message": "Las personas coordinadoras pueden añadir y eliminar dispositivos de los proyectos" }, "screens.AddToProjectScreen.DeviceFoundStep.coordinatorOptionTitle": { "message": "Este dispositivo es un Coordinador" @@ -132,7 +140,7 @@ "message": "Ya estás en un proyecto" }, "screens.AppPasscode": { - "message": "Contraseña de App" + "message": "Contraseña de la aplicación" }, "screens.AppPasscode.ConfirmPasscodeSheet.cancel": { "message": "Cancelar" @@ -147,11 +155,17 @@ "screens.AppPasscode.ConfirmPasscodeSheet.title": { "message": "¡Si pierde u olvida la Contraseña de la aplicación, no va a poder recuperarla! Asegúrese de anotar su contraseña en un lugar seguro antes de guardar." }, + "screens.AppPasscode.EnterPassToTurnOff.subTitleEnter": { + "message": "Por favor, introduzca su contraseña" + }, + "screens.AppPasscode.EnterPassToTurnOff.titleEnter": { + "message": "Introduzca su contraseña" + }, "screens.AppPasscode.InputPasscodeScreen.cancel": { "message": "Cancelar" }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.TitleConfirm": { - "message": "Volver a introducir la contraseña" + "message": "Vuelva a introducir la contraseña" }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.TitleSet": { "message": "Establecer la Contraseña de la aplicación" @@ -168,6 +182,9 @@ "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordDoesNotMatch": { "message": "La contraseña no coincide" }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordError": { + "message": "Contraseña incorrecta" + }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.subTitleSet": { "message": "Esta contraseña será necesaria para abrir la aplicación de Mapeo" }, @@ -345,6 +362,24 @@ "screens.LeaveProject.LeaveProject": { "message": "Abandonar proyecto" }, + "screens.LeaveProject.LeaveProject.agreeToDelete": { + "message": "Entiendo que borraré todos los datos de mi dispositivo." + }, + "screens.LeaveProject.LeaveProject.cancel": { + "message": "Cancelar" + }, + "screens.LeaveProject.LeaveProject.confirmDelete": { + "message": "Por favor, marque la casilla para continuar" + }, + "screens.LeaveProject.LeaveProject.headerTitle": { + "message": "Abandonar proyecto" + }, + "screens.LeaveProject.LeaveProject.leaveButton": { + "message": "Abandonar proyecto" + }, + "screens.LeaveProject.LeaveProject.syncWarning": { + "message": "Sincroniza con alguien del equipo para que no pierdas todas tus observaciones." + }, "screens.LeaveProject.LeaveProjectCompleted.goHome": { "message": "Ir a la página principal" }, @@ -791,7 +826,7 @@ "message": "Actualizaciones P2P de la App" }, "screens.Settings.Experiments.P2pUpgrade.useP2p": { - "message": "Usar Actualizaciones P2P de la App" + "message": "Usar Actualizador P2P de la App" }, "screens.Settings.Experiments.P2pUpgrade.warning": { "message": "Las Actualizaciones P2P (entre pares) de la aplicación le permiten compartir y recibir nuevas versiones de la aplicación Mapeo conectándose a otros dispositivos Mapeo a través de Wi-Fi (sin necesidad de conexión a Internet). Para utilizar esta función, la pantalla de sincronización de Mapeo debe estar abierta en ambos dispositivos." @@ -837,12 +872,64 @@ "description": "Row label indicating details related to the user's device membership", "message": "(Tú) {deviceName}" }, + "screens.Settings.MapSettings.BackgroundMapInfo.cancel": { + "message": "Cancelar" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.deleteMap": { + "message": "Eliminar mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.description": { + "message": "Nivel de detalle" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.maxZoom": { + "description": "Shows the user the max zoom for an offline map", + "message": "Zoom máximo" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.mb": { + "description": "abbreviation for megabyte", + "message": "MB" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.minZoom": { + "description": "Shows the user the min zoom for an offline map", + "message": "Zoom mínimo" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.removeMap": { + "message": "Eliminar mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.subtitle": { + "message": "Este mapa y las áreas fuera de línea adjuntas a él serán eliminadas permanentemente. Esta acción no se puede deshacer" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.useMap": { + "message": "Usar mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.zoomRange": { + "description": "Shows the user the range of zoom available for an offline map", + "message": "Rango del Zoom" + }, "screens.Settings.MapSettings.BackgroundMapTitle": { "message": "Mapas de fondo" }, "screens.Settings.MapSettings.BackgroundMaps": { "message": "Añadir mapa de fondo" }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.cancel": { + "message": "Cancelar" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMap": { + "message": "Eliminar mapa" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMapMessage": { + "message": "¿Seguro que desea eliminar el mapa {mapName}?" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMapWarning": { + "message": "Esta área ya no estará disponible sin conexión a internet. Esta acción no se puede deshacer." + }, + "screens.Settings.MapSettings.addBGMap": { + "message": "Añadir mapa de fondo" + }, + "screens.Settings.MapSettings.backgroundMapTitle": { + "message": "Mapas de fondo" + }, "screens.Settings.MapSettings.backgroundMaps": { "message": "Mapas de fondo" }, @@ -853,20 +940,36 @@ "description": "Confirm delete map modal button", "message": "Sí, eliminar" }, + "screens.Settings.MapSettings.defaultMap": { + "description": "Name of default map", + "message": "Mapa por defecto" + }, "screens.Settings.MapSettings.deleteMapTitle": { "description": "Title for the delete map modal", "message": "Eliminar mapa" }, - "screens.Settings.MapSettings.importError": { - "description": "Error importing map warning", + "screens.Settings.MapSettings.fileErrorDescription": { + "description": "Description for file error in bottom sheet content", "message": "Error al importar el mapa, por favor inténtelo con un archivo diferente." }, + "screens.Settings.MapSettings.importErrorDescription": { + "description": "Description for import error in bottom sheet content", + "message": "No se puede importar {styleNames}. Re-importa el mapa {fileCount, plural, one {archivo} other {¡archivos}} para intentarlo de nuevo." + }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Error al importar" + }, "screens.Settings.MapSettings.importFromFile": { "message": "Importar desde archivo" }, "screens.Settings.MapSettings.mapSettings": { "message": "Ajustes del mapa" }, + "screens.Settings.MapSettings.processingFile": { + "description": "Description for when a file is being processed for import", + "message": "Procesando archivo..." + }, "screens.Settings.MapSettings.subtitle": { "message": "Agregar, eliminar y ver detalles del mapa" }, @@ -1228,10 +1331,22 @@ "sharedComponents.BGMapCard.currentMap": { "message": "Mapa actual" }, + "sharedComponents.BGMapCard.errorOccurred": { + "description": "Message describing that error occurred for map import", + "message": "Se ha producido un error" + }, + "sharedComponents.BGMapCard.importInProgress": { + "description": "Progress bar message about the import being in progress", + "message": "Importando…" + }, "sharedComponents.BGMapCard.unamedStyle": { "description": "The name for the default map style", "message": "Estilo sin nombre" }, + "sharedComponents.BGMapCard.waitingForImport": { + "description": "Progress bar message indicating that import is waiting to start", + "message": "Esperando para importar…" + }, "sharedComponents.BGMapSelector.close": { "message": "Cerrar" }, diff --git a/messages/fr.json b/messages/fr.json index 297b88c95..c04f5a517 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -728,6 +728,10 @@ "description": "Row label indicating details related to the user's device membership", "message": "(Vous) {deviceName}" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Erreur d'importation" + }, "screens.Settings.ProjectConfig.LeavePracticeMode.createOrJoinProject": { "message": "Créer ou rejoindre un projet ci-dessous" }, diff --git a/messages/id.json b/messages/id.json index dbe04f0c6..8d07bf50e 100644 --- a/messages/id.json +++ b/messages/id.json @@ -654,6 +654,10 @@ "description": "Row label indicating details related to the user's device membership", "message": "(Anda) {deviceName}" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Kesalahan Impor" + }, "screens.Settings.ProjectConfig.LeavePracticeMode.createOrJoinProject": { "message": "Buat atau bergabung dengan proyek di bawah ini" }, diff --git a/messages/ja.json b/messages/ja.json index dae3b2a15..60778bc67 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -204,6 +204,10 @@ "description": "Title of message when the server has not responded for 10 seconds", "message": "Mapeoデータベースに問題があります" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "インポートエラー" + }, "screens.Settings.ProjectConfig.configErrorDescription": { "description": "Description of error dialog when there is an error importing a config file", "message": "この設定ファイルのインポート中にエラーが発生しました" diff --git a/messages/km.json b/messages/km.json index 79bf48986..f12d36977 100644 --- a/messages/km.json +++ b/messages/km.json @@ -264,6 +264,10 @@ "description": "Title of message when the server has not responded for 10 seconds", "message": "វាមានភាពត្រូវគ្នាជាមួយនឹងមូលដ្ឋានទិន្នន័យរបស់ Mapeo" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "ការទាញចូលមានបញ្ហា" + }, "screens.Settings.ProjectConfig.configErrorDescription": { "description": "Description of error dialog when there is an error importing a config file", "message": "មិនបានមានបញ្ហាក្នុងការព្យាយាមទាញចូលឯកសារខិនហ្វិក" diff --git a/messages/ne.json b/messages/ne.json index c1028a517..6fcbd051f 100644 --- a/messages/ne.json +++ b/messages/ne.json @@ -415,6 +415,10 @@ "description": "Title of message when the server has not responded for 10 seconds", "message": "Mapeo डाटावेसकाे साथ केहि छ" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "भित्राउनमा समस्या" + }, "screens.Settings.ProjectConfig.configErrorDescription": { "description": "Description of error dialog when there is an error importing a config file", "message": "याे config file भित्राउन खाेज्दा समस्या पैदा भएकाे छ" diff --git a/messages/nl.json b/messages/nl.json index 1ce3043ae..ce23ad8ac 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -308,6 +308,10 @@ "description": "Title of message when the server has not responded for 10 seconds", "message": "Er is iets mis met de Mapeo database" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Fout bij importeren" + }, "screens.Settings.ProjectConfig.configErrorDescription": { "description": "Description of error dialog when there is an error importing a config file", "message": "Er is een fout opgetreden bij het importeren van deze configuratie file" diff --git a/messages/pt.json b/messages/pt.json index 2b14daaba..15b06e85e 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -31,6 +31,14 @@ "description": "Title of dialog shown to the user when Mapeo is updated", "message": "Mapeo atualizado ✅" }, + "hooks.useMapStyles.defaultMapName": { + "description": "The name of the default background map", + "message": "Padrão" + }, + "hooks.useMapStyles.offlineMapName": { + "description": "The name of the legacy offline background map", + "message": "Mapa offline" + }, "screens.AboutMapeo.androidBuild": { "description": "Label for Android build number", "message": "Número do build do Android" @@ -135,7 +143,7 @@ "message": "Você já está participando de um projeto" }, "screens.AppPasscode": { - "message": "Senha de aplicativo" + "message": "Senha do aplicativo" }, "screens.AppPasscode.ConfirmPasscodeSheet.cancel": { "message": "Cancelar" @@ -150,6 +158,12 @@ "screens.AppPasscode.ConfirmPasscodeSheet.title": { "message": "Se perder ou esquecer a Senha do aplicativo, ela nunca pode ser recuperada! Certifique-se de anotar sua senha em um local seguro antes de salvar." }, + "screens.AppPasscode.EnterPassToTurnOff.subTitleEnter": { + "message": "Por favor insira sua senha" + }, + "screens.AppPasscode.EnterPassToTurnOff.titleEnter": { + "message": "Insira sua senha" + }, "screens.AppPasscode.InputPasscodeScreen.cancel": { "message": "Cancelar" }, @@ -157,7 +171,7 @@ "message": "Insira sua senha novamente" }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.TitleSet": { - "message": "Definir Senha de aplicativo" + "message": "Defina Senha do aplicativo" }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.button": { "message": "Próximo" @@ -171,6 +185,9 @@ "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordDoesNotMatch": { "message": "Senha não corresponde" }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordError": { + "message": "Senha incorreta" + }, "screens.AppPasscode.NewPasscode.InputPasscodeScreen.subTitleSet": { "message": "Esta senha será necessária para abrir o Mapeo" }, @@ -178,7 +195,7 @@ "message": "Continue" }, "screens.AppPasscode.NewPasscode.Splash.title": { - "message": "O que é Senha de aplicativo?" + "message": "O que é Senha do aplicativo?" }, "screens.AppPasscode.PasscodeIntro.description": { "message": "A Senha do aplicativo permite que você adicione uma camada adicional de segurança, exigindo que você insira uma senha para abrir o aplicativo Mapeo. Você pode definir sua própria senha de 5 dígitos ativando o recurso abaixo." @@ -736,7 +753,7 @@ "message": "Senha definida" }, "screens.Security.passcodeHeader": { - "message": "Senha de aplicativo" + "message": "Senha do aplicativo" }, "screens.Security.securitySubheader": { "message": "Segurança do dispositivo" @@ -778,6 +795,10 @@ "screens.Settings.Experiments.BGMaps.goTo": { "message": "Experimente agora" }, + "screens.Settings.Experiments.BGMaps.shortLink": { + "description": "Used as a link to the gitbooks documentation for adding background maps", + "message": "aqui." + }, "screens.Settings.Experiments.BGMaps.useBGMap": { "message": "Usar o recurso de Mapas de fundo" }, @@ -846,12 +867,64 @@ "description": "Row label indicating details related to the user's device membership", "message": "(Você) {deviceName}" }, + "screens.Settings.MapSettings.BackgroundMapInfo.cancel": { + "message": "Cancelar" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.deleteMap": { + "message": "Excluir mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.description": { + "message": "Nível de detalhe" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.maxZoom": { + "description": "Shows the user the max zoom for an offline map", + "message": "Zoom máximo" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.mb": { + "description": "abbreviation for megabyte", + "message": "MB" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.minZoom": { + "description": "Shows the user the min zoom for an offline map", + "message": "Zoom mínimo" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.removeMap": { + "message": "Excluir mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.subtitle": { + "message": "Este mapa e as áreas offline anexadas a ele serão excluídos. Isso não pode ser desfeito." + }, + "screens.Settings.MapSettings.BackgroundMapInfo.useMap": { + "message": "Usar mapa" + }, + "screens.Settings.MapSettings.BackgroundMapInfo.zoomRange": { + "description": "Shows the user the range of zoom available for an offline map", + "message": "Faixa de zoom" + }, "screens.Settings.MapSettings.BackgroundMapTitle": { "message": "Mapas de fundo" }, "screens.Settings.MapSettings.BackgroundMaps": { "message": "Adicionar mapa de fundo" }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.cancel": { + "message": "Cancelar" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMap": { + "message": "Excluir mapa" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMapMessage": { + "message": "Tem certeza que deseja excluir {mapName}?" + }, + "screens.Settings.MapSettings.DeleteMapBottomSheet.deleteMapWarning": { + "message": "Este mapa não estará mais disponível neste dispositivo. Não pode ser desfeito." + }, + "screens.Settings.MapSettings.addBGMap": { + "message": "Adicionar mapa de fundo" + }, + "screens.Settings.MapSettings.backgroundMapTitle": { + "message": "Mapas de fundo" + }, "screens.Settings.MapSettings.backgroundMaps": { "message": "Mapas de fundo" }, @@ -862,13 +935,25 @@ "description": "Confirm delete map modal button", "message": "Sim, excluir" }, + "screens.Settings.MapSettings.defaultMap": { + "description": "Name of default map", + "message": "Mapa padrão" + }, "screens.Settings.MapSettings.deleteMapTitle": { "description": "Title for the delete map modal", "message": "Excluir mapa" }, - "screens.Settings.MapSettings.importError": { - "description": "Error importing map warning", - "message": "Erro ao importar o mapa, por favor tente um arquivo diferente." + "screens.Settings.MapSettings.fileErrorDescription": { + "description": "Description for file error in bottom sheet content", + "message": "Erro ao importar o mapa. Por favor tente um arquivo diferente." + }, + "screens.Settings.MapSettings.importErrorDescription": { + "description": "Description for import error in bottom sheet content", + "message": "Não foi possível importar {styleNames}. Por favor, tente importar {fileCount, plural, one {o arquivo} other {os arquivos}} novamente." + }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Erro ao importar" }, "screens.Settings.MapSettings.importFromFile": { "message": "Importar de arquivo" @@ -876,6 +961,10 @@ "screens.Settings.MapSettings.mapSettings": { "message": "Ajustes do mapa" }, + "screens.Settings.MapSettings.processingFile": { + "description": "Description for when a file is being processed for import", + "message": "Processando arquivo..." + }, "screens.Settings.MapSettings.subtitle": { "message": "Adicionar, excluir e ver detalhes do mapa" }, @@ -944,7 +1033,7 @@ }, "screens.Settings.aboutMapeo": { "description": "Primary text for 'About Mapeo' link (version info)", - "message": "Sobre Mapeo" + "message": "Sobre o Mapeo" }, "screens.Settings.aboutMapeoDesc": { "description": "Description of the 'About Mapeo' page", @@ -1010,7 +1099,7 @@ }, "screens.Settings.securityDesc": { "description": "Description of security button in settings", - "message": "Senha de aplicativo e Segurança do dispositivo" + "message": "Senha do aplicativo e Segurança do dispositivo" }, "screens.Settings.title": { "description": "Title of settings screen", @@ -1074,7 +1163,7 @@ }, "screens.SyncModal.SyncView.openSettingsButton": { "description": "Label for button to open settings to fix update permissions error", - "message": "Abrir ajustes" + "message": "Abrir Ajustes" }, "screens.SyncModal.SyncView.projectKey": { "description": "First 5 characters of project key displayed on sync screen", @@ -1102,7 +1191,7 @@ }, "screens.SyncModal.SyncView.updateAwaitingSync": { "description": "Label in upgrade bar when waiting for sync to complete", - "message": "Atualização disponível; aguardando sincronização terminar" + "message": "Atualização disponível; aguardando a sincronização terminar" }, "screens.SyncModal.SyncView.updateDownloading": { "description": "Label in upgrade bar when downloading an update", @@ -1237,10 +1326,22 @@ "sharedComponents.BGMapCard.currentMap": { "message": "Mapa atual" }, + "sharedComponents.BGMapCard.errorOccurred": { + "description": "Message describing that error occurred for map import", + "message": "Ocorreu um erro" + }, + "sharedComponents.BGMapCard.importInProgress": { + "description": "Progress bar message about the import being in progress", + "message": "Importação em andamento…" + }, "sharedComponents.BGMapCard.unamedStyle": { "description": "The name for the default map style", "message": "Estilo sem nome" }, + "sharedComponents.BGMapCard.waitingForImport": { + "description": "Progress bar message indicating that import is waiting to start", + "message": "Aguardando importação…" + }, "sharedComponents.BGMapSelector.close": { "message": "Fechar" }, diff --git a/messages/si.json b/messages/si.json index a97badbd7..3c7d3bbf9 100644 --- a/messages/si.json +++ b/messages/si.json @@ -743,6 +743,10 @@ "screens.Settings.MapSettings.close": { "message": "වසන්න" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "අපනයන දෝෂ" + }, "screens.Settings.MapSettings.importFromFile": { "message": "ගොනුව වෙතින් ආයාත කරන්න" }, diff --git a/messages/ta.json b/messages/ta.json index 14e44f77b..85ebb1eda 100644 --- a/messages/ta.json +++ b/messages/ta.json @@ -728,6 +728,10 @@ "description": "Row label indicating details related to the user's device membership", "message": "(நீங்கள்) {சாதனத்தின் பெயர்}" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "பதிவிறக்கப் பிழை" + }, "screens.Settings.ProjectConfig.LeavePracticeMode.createOrJoinProject": { "message": "கீழே உள்ள திட்டத்தை உருவாக்கவும் அல்லது சேரவும்" }, diff --git a/messages/th.json b/messages/th.json index a0dcbbd3d..019f03764 100644 --- a/messages/th.json +++ b/messages/th.json @@ -743,6 +743,10 @@ "screens.Settings.MapSettings.close": { "message": "ปิด" }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "ข้อผิดพลาดในการนำเข้า" + }, "screens.Settings.MapSettings.importFromFile": { "message": "นำเข้าจากไฟล์" }, diff --git a/messages/vi.json b/messages/vi.json index 6ba7f7164..2a43abc43 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -150,18 +150,72 @@ "screens.AppPasscode.ConfirmPasscodeSheet.title": { "message": "Mật khẩu ứng dụng không thể được phục hồi nếu bị mất hoặc bị quên! Hãy nhớ ghi lại mật khẩu của bạn ở một nơi an toàn trước khi lưu để dùng." }, + "screens.AppPasscode.EnterPassToTurnOff.subTitleEnter": { + "message": "Vui lòng nhập mật khẩu" + }, + "screens.AppPasscode.EnterPassToTurnOff.titleEnter": { + "message": "Nhập mật khẩu" + }, + "screens.AppPasscode.InputPasscodeScreen.cancel": { + "message": "Hủy" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.TitleConfirm": { + "message": "Nhập lại mật khẩu" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.TitleSet": { + "message": "Thiết lập mật khauar ứng dụng" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.button": { + "message": "Tiếp theo" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.initialPassError": { + "message": "Mật khẩu phải có 5 số" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.obscurePasscodeError": { + "message": "Không thể sử dụng làm mật khẩu" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordDoesNotMatch": { + "message": "Mật khẩu không khớp" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordError": { + "message": "Mật khẩu không đúng" + }, + "screens.AppPasscode.NewPasscode.InputPasscodeScreen.subTitleSet": { + "message": "Mật khẩu này sẽ được yêu cầu để mở ứng dụng Mapeo" + }, "screens.AppPasscode.NewPasscode.Splash.continue": { "message": "Tiếp tục" }, "screens.AppPasscode.NewPasscode.Splash.title": { "message": "Mật khẩu Ứng dụng là gì?" }, + "screens.AppPasscode.PasscodeIntro.description": { + "message": "Mật khẩu ứng dụng cho phép bạn thêm một lớp bảo mật bổ sung bằng cách yêu cầu bạn nhập mật khẩu để mở ứng dụng Mapeo. Bạn có thể xác định mật khẩu gồm 5 chữ số của riêng mình bằng cách bật tính năng bên dưới." + }, + "screens.AppPasscode.PasscodeIntro.warning": { + "message": "**Xin lưu ý rằng mật khẩu đã quên không thể khôi phục được!** Khi tính năng này được bật, nếu bạn quên hoặc mất mật khẩu, bạn sẽ không thể mở Mapeo và sẽ mất quyền truy cập vào mọi dữ liệu Mapeo chưa được đồng bộ hóa với những người tham gia dự án khác." + }, + "screens.AppPasscode.TurnOffPasscode.cancel": { + "message": "Hủy" + }, + "screens.AppPasscode.TurnOffPasscode.changePasscode": { + "message": "Đổi mật khẩu ứng dụng" + }, "screens.AppPasscode.TurnOffPasscode.currentlyUsing": { "message": "Bạn đang sử dụng Mật khẩu ứng dụng. Xem bên dưới để ngừng sử dụng hoặc thay đổi mật khẩu của bạn." }, "screens.AppPasscode.TurnOffPasscode.description": { "message": "Mật khẩu ứng dụng bổ sung thêm một lớp bảo mật bằng cách yêu cầu bạn nhập mật khẩu để mở ứng dụng Mapeo." }, + "screens.AppPasscode.TurnOffPasscode.turnOff": { + "message": "Tắt" + }, + "screens.AppPasscode.TurnOffPasscode.turnOffConfirmation": { + "message": "Tắt mật khẩu ứng dụng?" + }, + "screens.AppPasscode.TurnOffPasscode.usePasscode": { + "message": "Sử dụng mật khẩu ứng dụng" + }, "screens.CameraScreen.noCameraAccess": { "description": "Error message shown when app does not have permissions to camera", "message": "Không có quyền truy cập tới máy ảnh" @@ -236,6 +290,12 @@ "description": "Placeholder text for project name input field", "message": "Tên dự án" }, + "screens.EnterPassword.enterPass": { + "message": "Nhập mật khẩu" + }, + "screens.EnterPassword.wrongPass": { + "message": "Mật khẩu không đúng, vui lòng thử lại" + }, "screens.GpsModal.details": { "description": "Section title for details about current position", "message": "Chi tiết" @@ -297,6 +357,30 @@ "screens.LeaveProject.LeaveProject": { "message": "Rời dự án" }, + "screens.LeaveProject.LeaveProject.agreeToDelete": { + "message": "Tôi hiểu rằng tôi sẽ xóa toàn bộ dữ liệu khỏi thiết bị của mình." + }, + "screens.LeaveProject.LeaveProject.cancel": { + "message": "Hủy" + }, + "screens.LeaveProject.LeaveProject.confirmDelete": { + "message": "Vui lòng đánh dấu vào ô để xác nhận" + }, + "screens.LeaveProject.LeaveProject.headerTitle": { + "message": "Rời khỏi dự án" + }, + "screens.LeaveProject.LeaveProject.leaveButton": { + "message": "Rời khỏi dự án" + }, + "screens.LeaveProject.LeaveProject.leaveProjectTitle": { + "message": "Rời khỏi dự án {projectName}?" + }, + "screens.LeaveProject.LeaveProject.syncWarning": { + "message": "Đồng bộ với thành viên khác, bạn sẽ không bị mất tất cả các quan sát của mình." + }, + "screens.LeaveProject.LeaveProject.willDelete": { + "message": "{numOfObservations} quan sát và {numOfPics} ảnh sẽ bị xóa" + }, "screens.LeaveProject.LeaveProjectCompleted.goHome": { "message": "Về Màn hình chính" }, @@ -770,6 +854,12 @@ "screens.Settings.MapSettings.BackgroundMaps": { "message": "Thêm Bản đồ nền" }, + "screens.Settings.MapSettings.addBGMap": { + "message": "Thêm Bản đồ nền" + }, + "screens.Settings.MapSettings.backgroundMapTitle": { + "message": "Bản đồ nền" + }, "screens.Settings.MapSettings.backgroundMaps": { "message": "Bản đồ nền" }, @@ -780,13 +870,25 @@ "description": "Confirm delete map modal button", "message": "Có, xoá đi" }, + "screens.Settings.MapSettings.defaultMap": { + "description": "Name of default map", + "message": "Bản đồ mặc định" + }, "screens.Settings.MapSettings.deleteMapTitle": { "description": "Title for the delete map modal", "message": "Xoá bản đồ" }, - "screens.Settings.MapSettings.importError": { - "description": "Error importing map warning", - "message": "Lỗi khi nhập bản đồ, vui lòng thử một tệp khác." + "screens.Settings.MapSettings.fileErrorDescription": { + "description": "Description for file error in bottom sheet content", + "message": "Lỗi khi nhập tệp, vui lỏng thử một tệp khác." + }, + "screens.Settings.MapSettings.importErrorDescription": { + "description": "Description for import error in bottom sheet content", + "message": "Không thể nhập {styleNames}. Nhập lại bản đồ {fileCount, plural, one {file} other {files}} để thử lại." + }, + "screens.Settings.MapSettings.importErrorTitle": { + "description": "Title for import error in bottom sheet content", + "message": "Lỗi nhập" }, "screens.Settings.MapSettings.importFromFile": { "message": "Nhập từ file" @@ -794,6 +896,10 @@ "screens.Settings.MapSettings.mapSettings": { "message": "Cài đặt bản đồ" }, + "screens.Settings.MapSettings.processingFile": { + "description": "Description for when a file is being processed for import", + "message": "Đang xử lý tệp..." + }, "screens.Settings.MapSettings.subtitle": { "message": "Thêm, xóa và xem chi tiết bản đồ" }, @@ -1148,10 +1254,22 @@ "sharedComponents.BGMapCard.currentMap": { "message": "Bản đồ hiện tại" }, + "sharedComponents.BGMapCard.errorOccurred": { + "description": "Message describing that error occurred for map import", + "message": "Xảy ra lỗi" + }, + "sharedComponents.BGMapCard.importInProgress": { + "description": "Progress bar message about the import being in progress", + "message": "Đang nhập vào…" + }, "sharedComponents.BGMapCard.unamedStyle": { "description": "The name for the default map style", "message": "Kiểu không tên" }, + "sharedComponents.BGMapCard.waitingForImport": { + "description": "Progress bar message indicating that import is waiting to start", + "message": "Đợi để nhập…" + }, "sharedComponents.BGMapSelector.close": { "message": "Đóng" }, diff --git a/package-lock.json b/package-lock.json index 8610977d4..f7b01db76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "mapeo-mobile", - "version": "5.5.0", + "version": "5.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6156,9 +6156,9 @@ } }, "@fastify/swagger": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-6.1.0.tgz", - "integrity": "sha512-oum6QzV1/8/2eqWOEYzqwhRJQ3ftpxfGnDGmAinLIyeTrsr7xpNev/8iRxAAb7S6S8KRIIybkhvzvkuqnQKTkA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-6.1.1.tgz", + "integrity": "sha512-i+6UzvJf9tWT9+Cg+Tb/sM/+LMGpc2yGh+dvZYM2jT5p71PVK7YiEac93mJpLW9CH3RDOXV70PFey5pWpL6SJA==", "dev": true, "requires": { "@fastify/static": "^5.0.0", @@ -6309,11 +6309,11 @@ } }, "@gorhom/bottom-sheet": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-2.3.0.tgz", - "integrity": "sha512-INEEvTFQ1TgGgbJyjjpO/dzrEV5J+Nhdl31tQotbLekL1Ut5v1uojPid06y7T86y23VWDRuIwsYW/5p70FLB1A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-2.4.1.tgz", + "integrity": "sha512-Zj5G5yqfKG78fFE2WWJbvvxMYu5hl9OdlATAOUnqL9GGKIOTe+dLBqGfEjGU81XUbtqCtoNp2qlERM4ulV58bg==", "requires": { - "@gorhom/portal": "^1.0.4", + "@gorhom/portal": "^1.0.9", "invariant": "^2.2.4", "lodash.isequal": "^4.5.0", "nanoid": "^3.1.20", @@ -6333,18 +6333,17 @@ } }, "@gorhom/portal": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.7.tgz", - "integrity": "sha512-lDZZ5/cjvg1GHrK6Swljv1EssndiVz0iXsIOpsI8FS6IDyxIDDnzbHQ6ZhLp95jDhDyE0UTUsAjL2PeI2yVKMQ==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", "requires": { - "immer": "^9.0.3", - "nanoid": "^3.1.23" + "nanoid": "^3.3.1" }, "dependencies": { - "immer": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.5.tgz", - "integrity": "sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ==" + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" } } }, @@ -8981,27 +8980,26 @@ "dev": true }, "@mapeo/map-server": { - "version": "1.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@mapeo/map-server/-/map-server-1.0.0-alpha.10.tgz", - "integrity": "sha512-V6RJ+vWi4Umjrk1YUlrDCcDE+Mn6f+ANcBgIe2miFNb2m+sdFzuAaNNUePBewISbKjP9Bn4GOlhHbLLL9hhqOA==", + "version": "1.0.0-alpha.11", + "resolved": "https://registry.npmjs.org/@mapeo/map-server/-/map-server-1.0.0-alpha.11.tgz", + "integrity": "sha512-TvsdSjiR9CmyLwNvbmHwlACjXER5zeDmINNMkpGhJHX/5BxQ9siBwd7DnJtRGYNQ9/JKZk4ZPh6CqTg55+XIdw==", "dev": true, "requires": { "@fastify/error": "^2.0.0", "@fastify/static": "^5.0.2", - "@fastify/swagger": "^6.0.1", + "@fastify/swagger": "^6.1.1", "@mapbox/sphericalmercator": "^1.2.0", "@mapbox/whoots-js": "^3.1.0", "@maplibre/maplibre-gl-style-spec": "^16.0.0", - "@sinclair/typebox": "^0.24.28", - "@types/readable-stream": "^2.3.14", + "@sinclair/typebox": "^0.24.51", + "@types/readable-stream": "^2.3.15", "ajv": "^8.11.0", "base32.js": "^0.1.0", - "better-sqlite3": "^7.5.3", + "better-sqlite3": "^7.6.2", "fastify": "^3.29.0", "fastify-oas": "^3.0.8", "fastify-plugin": "^3.0.1", - "get-stream": "^6.0.1", - "got": "^11.8.3", + "got": "^11.8.5", "is-url": "^1.2.4", "make-promises-safe": "^5.1.0", "mem": "^8.1.1", @@ -9018,9 +9016,9 @@ "dev": true }, "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -9048,17 +9046,6 @@ "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" - }, - "dependencies": { - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - } } }, "decompress-response": { @@ -9071,10 +9058,13 @@ } }, "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } }, "got": { "version": "11.8.5", @@ -9153,6 +9143,11 @@ "sort-object": "^0.3.2" } }, + "@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -10393,9 +10388,9 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "@sinclair/typebox": { - "version": "0.24.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.41.tgz", - "integrity": "sha512-TJCgQurls4FipFvHeC+gfAzb+GGstL0TDwYJKQVtTeSvJIznWzP7g3bAd5gEBlr8+bIxqnWS9VGVWREDhmE8jA==", + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", "dev": true }, "@sindresorhus/is": { @@ -12487,13 +12482,13 @@ } }, "@types/readable-stream": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.14.tgz", - "integrity": "sha512-8jQ5Mp7bsDJEnW/69i6nAaQMoLwAVJVc7ZRAVTrdh/o6XueQsX38TEvKuYyoQj76/mg7WdlRfMrtl9pDLCJWsg==", + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", "dev": true, "requires": { "@types/node": "*", - "safe-buffer": "*" + "safe-buffer": "~5.1.1" } }, "@types/responselike": { @@ -20694,10 +20689,15 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, + "fast-text-encoding": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", + "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" + }, "fastify": { - "version": "3.29.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-3.29.2.tgz", - "integrity": "sha512-XFuIF4T9IdkCVtV0amWQZg50w8iPn8MoAV4yK1DP88dU7YEwxDOOpVgKLWZS4YJA8RU001KUfg2b/2L6kEwJWA==", + "version": "3.29.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-3.29.3.tgz", + "integrity": "sha512-PA5mGkVnAnhysmyAnXMN9gdOlcfIxyGsfj9C7/a3sSfe5mC38euEGRLEB0T7ygbi7TIZ9yIZ/FLiERpwZeWriA==", "dev": true, "requires": { "@fastify/ajv-compiler": "^1.0.0", @@ -31308,9 +31308,9 @@ }, "dependencies": { "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -31742,6 +31742,14 @@ "dev": true, "requires": { "p-defer": "^1.0.0" + }, + "dependencies": { + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true + } } }, "map-cache": { @@ -36392,9 +36400,9 @@ "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" }, "node-abi": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.24.0.tgz", - "integrity": "sha512-YPG3Co0luSu6GwOBsmIdGW6Wx0NyNDLg/hriIyDllVsNwnI6UeqaWShxC3lbH4LtEQUgoLP3XR1ndXiDAWvmRw==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.28.0.tgz", + "integrity": "sha512-fRlDb4I0eLcQeUvGq7IY3xHrSb0c9ummdvDSYWfT9+LKP+3jCKw/tKoqaM7r1BAoiAC6GtwyjaGnOz6B3OtF+A==", "dev": true, "requires": { "semver": "^7.3.5" @@ -36410,9 +36418,9 @@ } }, "semver": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", - "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -37035,10 +37043,9 @@ "dev": true }, "p-defer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", - "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", - "dev": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.0.tgz", + "integrity": "sha512-Vb3QRvQ0Y5XnF40ZUWW7JfLogicVh/EnA5gBIvKDJoYpeI82+1E3AlB9yOcKFS0AhHrWVnAQO39fbR0G99IVEQ==" }, "p-each-series": { "version": "2.1.0", @@ -39184,6 +39191,21 @@ "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-8.4.8.tgz", "integrity": "sha512-92676ZWHZHsPM/EW1ulgb2MuVfjYfMWRTWMbLcrCsipkcMaZ9Traz5mpsnCS7KZpsOksnvUinzDIjsct2XGc6Q==" }, + "react-native-fetch-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-native-fetch-api/-/react-native-fetch-api-3.0.0.tgz", + "integrity": "sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==", + "requires": { + "p-defer": "^3.0.0" + }, + "dependencies": { + "p-defer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz", + "integrity": "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==" + } + } + }, "react-native-fs": { "version": "2.16.6", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.16.6.tgz", @@ -43553,6 +43575,11 @@ "defaults": "^1.0.3" } }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", diff --git a/package.json b/package.json index d727bc9c7..f91309d7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapeo-mobile", - "version": "5.5.0", + "version": "5.6.0", "private": true, "engines": { "node": "12.16.3" @@ -46,7 +46,8 @@ "@bugsnag/react-native": "^7.16.7", "@formatjs/intl-locale": "^3.0.7", "@formatjs/intl-relativetimeformat": "^11.1.4", - "@gorhom/bottom-sheet": "^2.3.0", + "@gorhom/bottom-sheet": "^2.4.1", + "@microsoft/fetch-event-source": "^2.0.1", "@react-native-async-storage/async-storage": "^1.15.14", "@react-native-community/art": "^1.2.0", "@react-native-community/checkbox": "^0.5.8", @@ -70,12 +71,14 @@ "expo-localization": "^10.2.0", "expo-location": "^12.1.0", "expo-sensors": "^10.2.0", + "fast-text-encoding": "^1.0.6", "flat": "^5.0.2", "ky": "^0.23.0", "lodash": "^4.17.19", "mapeo-offline-map": "^2.0.0", "mapeo-schema": "^2.0.2", "nodejs-mobile-react-native": "^0.8.1", + "p-defer": "^4.0.0", "p-is-promise": "^4.0.0", "p-timeout": "^4.1.0", "patch-package": "^6.4.7", @@ -87,6 +90,7 @@ "react-native-android-open-settings": "^1.3.0", "react-native-confirmation-code-field": "^7.3.0", "react-native-device-info": "^8.4.8", + "react-native-fetch-api": "^3.0.0", "react-native-fs": "^2.16.6", "react-native-gesture-handler": "^1.10.3", "react-native-image-resizer": "^1.2.3", @@ -111,7 +115,8 @@ "turndown-rn": "^6.1.0", "use-memo-one": "^1.1.1", "utm": "^1.1.1", - "validate-color": "^2.2.1" + "validate-color": "^2.2.1", + "web-streams-polyfill": "^3.2.1" }, "devDependencies": { "@babel/core": "~7.12.17", @@ -119,7 +124,7 @@ "@babel/runtime": "~7.12.18", "@bugsnag/source-maps": "^2.3.1", "@digidem/extract-react-intl-messages": "^2.0.2", - "@mapeo/map-server": "^1.0.0-alpha.10", + "@mapeo/map-server": "^1.0.0-alpha.11", "@react-native-community/cli": "^6.2.0", "@react-native-community/eslint-config": "^2.0.0", "@sinonjs/fake-timers": "^8.1.0", diff --git a/patches/README.md b/patches/README.md index 0a850113c..45634cb0f 100644 --- a/patches/README.md +++ b/patches/README.md @@ -29,3 +29,7 @@ based on application variant. It is necessary to patch this file rather than just add an environment variable as part of the build script because it allows gradle tasks like `./gradlew assembleRelease` to work as expected — each variant will be built correctly with the correct bundled files. + +## `react-native-fetch-api` + +This patch removes the second argument passed to the `TextEncoder.encode` method because we polyfill `TextEncoder` with [`fast-text-encoding`](https://github.com/samthor/fast-text-encoding) instead of [`text-encoding`](https://github.com/inexorabletash/text-encoding). `react-native-fetch-api` assumes that the latter is used for polyfilling, but as detailed [here](https://github.com/inexorabletash/text-encoding/blob/3f330964c0e97e1ed344c2a3e963f4598610a7ad/lib/encoding.js#L1275-L1281), the argument is non-standard. While the former does accept a second argument in general, it [explicitly checks against](https://github.com/samthor/fast-text-encoding/blob/60d0a6cc787da13c2799c11cae48eba037f74548/src/o-encoder.js#L23) the specific argument that `react-native-fetch-api` is passing here and throws as a result. diff --git a/patches/react-native-fetch-api+3.0.0.patch b/patches/react-native-fetch-api+3.0.0.patch new file mode 100644 index 000000000..80da4483d --- /dev/null +++ b/patches/react-native-fetch-api+3.0.0.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/react-native-fetch-api/src/Fetch.js b/node_modules/react-native-fetch-api/src/Fetch.js +index b3a5614..043dedd 100644 +--- a/node_modules/react-native-fetch-api/src/Fetch.js ++++ b/node_modules/react-native-fetch-api/src/Fetch.js +@@ -181,9 +181,7 @@ class Fetch { + return; + } + +- const typedArray = this._textEncoder.encode(responseText, { +- stream: true, +- }); ++ const typedArray = this._textEncoder.encode(responseText); + this._streamController.enqueue(typedArray); + } + diff --git a/src/backend/package-lock.json b/src/backend/package-lock.json index f5b732b40..9503cecb9 100644 --- a/src/backend/package-lock.json +++ b/src/backend/package-lock.json @@ -240,9 +240,9 @@ } }, "@fastify/swagger": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-6.1.0.tgz", - "integrity": "sha512-oum6QzV1/8/2eqWOEYzqwhRJQ3ftpxfGnDGmAinLIyeTrsr7xpNev/8iRxAAb7S6S8KRIIybkhvzvkuqnQKTkA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-6.1.1.tgz", + "integrity": "sha512-i+6UzvJf9tWT9+Cg+Tb/sM/+LMGpc2yGh+dvZYM2jT5p71PVK7YiEac93mJpLW9CH3RDOXV70PFey5pWpL6SJA==", "requires": { "@fastify/static": "^5.0.0", "fastify-plugin": "^3.0.0", @@ -370,26 +370,25 @@ } }, "@mapeo/map-server": { - "version": "1.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@mapeo/map-server/-/map-server-1.0.0-alpha.10.tgz", - "integrity": "sha512-V6RJ+vWi4Umjrk1YUlrDCcDE+Mn6f+ANcBgIe2miFNb2m+sdFzuAaNNUePBewISbKjP9Bn4GOlhHbLLL9hhqOA==", + "version": "1.0.0-alpha.11", + "resolved": "https://registry.npmjs.org/@mapeo/map-server/-/map-server-1.0.0-alpha.11.tgz", + "integrity": "sha512-TvsdSjiR9CmyLwNvbmHwlACjXER5zeDmINNMkpGhJHX/5BxQ9siBwd7DnJtRGYNQ9/JKZk4ZPh6CqTg55+XIdw==", "requires": { "@fastify/error": "^2.0.0", "@fastify/static": "^5.0.2", - "@fastify/swagger": "^6.0.1", + "@fastify/swagger": "^6.1.1", "@mapbox/sphericalmercator": "^1.2.0", "@mapbox/whoots-js": "^3.1.0", "@maplibre/maplibre-gl-style-spec": "^16.0.0", - "@sinclair/typebox": "^0.24.28", - "@types/readable-stream": "^2.3.14", + "@sinclair/typebox": "^0.24.51", + "@types/readable-stream": "^2.3.15", "ajv": "^8.11.0", "base32.js": "^0.1.0", - "better-sqlite3": "^7.5.3", + "better-sqlite3": "^7.6.2", "fastify": "^3.29.0", "fastify-oas": "^3.0.8", "fastify-plugin": "^3.0.1", - "get-stream": "^6.0.1", - "got": "^11.8.3", + "got": "^11.8.5", "is-url": "^1.2.4", "make-promises-safe": "^5.1.0", "mem": "^8.1.1", @@ -400,14 +399,14 @@ }, "dependencies": { "@sinclair/typebox": { - "version": "0.24.44", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", - "integrity": "sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg==" + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" }, "ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -427,16 +426,14 @@ "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" - }, - "dependencies": { - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "requires": { - "pump": "^3.0.0" - } - } + } + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" } }, "got": { @@ -642,12 +639,12 @@ } }, "@types/readable-stream": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.14.tgz", - "integrity": "sha512-8jQ5Mp7bsDJEnW/69i6nAaQMoLwAVJVc7ZRAVTrdh/o6XueQsX38TEvKuYyoQj76/mg7WdlRfMrtl9pDLCJWsg==", + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", "requires": { "@types/node": "*", - "safe-buffer": "*" + "safe-buffer": "~5.1.1" } }, "@types/responselike": { @@ -3171,7 +3168,8 @@ "get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true }, "get-value": { "version": "2.0.6", @@ -3954,7 +3952,7 @@ "iserror": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/iserror/-/iserror-0.0.2.tgz", - "integrity": "sha512-oKGGrFVaWwETimP3SiWwjDeY27ovZoyZPHtxblC4hCq9fXxed/jasx+ATWFFjCVSRZng8VTMsN1nDnGo6zMBSw==" + "integrity": "sha1-vVNFH+L2aLnyQCwZZnh6qix8C/U=" }, "isexe": { "version": "2.0.0", @@ -4950,9 +4948,9 @@ "dev": true }, "node-abi": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.26.0.tgz", - "integrity": "sha512-jRVtMFTChbi2i/jqo/i2iP9634KMe+7K1v35mIdj3Mn59i5q27ZYhn+sW6npISM/PQg7HrP2kwtRBMmh5Uvzdg==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.28.0.tgz", + "integrity": "sha512-fRlDb4I0eLcQeUvGq7IY3xHrSb0c9ummdvDSYWfT9+LKP+3jCKw/tKoqaM7r1BAoiAC6GtwyjaGnOz6B3OtF+A==", "requires": { "semver": "^7.3.5" }, @@ -5860,7 +5858,7 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" } } }, diff --git a/src/backend/package.json b/src/backend/package.json index 0fa76be3f..a77a87641 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -17,7 +17,7 @@ "dependencies": { "@bugsnag/js": "^7.16.5", "@gmaclennan/zip-fs": "^1.2.0", - "@mapeo/map-server": "^1.0.0-alpha.10", + "@mapeo/map-server": "^1.0.0-alpha.11", "@sinclair/typebox": "^0.16.5", "agentkeepalive": "^4.1.4", "ajv": "^8.3.0", diff --git a/src/backend/server.js b/src/backend/server.js index 2ef1f87dd..6644d5e39 100644 --- a/src/backend/server.js +++ b/src/backend/server.js @@ -267,9 +267,6 @@ function createServer({ return; } - handleGetState = createGetStateHandler(mapServer); - rnBridge.channel.on("map-server::get-state", handleGetState); - mapServer .start() .then(() => { @@ -283,20 +280,6 @@ function createServer({ stop: () => { log("Stopping map server"); - if (handleGetState) { - rnBridge.channel.removeListener( - "map-server::get-state", - handleGetState - ); - } - - if (handleReportErrorState) { - rnBridge.channel.removeListener( - "map-server::get-state", - handleReportErrorState - ); - } - // TODO: Handle this better if (!mapServer) { log("Map server instance does not exist"); diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 7d0dba2a2..46f51a177 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -15,7 +15,7 @@ import { CreateProjectScreen } from "../../screens/CreateProject"; import GpsModal from "../../screens/GpsModal"; import { LeaveProjectScreen } from "../../screens/LeaveProject"; import ManualGpsScreen from "../../screens/ManualGpsScreen"; -import { MapScreen } from "../../screens/MapScreen/MapScreen"; +import { MapScreen } from "../../screens/MapScreen"; import Observation from "../../screens/Observation"; import ObservationDetails from "../../screens/ObservationDetails"; import ObservationEdit from "../../screens/ObservationEdit"; @@ -29,7 +29,7 @@ import Settings from "../../screens/Settings"; import AboutMapeo from "../../screens/Settings/AboutMapeo"; import CoordinateFormat from "../../screens/Settings/CoordinateFormat"; import Experiments from "../../screens/Settings/Experiments"; -import { BGMapsSettings } from "../../screens/Settings/Experiments/BGMaps"; +import { BackgroundMapsSettings } from "../../screens/Settings/Experiments/BackgroundMaps"; import { DirectionalArrow } from "../../screens/Settings/Experiments/DirectionalArrow"; import { P2pUpgrade } from "../../screens/Settings/Experiments/P2pUpgrade"; import LanguageSettings from "../../screens/Settings/LanguageSettings"; @@ -44,8 +44,11 @@ import { AuthScreen } from "../../screens/AuthScreen"; import { RootStack } from "../AppStack"; import { BackgroundMapInfo } from "../../screens/Settings/MapSettings/BackgroundMapInfo"; import { AppPasscode } from "../../screens/AppPasscode"; +import { TurnOffPasscode } from "../../screens/AppPasscode/TurnOffPasscode"; import { ConfirmPasscodeSheet } from "../../screens/AppPasscode/ConfirmPasscodeSheet"; import { ObscurePasscode } from "../../screens/ObscurePasscode"; +import { SetPasscode } from "../../screens/AppPasscode/SetPasscode"; +import { EnterPassToTurnOff } from "../../screens/AppPasscode/EnterPassToTurnOff"; export type HomeTabsList = { Map: undefined; @@ -87,7 +90,7 @@ export type AppList = { MapSettings: undefined; BackgroundMaps: undefined; BackgroundMapInfo: { - bytesStored?: number; + bytesStored: number; id: string; styleUrl: string; name: string; @@ -97,6 +100,9 @@ export type AppList = { AppPasscode: undefined; ObscurePasscode: undefined; ConfirmPasscodeSheet: { passcode: string }; + DisablePasscode: undefined; + SetPasscode: undefined; + EnterPassToTurnOff: undefined; }; const Tab = createBottomTabNavigator(); @@ -155,11 +161,14 @@ export const createDefaultScreenGroup = ( + - {/* Modal, no title */} - + + + & { + port: number; +}; + // TODO: Incorporate server status and making sure it's ready for requests? function createMapServerApi() { - let mapServerPort: number | undefined; - let client: ReturnType | undefined; + let deferred = pDefer(); + function getClient() { + return deferred.promise; + } + // Workaround ready function. + async function ready() { + await getClient(); + } // This event occurs whenever the map server's start method is called, // which can happen on app startup but also app resumes nodejs.channel.addListener( "map-server::start", - (payload: { port: number }) => { - if (mapServerPort !== payload.port) { - mapServerPort = payload.port; - client = createMapServerClient(mapServerPort); - } + ({ port }: { port: number }) => { + const client = { + ...createRequestClient({ baseUrl: getBaseUrl(port) }), + port, + }; + deferred.resolve(client); } ); - function createMapServerClient(port: number) { - return createRequestClient({ baseUrl: getBaseUrl(port) }); - } - - function guaranteeClient() { - if (!mapServerPort) - throw new Error( - "Map server client cannot be used because port is unknown" - ); - - if (!client) { - client = createMapServerClient(mapServerPort); + nodejs.channel.addListener("map-server::state", state => { + if (state.value === "stopping") { + deferred = pDefer(); } - - return client; - } + }); // TODO: Implement addMapServerStateListener and addMapServerErrorListener const mapsApi = { - // TODO: Probably should use some kind of status-related implementation similar to how the app server does it - ready: () => { - // TODO: Rely on the app server's status heartbeat to ping the map server and check its state - // This is a temporary measure since there is no server status implemented for the map server right now - const appServerStatusSubscription = nodejs.channel.addListener( - "status", - ({ value }: ServerStatusMessage) => { - if ( - value === STATUS.LISTENING || - value === STATUS.STARTING || - value === STATUS.IDLE - ) { - nodejs.channel.post("map-server::get-state"); - } - } - ); - - const readyPromise = new Promise(resolve => { - const stateListenerSubscription = nodejs.channel.addListener( - "map-server::state", - state => { - if (state.value === "started") { - // @ts-expect-error - appServerStatusSubscription.remove(); - // @ts-expect-error - stateListenerSubscription.remove(); - resolve(); - } - } - ); - }); - - const mapServerReadyPromise = promiseTimeout( - readyPromise, - DEFAULT_TIMEOUT, - "Map server start timeout" - ); - - mapServerReadyPromise.catch(err => { - // @ts-expect-error - appServerStatusSubscription.remove(); - bugsnag.notify(err); - }); - - return mapServerReadyPromise as Promise; - }, addServerStateListener: ( handler: (state: MapServerState) => void ): Subscription => { @@ -281,8 +242,7 @@ function createMapServerApi() { ); // Poke backend to send a state event - mapsApi - .ready() + ready() .then(() => nodejs.channel.post("map-server::get-state")) .catch(() => {}); @@ -322,32 +282,53 @@ function createMapServerApi() { }: { accessToken?: string } & ( | { from: "url"; url: string } | { from: "style"; id?: string; style: StyleJSON } - )): Promise<{ id: string; style: StyleJSON }> => - (await guaranteeClient().post("styles", params)) as { + )): Promise<{ id: string; style: StyleJSON }> => { + const client = await getClient(); + return client.post("styles", params) as Promise<{ id: string; style: StyleJSON; - }, + }>; + }, // Delete a map style - deleteStyle: async (id: string): Promise => - (await guaranteeClient().del(`styles/${id}`)) as void, + deleteStyle: async (id: string) => { + const client = await getClient(); + return client.del(`styles/${id}`) as Promise; + }, // Get a map style in the form of a style definition - getStyle: async (id: string): Promise => - (await guaranteeClient().get(`styles/${id}`)) as StyleJSON, - // Get a list of all existing styles containing scalar information about each style - getStyleList: async (): Promise => - (await guaranteeClient().get("styles")) as MapServerStyle[], + getStyle: async (id: string) => { + const client = await getClient(); + return client.get(`styles/${id}`) as Promise; + }, + // Get a list of all styles and basic info about each one + getStyleList: async () => { + const client = await getClient(); + return client.get("styles") as Promise; + }, // Create a tileset using an existing MBTiles file - importTileset: async ( - filePath: string - ): Promise => - (await guaranteeClient().post("tilesets/import", { + importTileset: async (filePath: string) => { + const client = await getClient(); + return client.post("tilesets/import", { filePath: convertFileUriToPosixPath(filePath), - })) as TileJSON & { id: string }, + }) as Promise< + TileJSON & { import: { id: string }; style: { id: string } | null } + >; + }, // Return the url to a map style from the map server - getStyleUrl: (id: string): string | undefined => - mapServerPort ? `${getBaseUrl(mapServerPort)}styles/${id}` : undefined, - getTileset: async (id: string): Promise => - (await guaranteeClient().get(`tilesets/${id}`)) as TileJSON, + getStyleUrl: async (id: string) => + `${getBaseUrl((await getClient()).port)}styles/${id}`, + // Return the url to subscribe to an import's progress via server-sent events + getImportProgressUrl: async (id: string) => + `${getBaseUrl((await getClient()).port)}imports/progress/${id}`, + // Get the import info for a particular import + getImport: async (id: string) => { + const client = await getClient(); + return client.get(`imports/${id}`) as Promise; + }, + // Get the tilejson representation of a tileset + getTileset: async (id: string) => { + const client = await getClient(); + return client.get(`tilesets/${id}`) as Promise; + }, }; return mapsApi; @@ -826,3 +807,8 @@ function convertFileUriToPosixPath(fileUri: unknown) { throw new Error("Attempted to convert invalid file Uri:" + fileUri); return fileUri.replace(/^file:\/\//, ""); } + +export function extractHttpErrorResponse(err: unknown) { + if (!(err instanceof ky.HTTPError)) return; + return err?.response; +} diff --git a/src/frontend/context/AppProvider.js b/src/frontend/context/AppProvider.tsx similarity index 82% rename from src/frontend/context/AppProvider.js rename to src/frontend/context/AppProvider.tsx index 3e1d2acb4..248cbd706 100644 --- a/src/frontend/context/AppProvider.js +++ b/src/frontend/context/AppProvider.tsx @@ -6,18 +6,18 @@ import { ConfigProvider } from "./ConfigContext"; import { SettingsProvider } from "./SettingsContext"; import { DraftObservationProvider } from "./DraftObservationContext"; import { SecurityProvider } from "./SecurityContext"; -import { MapStyleProvider } from "./MapStyleContext"; +import { MapImportsProvider } from "./MapImportsContext"; // This is a convenience wrapper for providing all App contexts to the tree, // apart from the Permissions Provider which is needed separately. -const AppProvider = ({ children }: { children: React.Node }) => ( +const AppProvider = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} diff --git a/src/frontend/context/MapImportsContext.tsx b/src/frontend/context/MapImportsContext.tsx new file mode 100644 index 000000000..cbca650f5 --- /dev/null +++ b/src/frontend/context/MapImportsContext.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import { MapServerImport, MapServerStyleInfo } from "../sharedTypes"; + +type MapImportsState = Record; + +type AddAction = { + type: "add"; + styleId: string; + importId: string; +}; +type RemoveAction = { type: "remove"; styleId: string }; + +type MapImportsAction = AddAction | RemoveAction; + +function reducer( + state: MapImportsState, + action: MapImportsAction +): Readonly { + switch (action.type) { + case "add": { + return { + ...state, + [action.styleId]: action.importId, + }; + } + case "remove": { + const { [action.styleId]: omitted, ...newState } = state; + return newState; + } + } +} + +const StateContext = React.createContext({}); + +const ActionsContext = React.createContext>( + (_action: MapImportsAction) => {} +); + +const MapImportsProvider = ({ children }: React.PropsWithChildren<{}>) => { + const [state, dispatch] = React.useReducer(reducer, {}); + + return ( + + + {children} + + + ); +}; + +export { + StateContext as MapImportsStateContext, + ActionsContext as MapImportsActionsContext, + MapImportsProvider, +}; diff --git a/src/frontend/context/MapStyleContext.tsx b/src/frontend/context/MapStyleContext.tsx deleted file mode 100644 index c43fbd5ee..000000000 --- a/src/frontend/context/MapStyleContext.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from "react"; -import ky from "ky"; -import MapboxGL from "@react-native-mapbox-gl/maps"; -import createPersistedState from "../hooks/usePersistedState"; -import api, { MapServerState, Subscription } from "../api"; -import { useExperiments } from "../hooks/useExperiments"; -import { normalizeStyleURL } from "../lib/mapbox"; -import config from "../../config.json"; - -/** Key used to store most recent map id in Async Storage */ -const MAP_STYLE_KEY = "@MAPSTYLE"; - -/** URL used for map style when no custom map and user is online */ -export const onlineStyleURL = MapboxGL.StyleURL.Outdoors + "?" + Date.now(); - -/** URL used for map style when user is not online - * generated by [mapeo-offline-map](https://github.com/digidem/mapeo-offline-map) */ -export const fallbackStyleURL = "asset://offline-style.json"; - -export type OnlineState = "unknown" | "online" | "offline"; - -export type MapTypes = - | "loading" - | "mapServer" - | "custom" - | "online" - | "fallback"; - -export type MapStyleContextType = { - styleId: string; - setStyleId: React.Dispatch>; - mapServerReady: boolean; - onlineMapState: OnlineState; -}; - -const defaultMapStyleContext: MapStyleContextType = { - styleId: "", - setStyleId: () => {}, - mapServerReady: false, - onlineMapState: "unknown", -}; - -export const MapStyleContext: React.Context = React.createContext< - MapStyleContextType ->(defaultMapStyleContext); - -const usePersistedState = createPersistedState(MAP_STYLE_KEY); - -export const MapStyleProvider: React.FC = ({ children }) => { - const [mapServerReady, setMapServerReady] = React.useState(false); - const [styleId, status, setStyleId] = usePersistedState(""); - const [{ backgroundMaps }] = useExperiments(); - - const [onlineMapState, setOnlineState] = React.useState( - "unknown" - ); - - // TODO: Eventually use the net info module to determine if internet access is available - // Currently not truly a reflection of internet access - React.useEffect(() => { - let didCancel = false; - - ky.get(normalizeStyleURL(onlineStyleURL, config.mapboxAccessToken)) - .json() - .then(() => didCancel || setOnlineState("online")) - .catch(() => didCancel || setOnlineState("offline")); - - return () => { - didCancel = true; - }; - }, []); - - React.useEffect(() => { - let subscription: Subscription | undefined; - - if (backgroundMaps) { - subscription = api.maps.addServerStateListener(({ value }) => { - setMapServerReady(prev => { - const next = value === "started"; - return prev === next ? prev : next; - }); - }); - } - - return () => subscription?.remove(); - }, [backgroundMaps]); - - const contextValue = React.useMemo( - () => ({ styleId, setStyleId, mapServerReady, onlineMapState }), - [styleId, setStyleId, mapServerReady, onlineMapState] - ); - - return ( - - {status === "loading" ? null : children} - - ); -}; diff --git a/src/frontend/context/SecurityContext.tsx b/src/frontend/context/SecurityContext.tsx index 377d74545..903e6443c 100644 --- a/src/frontend/context/SecurityContext.tsx +++ b/src/frontend/context/SecurityContext.tsx @@ -1,9 +1,11 @@ import * as React from "react"; -import { AppState, AppStateStatus } from "react-native"; +import { AppState, AppStateStatus, NativeModules } from "react-native"; import { OBSCURE_KEY, OBSCURE_PASSCODE, PASSWORD_KEY } from "../constants"; import createPersistedState from "../hooks/usePersistedState"; +const { FlagSecureModule } = NativeModules; + type AuthState = "unauthenticated" | "authenticated" | "obscured"; type AuthSetters = @@ -113,8 +115,12 @@ const SecurityProviderInner = ({ if (passcodeValue === null) { setAuthState("authenticated"); setObscureCode(null); + setPasscode(passcodeValue); + FlagSecureModule.deactivate(); + return; } + FlagSecureModule.activate(); setPasscode(passcodeValue); }, [obscureCode] diff --git a/src/frontend/context/SettingsContext.tsx b/src/frontend/context/SettingsContext.tsx index 62e5cd311..301b2c85e 100644 --- a/src/frontend/context/SettingsContext.tsx +++ b/src/frontend/context/SettingsContext.tsx @@ -3,6 +3,7 @@ import merge from "lodash/merge"; import Bugsnag from "@bugsnag/react-native"; import createPersistedState from "../hooks/usePersistedState"; +import { CUSTOM_MAP_ID } from "../hooks/useMapStyles"; // Increment if the shape of settings changes, but try to avoid doing this // because it will reset everybody's settings back to the defaults = bad :( It is @@ -21,6 +22,7 @@ export type SettingsState = { directionalArrow: boolean; backgroundMaps: boolean; }; + mapStyleId: string; }; type SettingsContextType = [ @@ -35,6 +37,9 @@ const DEFAULT_SETTINGS: SettingsState = { directionalArrow: false, backgroundMaps: false, }, + // Default to this, if it doesn't exist then the useSelectedMapStyle hook will + // default to the default online map + mapStyleId: CUSTOM_MAP_ID, }; const SettingsContext = React.createContext([ @@ -57,9 +62,7 @@ export const SettingsProvider = ({ children }: React.PropsWithChildren<{}>) => { const contextValue: SettingsContextType = React.useMemo(() => { // If we add any new properties to the settings state, they will be // undefined in a users' persisted state, so we merge in the defaults - const mergedState = merge({}, DEFAULT_SETTINGS, state, { - experiments: { backgroundMaps: false }, - }); + const mergedState = merge({}, DEFAULT_SETTINGS, state); return [mergedState, setSettings]; }, [state, setSettings]); diff --git a/src/frontend/hooks/useDefaultStyleUrl.ts b/src/frontend/hooks/useDefaultStyleUrl.ts deleted file mode 100644 index da5dbb8dc..000000000 --- a/src/frontend/hooks/useDefaultStyleUrl.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from "react"; -import { - fallbackStyleURL, - MapStyleContext, - onlineStyleURL, -} from "../context/MapStyleContext"; - -export const useDefaultStyleUrl = () => { - const { onlineMapState } = React.useContext(MapStyleContext); - - return onlineMapState === "online" ? onlineStyleURL : fallbackStyleURL; -}; diff --git a/src/frontend/hooks/useMapAvailability.ts b/src/frontend/hooks/useMapAvailability.ts new file mode 100644 index 000000000..5d33cafb2 --- /dev/null +++ b/src/frontend/hooks/useMapAvailability.ts @@ -0,0 +1,57 @@ +import * as React from "react"; +import ky from "ky"; +import MapboxGL from "@react-native-mapbox-gl/maps"; + +import api from "../api"; +import { normalizeStyleURL } from "../lib/mapbox"; +import config from "../../config.json"; + +/** URL used for map style when no custom map and user is online */ +export const onlineStyleURL = normalizeStyleURL( + MapboxGL.StyleURL.Outdoors + "?" + Date.now(), + config.mapboxAccessToken +); + +export type MapAvailability = "unknown" | "available" | "unavailable"; + +/** + * Returns the current availability of either the default online map (e.g. is + * the user online) or a custom map (e.g. has the user added a legacy custom + * map). Re-checks on remount. + */ +export function useMapAvailability(mapType: "online" | "custom") { + const [mapAvailability, setMapAvailability] = React.useState( + "unknown" + ); + + // TODO: either poll for connectivity changes, or detect network status (often done by polling anyway) + React.useEffect(() => { + if (mapType !== "online") return; + let didCancel = false; + + // TODO: HEAD request on this should be quicker and give us what we need. + ky.get(onlineStyleURL) + .json() + .then(() => didCancel || setMapAvailability("available")) + .catch(() => didCancel || setMapAvailability("unavailable")); + + return () => { + didCancel = true; + }; + }, [mapType]); + + React.useEffect(() => { + if (mapType !== "custom") return; + let didCancel = false; + + api + .getMapStyle("default") + .then(() => didCancel || setMapAvailability("available")) + .catch(() => didCancel || setMapAvailability("unavailable")); + return () => { + didCancel = true; + }; + }, [mapType]); + + return mapAvailability; +} diff --git a/src/frontend/hooks/useMapImportProgress.ts b/src/frontend/hooks/useMapImportProgress.ts new file mode 100644 index 000000000..7966f4162 --- /dev/null +++ b/src/frontend/hooks/useMapImportProgress.ts @@ -0,0 +1,143 @@ +import { useEffect, useState, useContext } from "react"; +import { + fetchEventSource, + EventStreamContentType, +} from "@microsoft/fetch-event-source"; +// TODO: Ideally create module defs for this +// @ts-expect-error +import { fetch } from "react-native-fetch-api"; +import { + MapImportsStateContext, + MapImportsActionsContext, +} from "../context/MapImportsContext"; +import api from "../api"; + +// TODO: Export from map-server +type MessageProgress = { + type: "progress"; + importId: string; + soFar: number; + total: number; +}; + +type MessageComplete = { + type: "complete"; + importId: string; + soFar: number; + total: number; +}; + +type MessageError = { + type: "error"; + importId: string; + soFar: number; + total: number; +}; + +type ImportProgressMessage = MessageProgress | MessageComplete | MessageError; + +export type MapImportState = + | { status: "idle" } + | { status: "error" } + | { status: "progress"; progress: number } + | { status: "complete"; progress: number } + | undefined; + +class RetriableError extends Error {} +class FatalError extends Error {} + +export function useMapImportProgress(styleId: string): MapImportState { + const [state, setState] = useState({ status: "idle" }); + const activeImports = useContext(MapImportsStateContext); + const dispatch = useContext(MapImportsActionsContext); + const importId = activeImports[styleId]; + + useEffect(() => { + if (!importId) { + // If we are not tracking an import for this style, remove it from our map imports state + dispatch({ type: "remove", styleId: styleId }); + return; + } + + const controller = new AbortController(); + + (async () => { + try { + const url = await api.maps.getImportProgressUrl(importId); + if (controller.signal.aborted) return; + + await fetchEventSource(url, { + onopen: validateResponse, + onmessage(ev) { + try { + const msg = JSON.parse(ev.data) as ImportProgressMessage; + setState(progressMsgToState(msg)); + if (msg.type === "complete") { + // If import is complete, remove it from tracked import state + dispatch({ type: "remove", styleId }); + } + } catch (e) { + throw new FatalError("Unable to parse msg " + ev.data); + } + }, + onerror(err) { + if (err instanceof FatalError) throw err; + // If not thrown, then fetch-event-source will retry + }, + fetch: wrappedFetch, + signal: controller.signal, + }); + } catch (err) { + setState(state => { + return { status: "error" }; + }); + console.error("FETCH EVENT SOURCE ERROR", err); + } + })(); + + return () => { + controller.abort(); + }; + }, [importId, styleId]); + + if (!importId) return; + return state; +} + +/** + * Wrap fetch to accommodate react-native's implementation of fetch + */ +function wrappedFetch(input: RequestInfo | URL, opts: RequestInit | undefined) { + return fetch(input, { ...opts, reactNative: { textStreaming: true } }); +} + +/** + * Validate a fetch response, throws RetriableError if retryable, otherwise + * FatalError + */ +async function validateResponse(response: Response) { + if ( + response.ok && + response.headers.get("content-type") === EventStreamContentType + ) { + return; // everything's good + } else if ( + response.status >= 400 && + response.status < 500 && + response.status !== 429 + ) { + throw new FatalError(); + } else { + throw new RetriableError(); + } +} + +/** + * Convert a progress message received via SSE to import progress state + */ +function progressMsgToState(msg: ImportProgressMessage): MapImportState { + return { + status: msg.type, + progress: msg.soFar / msg.total, + }; +} diff --git a/src/frontend/hooks/useMapImports.ts b/src/frontend/hooks/useMapImports.ts new file mode 100644 index 000000000..b45cc7c36 --- /dev/null +++ b/src/frontend/hooks/useMapImports.ts @@ -0,0 +1,23 @@ +import { useContext, useMemo } from "react"; +import { + MapImportsStateContext, + MapImportsActionsContext, +} from "../context/MapImportsContext"; + +export function useMapImports() { + const activeImports = useContext(MapImportsStateContext); + return activeImports; +} + +export function useMapImportsManager() { + const dispatch = useContext(MapImportsActionsContext); + + // Memoize in case this context gets put inside another context that changes a lot. + return useMemo(() => { + return { + add: ({ styleId, importId }: { styleId: string; importId: string }) => + dispatch({ type: "add", styleId, importId }), + remove: (styleId: string) => dispatch({ type: "remove", styleId }), + }; + }, [dispatch]); +} diff --git a/src/frontend/hooks/useMapServerState.ts b/src/frontend/hooks/useMapServerState.ts deleted file mode 100644 index 87658583c..000000000 --- a/src/frontend/hooks/useMapServerState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from "react"; -import { MapStyleContext } from "../context/MapStyleContext"; - -export const useMapServerState = () => { - const { mapServerReady } = React.useContext(MapStyleContext); - - return React.useMemo(() => mapServerReady, [mapServerReady]); -}; diff --git a/src/frontend/hooks/useMapStyle.ts b/src/frontend/hooks/useMapStyle.ts deleted file mode 100644 index b3a0bb18a..000000000 --- a/src/frontend/hooks/useMapStyle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from "react"; - -import api from "../api"; -import { useExperiments } from "./useExperiments"; - -import { MapStyleContext, MapTypes } from "../context/MapStyleContext"; -import { DEFAULT_MAP_ID } from "../screens/Settings/MapSettings/BackgroundMaps"; -import { useDefaultStyleUrl } from "./useDefaultStyleUrl"; - -type LegacyCustomMapState = "unknown" | "unavailable" | "available"; -type MapStyleState = { - styleUrl: null | string; - styleType: MapTypes; - setStyleId: - | React.Dispatch> - | ((id: string) => never); - styleId?: string; -}; - -function useLegacyStyle(defaultMap: string): MapStyleState { - const [customMapState, setCustomMapState] = React.useState< - LegacyCustomMapState - >("unknown"); - - React.useEffect(() => { - let didCancel = false; - - api - .getMapStyle("default") - .then(() => didCancel || setCustomMapState("available")) - .catch(() => didCancel || setCustomMapState("unavailable")); - - return () => { - didCancel = true; - }; - }, []); - - return React.useMemo(() => { - const setStyleId = (id: string) => { - throw new Error("Cannot set styleId on legacy map"); - }; - - if (customMapState === "unknown") { - return { styleType: "loading", styleUrl: null, setStyleId }; - } - - if (customMapState === "available") { - return { - styleType: "custom", - styleUrl: api.getMapStyleUrl("default"), - setStyleId, - }; - } - - return { styleType: "fallback", styleUrl: defaultMap, setStyleId }; - }, [defaultMap, customMapState]); -} - -function useMapServerStyle(defaultStyleUrl: string): MapStyleState { - const { mapServerReady, setStyleId, styleId } = React.useContext( - MapStyleContext - ); - - return React.useMemo(() => { - if (!mapServerReady) { - return { - styleType: "loading", - styleUrl: null, - setStyleId, - }; - } - - if (styleId === DEFAULT_MAP_ID || !styleId) { - return { - styleType: "mapServer", - styleUrl: defaultStyleUrl, - setStyleId, - styleId, - }; - } - - return { - styleType: "mapServer", - styleUrl: api.maps.getStyleUrl(styleId) || null, - setStyleId, - styleId, - }; - }, [styleId, setStyleId, mapServerReady, defaultStyleUrl]); -} - -export function useMapStyle(): MapStyleState { - const [{ backgroundMaps }] = useExperiments(); - const defaultStyleUrl = useDefaultStyleUrl(); - const legacyStyleInfo = useLegacyStyle(defaultStyleUrl); - const mapServerInfo = useMapServerStyle(defaultStyleUrl); - - return backgroundMaps ? mapServerInfo : legacyStyleInfo; -} diff --git a/src/frontend/hooks/useMapStyles.ts b/src/frontend/hooks/useMapStyles.ts new file mode 100644 index 000000000..bef5bb527 --- /dev/null +++ b/src/frontend/hooks/useMapStyles.ts @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useFocusEffect } from "@react-navigation/native"; +import { defineMessages, useIntl } from "react-intl"; + +import api from "../api"; +import SettingsContext from "../context/SettingsContext"; +import { MapServerStyleInfo } from "../sharedTypes"; +import { useExperiments } from "./useExperiments"; +import { useMapAvailability, onlineStyleURL } from "./useMapAvailability"; +import { useMapImports } from "./useMapImports"; + +// Randomly generated, but should not change, since this is stored in settings +// if the user selects one of these "legacy" map styles +export const DEFAULT_MAP_ID = "487x2pc8ws801avhs5hw58qnxc" as const; +export const CUSTOM_MAP_ID = "vg4ft8yvzwfedzgz1dz7ntneb8" as const; + +interface StyleInfoWithImport extends MapServerStyleInfo { + isImporting: boolean; +} + +type Status = "loading" | "error" | "success"; +type StylesAtLeastOne = [StyleInfoWithImport, ...StyleInfoWithImport[]]; + +const m = defineMessages({ + defaultBackgroundMapName: { + id: "hooks.useMapStyles.defaultMapName", + defaultMessage: "Default", + description: "The name of the default background map", + }, + offlineBackgroundMapName: { + id: "hooks.useMapStyles.offlineMapName", + defaultMessage: "Offline Map", + description: "The name of the legacy offline background map", + }, +}); + +/** + * Returns list of available map styles. If the backgroundMaps experiment is not + * enabled then this is always a single item - either the default online map or + * the custom map is available + */ +export function useMapStyles() { + const { formatMessage: t } = useIntl(); + const activeImports = useMapImports(); + const customStyleAvailability = useMapAvailability("custom"); + const [{ backgroundMaps }] = useExperiments(); + const [settings, setSettings] = React.useContext(SettingsContext); + const [stylesList, setStylesList] = React.useState([]); + // There is nothing to load if background maps experiment is not enabled + const [status, setStatus] = React.useState( + backgroundMaps ? "loading" : "success" + ); + + useFocusEffect( + React.useCallback(() => { + if (!backgroundMaps) return; + let didCancel = false; + api.maps + .getStyleList() + .then(styles => { + if (didCancel) return; + const stylesWithImportState = styles.map(s => { + // Append importing state so we can filter out importing maps if needed + return { ...s, isImporting: !!activeImports[s.id] }; + }); + setStylesList(stylesWithImportState); + setStatus("success"); + }) + .catch(() => didCancel || setStatus("error")); + + return () => { + didCancel = true; + }; + }, [activeImports, backgroundMaps]) + ); + + const defaultStyle = { + id: DEFAULT_MAP_ID, + url: onlineStyleURL, + bytesStored: 0, + name: t(m.defaultBackgroundMapName), + isImporting: false, + }; + + const customStyle = { + id: CUSTOM_MAP_ID, + url: api.getMapStyleUrl("default"), + bytesStored: 0, + name: t(m.offlineBackgroundMapName), + isImporting: false, + }; + + const mergedStatus: Status = + customStyleAvailability === "unknown" || status === "loading" + ? "loading" + : status; + + let styles: StylesAtLeastOne; + + if (backgroundMaps) { + // Always show the online default style in MapServer. This is replaced with + // fallback in the useMapStyle hook + styles = + customStyleAvailability === "available" + ? [defaultStyle, customStyle, ...stylesList] + : [defaultStyle, ...stylesList]; + } else { + if (customStyleAvailability === "available") { + styles = [customStyle]; + } else { + styles = [defaultStyle]; + } + } + + const setSelectedStyleId = React.useCallback( + (styleId: string) => { + setSettings("mapStyleId", styleId); + }, + [setSettings] + ); + + let selectedStyleId = settings.mapStyleId; + // If the user selected style is not available, default to the first in the + // styles list. + if (!styles.find(style => style.id === selectedStyleId)) { + selectedStyleId = styles[0].id; + } + + return { + styles, + status: mergedStatus, + selectedStyleId, + setSelectedStyleId, + }; +} diff --git a/src/frontend/hooks/useSelectedMapStyle.ts b/src/frontend/hooks/useSelectedMapStyle.ts new file mode 100644 index 000000000..5216cd2e5 --- /dev/null +++ b/src/frontend/hooks/useSelectedMapStyle.ts @@ -0,0 +1,63 @@ +import { useMapAvailability } from "./useMapAvailability"; +import { useMapStyles, DEFAULT_MAP_ID } from "./useMapStyles"; + +// This map style is used if the user is offline and there is no custom offline +// map installed. It contains basic land, country, river and lake data and is +// generated by https://github.com/digidem/mapeo-offline-map +const FALLBACK_STYLE_URL = "asset://offline-style.json" as const; + +type SelectedMapStyle = + | { + status: "loading"; + } + | { + styleUrl: string; + status: "success"; + isOfflineFallback: false; + } + | { + styleUrl: typeof FALLBACK_STYLE_URL; + status: "success"; + isOfflineFallback: true; + }; + +/** + * Get the user selected map style. + * + * @returns The `styleUrl` and `status`, which is loading while map style + * availability is being checked. If there are no offline maps available and the + * user is offline, then `styleUrl` will be `undefined`. + */ +export function useSelectedMapStyle(): SelectedMapStyle { + const { styles, status, selectedStyleId } = useMapStyles(); + const onlineMapAvailability = useMapAvailability("online"); + + if (status === "loading" || onlineMapAvailability === "unknown") { + return { status: "loading" }; + } + + // We have a check in useMapStyles to ensure that selectedStyleId is in the + // styles array, but this is to make TS happy + const selectedStyle = + styles.find(style => style.id === selectedStyleId) || styles[0]; + + if ( + onlineMapAvailability === "unavailable" && + selectedStyle.id === DEFAULT_MAP_ID + ) { + // User is offline, but we have selected default (online) style, then use + // the fallback offline style (this requires offline layers to be added to + // the map view manually, so we need to treat this case specially) + return { + styleUrl: FALLBACK_STYLE_URL, + status: "success", + isOfflineFallback: true, + }; + } + + return { + styleUrl: selectedStyle.url, + status: "success", + isOfflineFallback: false, + }; +} diff --git a/src/frontend/polyfills/fetch.ts b/src/frontend/polyfills/fetch.ts new file mode 100644 index 000000000..b274e2074 --- /dev/null +++ b/src/frontend/polyfills/fetch.ts @@ -0,0 +1,34 @@ +// @ts-ignore +import { polyfillGlobal } from "react-native/Libraries/Utilities/PolyfillFunctions"; + +// Polyfill TextEncoder and TextDecoder +require("fast-text-encoding"); + +// Polyfill ReadableStream +const { ReadableStream } = require("web-streams-polyfill/ponyfill/es6"); + +polyfillGlobal("ReadableStream", () => ReadableStream); + +const { fetch, Headers, Request, Response } = require("react-native-fetch-api"); + +// Polyfill fetch +polyfillGlobal("fetch", () => fetch); +polyfillGlobal("Headers", () => Headers); +polyfillGlobal("Request", () => Request); +polyfillGlobal("Response", () => Response); + +// Polyfill window object (needed for @microsoft/fetch-event-source) +polyfillGlobal("window", () => ({ + ...(global.window || {}), + setTimeout, + clearTimeout, + fetch, +})); + +// Polyfill document object (needed for @microsoft/fetch-event-source) +polyfillGlobal("document", () => ({ + ...(global.document || {}), + hidden: false, + addEventListener: () => {}, + removeEventListener: () => {}, +})); diff --git a/src/frontend/polyfills/index.ts b/src/frontend/polyfills/index.ts index 71fddbb9b..4ed63f3a7 100644 --- a/src/frontend/polyfills/index.ts +++ b/src/frontend/polyfills/index.ts @@ -1 +1,2 @@ +import("./fetch"); import("./intl-relativetimeformat"); diff --git a/src/frontend/screens/AppPasscode/EnterPassToTurnOff.tsx b/src/frontend/screens/AppPasscode/EnterPassToTurnOff.tsx index 8094dfedb..998eeb9f8 100644 --- a/src/frontend/screens/AppPasscode/EnterPassToTurnOff.tsx +++ b/src/frontend/screens/AppPasscode/EnterPassToTurnOff.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { defineMessages } from "react-intl"; -import { PasscodeScreens } from "."; + import { SecurityContext } from "../../context/SecurityContext"; +import { NativeNavigationComponent } from "../../sharedTypes"; import { InputPasscode } from "./InputPasscode"; const m = defineMessages({ @@ -17,31 +18,32 @@ const m = defineMessages({ id: "screens.AppPasscode.NewPasscode.InputPasscodeScreen.passwordError", defaultMessage: "Incorrect Passcode", }, + title: { + id: "screens.AppPasscode.NewPasscode.InputPasscodeScreen.title", + defaultMessage: "Confirm Passcode", + }, }); -export const EnterPassToTurnOff = ({ - setScreenState, -}: { - setScreenState: React.Dispatch>; +export const EnterPassToTurnOff: NativeNavigationComponent<"EnterPassToTurnOff"> = ({ + navigation, }) => { - const { authenticate } = React.useContext(SecurityContext); + const { authenticate, authValuesSet } = React.useContext(SecurityContext); const [error, setError] = React.useState(false); - const [pass, setPass] = React.useState(""); + const { navigate } = navigation; - function validate() { - if (!authenticate(pass, true)) { - setError(true); - return; + // Stops user from accessing this page if no password is set + React.useLayoutEffect(() => { + if (!authValuesSet.passcodeSet) { + navigate("Security"); } - setScreenState("disablePasscode"); - } + }, [navigate, authValuesSet]); - function setPassWithValidation(passValue: string) { - if (error && passValue.length > 0) { - setError(false); + function validate(passcode: string) { + if (!authenticate(passcode, true)) { + setError(true); + return; } - - setPass(passValue); + navigate("DisablePasscode"); } return ( @@ -53,8 +55,10 @@ export const EnterPassToTurnOff = ({ }} error={error} validate={validate} - inputValue={pass} - setInputValue={setPassWithValidation} + showNext={false} + hideError={() => setError(false)} /> ); }; + +EnterPassToTurnOff.navTitle = m.title; diff --git a/src/frontend/screens/AppPasscode/InputPasscode.tsx b/src/frontend/screens/AppPasscode/InputPasscode.tsx index 4396ab22d..fb798f45c 100644 --- a/src/frontend/screens/AppPasscode/InputPasscode.tsx +++ b/src/frontend/screens/AppPasscode/InputPasscode.tsx @@ -6,6 +6,7 @@ import { } from "react-intl"; import { View, StyleSheet, Text, ScrollView } from "react-native"; import { useBlurOnFulfill } from "react-native-confirmation-code-field"; + import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; import { WHITE, RED, MAPEO_BLUE } from "../../lib/styles"; import Button from "../../sharedComponents/Button"; @@ -30,11 +31,11 @@ interface InputPasscodeProps { subtitle: MessageDescriptor; errorMessage: MessageDescriptor; }; - validate: () => void; + validate: (pass: string) => void; showPasscodeValues?: boolean; error: boolean; - inputValue: string; - setInputValue: (val: string) => void; + hideError: () => void; + showNext?: boolean; } export const InputPasscode = ({ @@ -42,9 +43,11 @@ export const InputPasscode = ({ text, showPasscodeValues, error, - inputValue, - setInputValue, + hideError, + showNext = true, }: InputPasscodeProps) => { + const [inputValue, setInputValue] = React.useState(""); + const inputRef = useBlurOnFulfill({ value: inputValue, cellCount: CELL_COUNT, @@ -52,7 +55,13 @@ export const InputPasscode = ({ if (error) inputRef.current?.focus(); - const { goBack } = useNavigationFromRoot(); + function updateInput(newVal: string) { + if (error) hideError(); + setInputValue(newVal); + if (!showNext && newVal.length === 5) validate(newVal); + } + + const { navigate } = useNavigationFromRoot(); return ( @@ -68,7 +77,7 @@ export const InputPasscode = ({ error={error} ref={inputRef} inputValue={inputValue} - onChangeTextWithValidation={setInputValue} + onChangeTextWithValidation={updateInput} maskValues={!showPasscodeValues} /> @@ -84,18 +93,27 @@ export const InputPasscode = ({ fullWidth variant="outlined" style={{ marginBottom: 20, marginTop: 20 }} - onPress={goBack} + onPress={() => { + navigate("Security"); + }} > - + {showNext && ( + + )} diff --git a/src/frontend/screens/AppPasscode/PasscodeIntro.tsx b/src/frontend/screens/AppPasscode/PasscodeIntro.tsx index c844677e6..cd7b0b47c 100644 --- a/src/frontend/screens/AppPasscode/PasscodeIntro.tsx +++ b/src/frontend/screens/AppPasscode/PasscodeIntro.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { defineMessages, useIntl } from "react-intl"; import { StyleSheet, Text, View, ScrollView } from "react-native"; -import { PasscodeScreens } from "."; +import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; import Button from "../../sharedComponents/Button"; const m = defineMessages({ @@ -26,12 +26,9 @@ const m = defineMessages({ }, }); -interface PasscodeIntroProps { - setScreen: React.Dispatch>; -} - -export const PasscodeIntro = ({ setScreen }: PasscodeIntroProps) => { +export const PasscodeIntro = () => { const { formatMessage: t } = useIntl(); + const { navigate } = useNavigationFromRoot(); return ( @@ -46,7 +43,7 @@ export const PasscodeIntro = ({ setScreen }: PasscodeIntroProps) => { diff --git a/src/frontend/screens/AppPasscode/SetPasscode.tsx b/src/frontend/screens/AppPasscode/SetPasscode.tsx index 944ce620f..37b82d68f 100644 --- a/src/frontend/screens/AppPasscode/SetPasscode.tsx +++ b/src/frontend/screens/AppPasscode/SetPasscode.tsx @@ -1,10 +1,9 @@ import * as React from "react"; -import { PasscodeScreens } from "."; import { InputPasscode } from "./InputPasscode"; import { defineMessages } from "react-intl"; import { OBSCURE_PASSCODE } from "../../constants"; import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; -import { SecurityContext } from "../../context/SecurityContext"; +import { NativeNavigationComponent } from "../../sharedTypes"; const m = defineMessages({ titleSet: { @@ -33,6 +32,10 @@ const m = defineMessages({ "screens.AppPasscode.NewPasscode.InputPasscodeScreen.obscurePasscodeError", defaultMessage: "Cannot be used as a Passcode", }, + title: { + id: "screens.AppPasscode.NewPasscode.InputPasscodeScreen.title", + defaultMessage: "Set Passcode", + }, }); type SetPasswordError = @@ -42,11 +45,7 @@ type SetPasswordError = } | { error: false; isObscurePassword: false }; -export const SetPassword = ({ - setScreen, -}: { - setScreen: React.Dispatch>; -}) => { +export const SetPasscode: NativeNavigationComponent<"SetPasscode"> = () => { const [error, setError] = React.useState({ error: false, isObscurePassword: false, @@ -54,25 +53,23 @@ export const SetPassword = ({ const [initialPass, setInitialPass] = React.useState(""); const [isConfirming, setIsConfirming] = React.useState(false); - function setInitialPassWithErrorCheck(password: string) { - if (error.error && password.length > 0) - setError({ error: false, isObscurePassword: false }); - - setInitialPass(password); + function hideError() { + setError({ error: false, isObscurePassword: false }); } - function validate() { - if (initialPass === OBSCURE_PASSCODE) { + function validate(inputVal: string) { + if (inputVal === OBSCURE_PASSCODE) { setError({ error: true, isObscurePassword: true }); setInitialPass(""); return; } - if (initialPass.length < 5) { + if (inputVal.length < 5) { setError({ error: true, isObscurePassword: false }); return; } + setInitialPass(inputVal); setIsConfirming(true); } @@ -89,8 +86,7 @@ export const SetPassword = ({ validate={validate} error={error.error} showPasscodeValues={true} - inputValue={initialPass} - setInputValue={setInitialPassWithErrorCheck} + hideError={hideError} /> ); } @@ -98,21 +94,15 @@ export const SetPassword = ({ return ; }; +SetPasscode.navTitle = m.title; + const SetPasswordConfirm = ({ initialPass }: { initialPass: string }) => { const [error, setError] = React.useState(false); const { navigate } = useNavigationFromRoot(); - const { setAuthValues } = React.useContext(SecurityContext); - - const [confirmPass, setConfirmPass] = React.useState(""); - function confirmPassWithCheck(password: string) { - if (error && password.length > 0) setError(false); - setConfirmPass(password); - } - - function validate() { - if (confirmPass === initialPass) { - navigate("ConfirmPasscodeSheet", { passcode: confirmPass }); + function validate(inputVal: string) { + if (inputVal === initialPass) { + navigate("ConfirmPasscodeSheet", { passcode: inputVal }); return; } @@ -129,8 +119,9 @@ const SetPasswordConfirm = ({ initialPass }: { initialPass: string }) => { validate={validate} error={error} showPasscodeValues={true} - inputValue={confirmPass} - setInputValue={confirmPassWithCheck} + hideError={() => { + setError(false); + }} /> ); }; diff --git a/src/frontend/screens/AppPasscode/TurnOffPasscode.tsx b/src/frontend/screens/AppPasscode/TurnOffPasscode.tsx index a70dec7ec..116f97e3b 100644 --- a/src/frontend/screens/AppPasscode/TurnOffPasscode.tsx +++ b/src/frontend/screens/AppPasscode/TurnOffPasscode.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { StyleSheet, Text, View } from "react-native"; +import { BackHandler, StyleSheet, Text, View } from "react-native"; import { defineMessages, FormattedMessage, useIntl } from "react-intl"; import MaterialIcon from "react-native-vector-icons/MaterialIcons"; import BottomSheet, { BottomSheetBackdrop } from "@gorhom/bottom-sheet"; @@ -13,11 +13,13 @@ import { ListItem, ListItemText, } from "../../sharedComponents/List"; -import { MEDIUM_GREY, RED } from "../../lib/styles"; -import { PasscodeScreens } from "."; -import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; +import { MEDIUM_GREY, RED, WHITE } from "../../lib/styles"; import { ErrorIcon } from "../../sharedComponents/icons"; import Button from "../../sharedComponents/Button"; +import { NativeNavigationComponent } from "../../sharedTypes"; +import { useFocusEffect, StackActions } from "@react-navigation/native"; +import CustomHeaderLeft from "../../sharedComponents/CustomHeaderLeft"; +import { HeaderButtonProps } from "@react-navigation/native-stack/lib/typescript/src/types"; const m = defineMessages({ usePasscode: { @@ -50,21 +52,53 @@ const m = defineMessages({ defaultMessage: "You are currently using App Passcode. See below to stop using or change your passcode.", }, + title: { + id: "screens.AppPasscode.TurnOffPasscode.title", + defaultMessage: "App Passcode", + }, }); -interface TurnOffPasscodeProps { - setScreenState: (screen: PasscodeScreens) => void; -} - -export const TurnOffPasscode = ({ setScreenState }: TurnOffPasscodeProps) => { +export const TurnOffPasscode: NativeNavigationComponent<"DisablePasscode"> = ({ + navigation, +}) => { const { authValuesSet, setAuthValues } = React.useContext(SecurityContext); const sheetRef = React.useRef(null); - const { navigate } = useNavigationFromRoot(); + const { navigate } = navigation; const { formatMessage: t } = useIntl(); + // These next three function forces the user to go back to the setting page instead of the "EnterPassToTurnOff" screen + const backPress = React.useCallback(() => { + const popAction = StackActions.pop(2); + navigation.dispatch(popAction); + }, [navigation]); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: (props: HeaderButtonProps) => ( + + ), + }); + }, [backPress, navigation]); + + useFocusEffect( + React.useCallback(() => { + const onBackPress = () => { + backPress(); + return true; + }; + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + onBackPress + ); + + return () => subscription.remove(); + }, [backPress]) + ); + function unsetAppPasscode() { setAuthValues({ type: "passcode", value: null }); navigate("Security"); @@ -75,7 +109,7 @@ export const TurnOffPasscode = ({ setScreenState }: TurnOffPasscodeProps) => { } return ( - + {t(m.description)} {t(m.currentlyUsing)} @@ -104,7 +138,7 @@ export const TurnOffPasscode = ({ setScreenState }: TurnOffPasscodeProps) => { {authValuesSet.passcodeSet && ( { - setScreenState("setPasscode"); + navigate("SetPasscode"); }} style={{ marginTop: 20 }} > @@ -122,18 +156,18 @@ export const TurnOffPasscode = ({ setScreenState }: TurnOffPasscodeProps) => { sheetRef.current?.close(); }} /> - + ); }; -interface ConfirmTurnOffPasswordModal { +interface ConfirmTurnOffPasswordModalProps { turnOffPasscode: () => void; closeSheet: () => void; } const ConfirmTurnOffPasswordModal = React.forwardRef< BottomSheetMethods, - ConfirmTurnOffPasswordModal + ConfirmTurnOffPasswordModalProps >(({ turnOffPasscode, closeSheet }, sheetRef) => { const [snapPoints, setSnapPoints] = React.useState<(number | string)[]>([ 0, @@ -151,6 +185,7 @@ const ConfirmTurnOffPasswordModal = React.forwardRef< enableHandlePanningGesture={false} handleHeight={0} handleComponent={() => null} + index={-1} > { @@ -179,6 +214,8 @@ const ConfirmTurnOffPasswordModal = React.forwardRef< ); }); +TurnOffPasscode.navTitle = m.title; + const styles = StyleSheet.create({ text: { fontSize: 16, @@ -200,4 +237,10 @@ const styles = StyleSheet.create({ fontSize: 16, marginBottom: 20, }, + pageContainer: { + paddingBottom: 20, + paddingHorizontal: 20, + flex: 1, + backgroundColor: WHITE, + }, }); diff --git a/src/frontend/screens/AppPasscode/index.tsx b/src/frontend/screens/AppPasscode/index.tsx index 61bf64d4c..95efeeb5c 100644 --- a/src/frontend/screens/AppPasscode/index.tsx +++ b/src/frontend/screens/AppPasscode/index.tsx @@ -1,15 +1,11 @@ import * as React from "react"; import { defineMessages } from "react-intl"; import { StyleSheet, View } from "react-native"; -import { SecurityContext } from "../../context/SecurityContext"; +import { SecurityContext } from "../../context/SecurityContext"; import { WHITE } from "../../lib/styles"; import { NativeNavigationComponent } from "../../sharedTypes"; -import { EnterPassToTurnOff } from "./EnterPassToTurnOff"; - import { PasscodeIntro } from "./PasscodeIntro"; -import { SetPassword } from "./SetPasscode"; -import { TurnOffPasscode } from "./TurnOffPasscode"; const m = defineMessages({ title: { @@ -18,43 +14,22 @@ const m = defineMessages({ }, }); -export type PasscodeScreens = - | "intro" - | "setPasscode" - | "enterPasscode" - | "disablePasscode"; - export const AppPasscode: NativeNavigationComponent<"AppPasscode"> = ({ navigation, }) => { - const { authValuesSet, authState } = React.useContext(SecurityContext); - const [screenState, setScreenState] = React.useState(() => - authValuesSet.passcodeSet ? "enterPasscode" : "intro" - ); + const { authState } = React.useContext(SecurityContext); - React.useEffect(() => { + React.useLayoutEffect(() => { if (authState === "obscured") { navigation.navigate("Settings"); } }, [navigation, authState]); - const screen = React.useMemo(() => { - if (screenState === "intro") { - return ; - } - - if (screenState === "setPasscode") { - return ; - } - - if (screenState === "enterPasscode") { - return ; - } - - return ; - }, [screenState]); - - return {screen}; + return ( + + + + ); }; AppPasscode.navTitle = m.title; diff --git a/src/frontend/screens/MapScreen/BGMapSelector.tsx b/src/frontend/screens/MapScreen/BGMapSelector.tsx deleted file mode 100644 index a272e181a..000000000 --- a/src/frontend/screens/MapScreen/BGMapSelector.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import * as React from "react"; -import { StyleSheet, View, Text } from "react-native"; -import MapboxGL from "@react-native-mapbox-gl/maps"; -import { defineMessages, useIntl } from "react-intl"; -import BottomSheet, { BottomSheetBackdrop } from "@gorhom/bottom-sheet"; -import { - ScrollView, - TouchableHighlight, - TouchableOpacity, -} from "react-native-gesture-handler"; -import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; - -import Loading from "../../sharedComponents/Loading"; -import { LIGHT_GREY, MEDIUM_BLUE, WHITE } from "../../lib/styles"; -import Button from "../../sharedComponents/Button"; -import LocationContext from "../../context/LocationContext"; -import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; -import { fallbackStyleURL } from "../../context/MapStyleContext"; -import { OfflineMapLayers } from "../../sharedComponents/OfflineMapLayers"; -import { DEFAULT_MAP_ID } from "../Settings/MapSettings/BackgroundMaps"; -import { useMapServerState } from "../../hooks/useMapServerState"; -import { useDefaultStyleUrl } from "../../hooks/useDefaultStyleUrl"; -import { MapServerStyle } from "../../sharedTypes"; - -const m = defineMessages({ - title: { - id: "sharedComponents.BGMapSelector.title", - defaultMessage: "Background Maps", - description: "Title for the background map selector", - }, - close: { - id: "sharedComponents.BGMapSelector.close", - defaultMessage: "Close", - }, - manageMaps: { - id: "sharedComponents.BGMapSelector.manageMaps", - defaultMessage: "Manage Maps", - }, -}); - -interface MapSelectorProps { - /** Should NOT come from `useBottomSheet()` */ - closeSheet: () => void; - onMapSelected: (id: string) => void; - bgMapsList: MapServerStyle[] | null; -} - -/** `ref` should NOT come from - `useBottomSheet()` */ -export const BGMapSelector = React.forwardRef< - BottomSheetMethods, - MapSelectorProps ->(({ closeSheet, onMapSelected, bgMapsList }, ref) => { - const { navigate } = useNavigationFromRoot(); - const mapServerReady = useMapServerState(); - - const defaultStyleUrl = useDefaultStyleUrl(); - - const [snapPoints, setSnapPoints] = React.useState<(number | string)[]>([ - 0, - "40%", - ]); - - const { formatMessage: t } = useIntl(); - - return ( - null} - > - { - const { height } = e.nativeEvent.layout; - setSnapPoints([0, height]); - }} - style={{ padding: 20 }} - > - - {t(m.title)} - - {bgMapsList === null || !mapServerReady ? ( - - - - ) : ( - - { - closeSheet(); - navigate("MapSettings"); - }} - > - - {t(m.manageMaps)} - - - - - {!!defaultStyleUrl && ( - { - onMapSelected(DEFAULT_MAP_ID); - closeSheet(); - }} - id={DEFAULT_MAP_ID} - styleUrl={defaultStyleUrl} - title="Default" - /> - )} - - {bgMapsList.map(({ id, url: styleUrl, name: title }) => ( - { - onMapSelected(id); - closeSheet(); - }} - styleUrl={styleUrl} - title={title} - key={id} - /> - ))} - - - )} - - - - - - ); -}); - -const MapThumbnail = ({ - styleUrl, - id, - title, - onMapSelected, -}: { - styleUrl: string; - title: string | null; - id: string; - onMapSelected: (id: string) => void; -}) => { - const { position } = React.useContext(LocationContext); - - return ( - - onMapSelected(id)} - style={{ width: 80, margin: 10 }} - > - - - {styleUrl === fallbackStyleURL ? : null} - - - {title && ( - - {title} - - )} - - ); -}; - -const styles = StyleSheet.create({ - flexContainer: { - display: "flex", - width: "100%", - height: "auto", - flexDirection: "row", - backgroundColor: WHITE, - paddingBottom: 20, - }, - thumbnail: { - width: 80, - height: 80, - }, - title: { - fontSize: 32, - textAlign: "center", - paddingBottom: 20, - }, - thumbnailTitle: { - textAlign: "center", - fontSize: 16, - maxWidth: 80, - }, -}); diff --git a/src/frontend/screens/MapScreen/BackgroundMapSelector.tsx b/src/frontend/screens/MapScreen/BackgroundMapSelector.tsx new file mode 100644 index 000000000..e1573efe3 --- /dev/null +++ b/src/frontend/screens/MapScreen/BackgroundMapSelector.tsx @@ -0,0 +1,162 @@ +import * as React from "react"; +import { StyleSheet, View, Text, LayoutChangeEvent } from "react-native"; +import { defineMessages, useIntl } from "react-intl"; +import { ScrollView } from "react-native-gesture-handler"; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; + +import Button from "../../sharedComponents/Button"; +import Loading from "../../sharedComponents/Loading"; +import { useMapStyles } from "../../hooks/useMapStyles"; +import { useNavigationFromRoot } from "../../hooks/useNavigationWithTypes"; +import { LIGHT_GREY, MAPEO_BLUE } from "../../lib/styles"; +import { MapPreviewCard } from "./MapPreviewCard"; + +const m = defineMessages({ + title: { + id: "sharedComponents.BGMapSelector.title", + defaultMessage: "Background Maps", + description: "Title for the background map selector", + }, + close: { + id: "sharedComponents.BGMapSelector.close", + defaultMessage: "Close", + }, + manageMaps: { + id: "sharedComponents.BGMapSelector.manageMaps", + defaultMessage: "Manage Maps", + }, +}); + +interface MapSelectorProps { + /** Should NOT come from `useBottomSheet()` */ + closeSheet: () => void; +} + +/** `ref` should NOT come from - `useBottomSheet()` */ +export const BackgroundMapSelector = React.forwardRef< + BottomSheetMethods, + MapSelectorProps +>(({ closeSheet }, ref) => { + const { navigate } = useNavigationFromRoot(); + + const { formatMessage: t } = useIntl(); + + const { + status, + styles: stylesList, + selectedStyleId, + setSelectedStyleId, + } = useMapStyles(); + + const [snapPoints, setSnapPoints] = React.useState<(number | string)[]>([ + 0, + "40%", + ]); + + const onLayout = React.useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setSnapPoints([0, height]); + }, []); + + return ( + null} + > + + + + {t(m.title)} + + + + {status === "loading" ? ( + + + + ) : status === "success" ? ( + + {stylesList + .filter(({ isImporting }) => !isImporting) + .map(({ id, url, name }) => { + const isSelected = selectedStyleId === id; + return ( + + { + if (isSelected) return; + closeSheet(); + setSelectedStyleId(id); + }} + selected={isSelected} + styleUrl={url} + title={name} + /> + + ); + })} + + ) : null} + + + + + + + + + ); +}); + +const styles = StyleSheet.create({ + bottomSheetView: { paddingTop: 30 }, + headerContainer: { + paddingBottom: 10, + borderBottomWidth: 1, + borderColor: LIGHT_GREY, + }, + title: { + fontSize: 20, + textAlign: "center", + paddingBottom: 20, + fontWeight: "bold", + }, + manageMapsButtonText: { + color: MAPEO_BLUE, + fontSize: 16, + textAlign: "center", + fontWeight: "bold", + }, + loadingContainer: { padding: 60 }, + mainContentContainer: { paddingVertical: 20 }, + scrollContainer: { + display: "flex", + flexDirection: "row", + paddingBottom: 20, + }, + scrollContentContainer: { paddingHorizontal: 10 }, + previewCardContainer: { marginHorizontal: 10 }, + closeButtonContainer: { paddingHorizontal: 40 }, +}); diff --git a/src/frontend/screens/MapScreen/MapPreviewCard.tsx b/src/frontend/screens/MapScreen/MapPreviewCard.tsx new file mode 100644 index 000000000..93e13ef64 --- /dev/null +++ b/src/frontend/screens/MapScreen/MapPreviewCard.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { StyleSheet, View } from "react-native"; + +import { MAPEO_BLUE, WHITE } from "../../lib/styles"; +import MapThumbnail from "../../sharedComponents/MapThumbnail"; +import Text from "../../sharedComponents/Text"; +import { TouchableHighlight } from "../../sharedComponents/Touchables"; + +interface Props { + onPress: () => void; + selected: boolean; + styleUrl: string; + title: string | null; +} + +export const MapPreviewCard = ({ + onPress, + selected, + styleUrl, + title, +}: Props) => ( + + + + + {title && ( + + {title} + + )} + +); + +const styles = StyleSheet.create({ + container: { alignItems: "center" }, + button: { + marginBottom: 10, + borderWidth: 4, + borderRadius: 12, + overflow: "hidden", + }, + thumbnail: { + height: 80, + width: 80, + }, + title: { + textAlign: "center", + fontSize: 16, + maxWidth: 80, + }, +}); diff --git a/src/frontend/screens/MapScreen/MapScreen.tsx b/src/frontend/screens/MapScreen/index.tsx similarity index 52% rename from src/frontend/screens/MapScreen/MapScreen.tsx rename to src/frontend/screens/MapScreen/index.tsx index a64daa4e4..8566803d5 100644 --- a/src/frontend/screens/MapScreen/MapScreen.tsx +++ b/src/frontend/screens/MapScreen/index.tsx @@ -1,66 +1,38 @@ import * as React from "react"; -import { View } from "react-native"; +import { StyleSheet, View } from "react-native"; import debug from "debug"; -import Text from "../../sharedComponents/Text"; import MapView from "../../sharedComponents/Map/MapView"; import Loading from "../../sharedComponents/Loading"; import { useDraftObservation } from "../../hooks/useDraftObservation"; -import { useMapStyle } from "../../hooks/useMapStyle"; +import { useSelectedMapStyle } from "../../hooks/useSelectedMapStyle"; import ObservationsContext from "../../context/ObservationsContext"; import LocationContext from "../../context/LocationContext"; import { AddButton } from "../../sharedComponents/AddButton"; -import { BGMapSelector } from "./BGMapSelector"; +import { BackgroundMapSelector } from "./BackgroundMapSelector"; import IconButton from "../../sharedComponents/IconButton"; import MaterialIcon from "react-native-vector-icons/MaterialIcons"; import { MEDIUM_GREY } from "../../lib/styles"; import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; import { useExperiments } from "../../hooks/useExperiments"; -import { - MapServerStyle, - NativeHomeTabsNavigationProps, -} from "../../sharedTypes"; -import api from "../../api"; -import { useMapServerState } from "../../hooks/useMapServerState"; +import { NativeHomeTabsNavigationProps } from "../../sharedTypes"; + const log = debug("mapeo:MapScreen"); export const MapScreen = ({ navigation, }: NativeHomeTabsNavigationProps<"Map">) => { const [, { newDraft }] = useDraftObservation(); - const { styleType, styleUrl, setStyleId } = useMapStyle(); + const selectedMapStyle = useSelectedMapStyle(); const [experiments] = useExperiments(); - const mapServerReady = useMapServerState(); const sheetRef = React.useRef(null); - const [bgMapsList, setBgMapList] = React.useState( - null - ); - - React.useEffect(() => { - if (mapServerReady) { - api.maps.getStyleList().then(val => setBgMapList(val)); - } - }, [mapServerReady]); - const [{ observations }] = React.useContext(ObservationsContext); const location = React.useContext(LocationContext); - async function openSheet() { - sheetRef.current?.snapTo(1); - if (mapServerReady) { - try { - const list = await api.maps.getStyleList(); - setBgMapList(list); - } catch { - setBgMapList([]); - } - } - } - const handleObservationPress = React.useCallback( (observationId: string) => navigation.navigate("Observation", { observationId }), @@ -74,48 +46,45 @@ export const MapScreen = ({ }, [navigation, newDraft]); return ( - - {styleUrl === null ? ( + + {selectedMapStyle.status === "loading" ? ( ) : ( )} {experiments.backgroundMaps && ( - - - + + sheetRef.current?.snapTo(1)} + > + + + + sheetRef.current?.close()} /> - + )} ); }; -interface BGMapButtonProps { - /** `openSheet()` should NOT come from `useBottomSheetModal` */ - openSheet: () => void; -} - -const BGMapButton = ({ openSheet }: BGMapButtonProps) => { - return ( - - - - - - ); -}; +const styles = StyleSheet.create({ + container: { flex: 1 }, + mapSelectorButtonContainer: { + position: "absolute", + top: 100, + right: 10, + }, + mapSelectorButton: { backgroundColor: "#fff", borderRadius: 50 }, +}); diff --git a/src/frontend/screens/Observation/ObservationView.js b/src/frontend/screens/Observation/ObservationView.js index 6fd355675..35a605707 100644 --- a/src/frontend/screens/Observation/ObservationView.js +++ b/src/frontend/screens/Observation/ObservationView.js @@ -31,7 +31,7 @@ import { import { TouchableOpacity } from "../../sharedComponents/Touchables"; import type { PresetWithFields } from "../../context/ConfigContext"; import type { Observation } from "../../context/ObservationsContext"; -import { useMapStyle } from "../../hooks/useMapStyle"; +import { useSelectedMapStyle } from "../../hooks/useSelectedMapStyle"; import useDeviceId from "../../hooks/useDeviceId"; import useSettingsValue from "../../hooks/useSettingsValue"; import Loading from "../../sharedComponents/Loading"; @@ -71,9 +71,9 @@ type MapProps = { }; const InsetMapView = React.memo(({ lon, lat }: MapProps) => { - const { styleType, styleUrl } = useMapStyle(); + const selectedMap = useSelectedMapStyle(); - return styleType === "loading" ? ( + return selectedMap.status === "loading" ? ( @@ -86,14 +86,14 @@ const InsetMapView = React.memo(({ lon, lat }: MapProps) => { pitchEnabled={false} rotateEnabled={false} compassEnabled={false} - styleURL={styleUrl} + styleURL={selectedMap.styleUrl} > - {styleType === "fallback" ? : null} + {selectedMap.isOfflineFallback ? : null} ); }); diff --git a/src/frontend/screens/Security/index.tsx b/src/frontend/screens/Security/index.tsx index f94efc581..56903a20a 100644 --- a/src/frontend/screens/Security/index.tsx +++ b/src/frontend/screens/Security/index.tsx @@ -76,7 +76,11 @@ export const Security: NativeNavigationComponent<"Security"> = ({ navigation.navigate("AppPasscode")} + onPress={() => + navigation.navigate( + !authValuesSet.passcodeSet ? "AppPasscode" : "EnterPassToTurnOff" + ) + } > } diff --git a/src/frontend/screens/Settings/Experiments/BGMaps.tsx b/src/frontend/screens/Settings/Experiments/BackgroundMaps.tsx similarity index 90% rename from src/frontend/screens/Settings/Experiments/BGMaps.tsx rename to src/frontend/screens/Settings/Experiments/BackgroundMaps.tsx index 4e66842c4..ebdf2f93b 100644 --- a/src/frontend/screens/Settings/Experiments/BGMaps.tsx +++ b/src/frontend/screens/Settings/Experiments/BackgroundMaps.tsx @@ -41,9 +41,15 @@ const m = defineMessages({ defaultMessage: "WARNING: When this feature is enabled, you will not have access to the map you had previously been using in Mapeo. Turn off Map Manager to switch back to your previous map. Please note that this feature is still in the pilot testing phase and you will need to re-import any maps added to the Map Manager once the final version is released.", }, + shortLink: { + id: "screens.Settings.Experiments.BGMaps.shortLink", + description: + "Used as a link to the gitbooks documentation for adding background maps", + defaultMessage: "here.", + }, }); -export const BGMapsSettings: NativeNavigationComponent<"BGMapsSettings"> = ({ +export const BackgroundMapsSettings: NativeNavigationComponent<"BGMapsSettings"> = ({ navigation, }) => { const [experiments, setExperiments] = useExperiments(); @@ -66,11 +72,11 @@ export const BGMapsSettings: NativeNavigationComponent<"BGMapsSettings"> = ({ ]} onPress={() => { Linking.openURL( - "https://docs.mapeo.app/complete-reference-guide/mapeo-mobile-installation-setup/adding-custom-base-maps-to-mapeo-mobile/add-maps-to-map-manager" + "https://docs.mapeo.app/complete-reference-guide/customization-options/custom-base-maps/creating-custom-maps/creating-mbtiles" ); }} > - https://docs.mapeo.app/complete-reference-guide/mapeo-mobile-installation-setup/adding-custom-base-maps-to-mapeo-mobile/add-maps-to-map-manager + {t(m.shortLink)} {t(m.feedBack)} @@ -122,7 +128,7 @@ export const BGMapsSettings: NativeNavigationComponent<"BGMapsSettings"> = ({ ); }; -BGMapsSettings.navTitle = m.BGMaps; +BackgroundMapsSettings.navTitle = m.BGMaps; const styles = StyleSheet.create({ container: { diff --git a/src/frontend/screens/Settings/Experiments/index.tsx b/src/frontend/screens/Settings/Experiments/index.tsx index dd3aaa22d..fd9989782 100644 --- a/src/frontend/screens/Settings/Experiments/index.tsx +++ b/src/frontend/screens/Settings/Experiments/index.tsx @@ -84,7 +84,7 @@ const Experiments: NativeNavigationComponent<"Experiments"> = () => { } /> - {/* navigate("BGMapsSettings")}> + navigate("BGMapsSettings")}> } @@ -96,7 +96,7 @@ const Experiments: NativeNavigationComponent<"Experiments"> = () => { ) } /> - */} + ); diff --git a/src/frontend/screens/Settings/MapSettings/BackgroundMapCard.tsx b/src/frontend/screens/Settings/MapSettings/BackgroundMapCard.tsx new file mode 100644 index 000000000..ebdba549c --- /dev/null +++ b/src/frontend/screens/Settings/MapSettings/BackgroundMapCard.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import { defineMessages, useIntl } from "react-intl"; +import { StyleSheet, View, Text } from "react-native"; +import { Bar } from "react-native-progress"; + +import { MapServerStyleInfo } from "../../../sharedTypes"; +import { LIGHT_GREY, MAPEO_BLUE, MEDIUM_GREY } from "../../../lib/styles"; +import { useMapImportProgress } from "../../../hooks/useMapImportProgress"; +import { Pill } from "../../../sharedComponents/Pill"; +import { CUSTOM_MAP_ID, DEFAULT_MAP_ID } from "../../../hooks/useMapStyles"; +import MapThumbnail from "../../../sharedComponents/MapThumbnail"; +import { TouchableOpacity } from "../../../sharedComponents/Touchables"; +import { bytesToMegabytes } from "."; +import { useNavigationFromRoot } from "../../../hooks/useNavigationWithTypes"; + +const m = defineMessages({ + currentMap: { + id: "sharedComponents.BGMapCard.currentMap", + defaultMessage: "Current Map", + }, + abbrevMegabyte: { + id: "sharedComponents.BGMapCard.abbrevMegabyte", + defaultMessage: "MB", + description: "The abbreviation for megabyte", + }, + unnamedStyle: { + id: "sharedComponents.BGMapCard.unamedStyle", + defaultMessage: "Unnamed Style", + description: "The name for the default map style", + }, + waitingForImport: { + id: "sharedComponents.BGMapCard.waitingForImport", + defaultMessage: "Waiting for import…", + description: + "Progress bar message indicating that import is waiting to start", + }, + importInProgress: { + id: "sharedComponents.BGMapCard.importInProgress", + defaultMessage: "Import in progress…", + description: "Progress bar message about the import being in progress", + }, + errorOccurred: { + id: "sharedComponents.BGMapCard.errorOccurred", + defaultMessage: "Error occurred", + description: "Message describing that error occurred for map import", + }, +}); + +const WithTopSeparation = ({ children }: React.PropsWithChildren<{}>) => ( + {children} +); + +interface BGMapCardProps extends MapServerStyleInfo { + isImporting: boolean; + isSelected: boolean; + onPress?: () => void; +} + +export const BackgroundMapCard = ({ + isImporting, + isSelected, + name, + bytesStored, + id: styleId, + url: styleUrl, +}: BGMapCardProps) => { + const { navigate } = useNavigationFromRoot(); + const { formatMessage: t } = useIntl(); + + const importInfo = useMapImportProgress(styleId); + + const showBytesStored = + styleId !== DEFAULT_MAP_ID && styleId !== CUSTOM_MAP_ID && !isImporting; + const showProgressBar = importInfo && importInfo.status !== "error"; + const showProgressBarMessage = + importInfo?.status === "idle" || importInfo?.status === "progress"; + + const mapName = name || t(m.unnamedStyle); + + return ( + + navigate("BackgroundMapInfo", { + bytesStored, + id: styleId, + name: mapName, + styleUrl, + }) + } + > + + + {mapName} + + {showBytesStored && ( + + {bytesToMegabytes(bytesStored).toFixed(0)} {t(m.abbrevMegabyte)} + + )} + + {importInfo && ( + + {showProgressBar && ( + + )} + {showProgressBarMessage && ( + + + {importInfo.status === "progress" + ? t(m.importInProgress) + : t(m.waitingForImport)} + + + )} + + )} + + {importInfo && importInfo.status === "error" && ( + + {t(m.errorOccurred)} + + )} + + {isSelected && ( + + + + )} + + + ); +}; + +const borderRadius = 8; + +const styles = StyleSheet.create({ + container: { + borderColor: LIGHT_GREY, + borderRadius, + overflow: "hidden", + flexDirection: "row", + minHeight: 100, + }, + infoContainer: { + padding: 10, + backgroundColor: LIGHT_GREY, + borderWidth: 0.5, + borderRightWidth: 1, + borderBottomRightRadius: borderRadius, + borderTopRightRadius: borderRadius, + flex: 3, + }, + text: { fontSize: 14 }, + mapPreview: { + flex: 1, + maxWidth: 100, + }, +}); diff --git a/src/frontend/screens/Settings/MapSettings/BackgroundMapInfo.tsx b/src/frontend/screens/Settings/MapSettings/BackgroundMapInfo.tsx index 22e4e1759..28bf1353c 100644 --- a/src/frontend/screens/Settings/MapSettings/BackgroundMapInfo.tsx +++ b/src/frontend/screens/Settings/MapSettings/BackgroundMapInfo.tsx @@ -1,18 +1,16 @@ import MapboxGL from "@react-native-mapbox-gl/maps"; import * as React from "react"; import { defineMessages, useIntl } from "react-intl"; -import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; -import api from "../../../api"; import { MAPEO_BLUE, MEDIUM_GREY, WHITE } from "../../../lib/styles"; import Button from "../../../sharedComponents/Button"; -import Loading from "../../../sharedComponents/Loading"; import { NativeRootNavigationProps } from "../../../sharedTypes"; import { DeleteMapBottomSheet } from "./DeleteMapBottomSheet"; -import { useMapStyle } from "../../../hooks/useMapStyle"; -import { convertBytesToMb, DEFAULT_MAP_ID } from "./BackgroundMaps"; +import { DEFAULT_MAP_ID, useMapStyles } from "../../../hooks/useMapStyles"; import { DeleteIcon } from "../../../sharedComponents/icons"; import { useBottomSheetModal } from "../../../sharedComponents/BottomSheetModal"; +import { bytesToMegabytes } from "."; const m = defineMessages({ removeMap: { @@ -138,20 +136,20 @@ export const BackgroundMapInfo = ({ const { formatMessage: t } = useIntl(); const { bytesStored, id, styleUrl, name } = route.params; - const { closeSheet, openSheet, sheetRef } = useBottomSheetModal({ + const { closeSheet, openSheet, sheetRef, isOpen } = useBottomSheetModal({ openOnMount: false, }); - const { setStyleId } = useMapStyle(); + const { setSelectedStyleId } = useMapStyles(); function setStyleAndNavigateHome() { - setStyleId(id); + setSelectedStyleId(id); navigation.navigate("Home", { screen: "Map" }); } return ( - - + + - - {bytesStored && ( - - {`${convertBytesToMb(bytesStored).toFixed(0)} ${t(m.mb)}`} - - )} - - {id !== DEFAULT_MAP_ID && ( + + + {bytesStored > 0 ? ( + + {`${bytesToMegabytes(bytesStored).toFixed(0)} ${t(m.mb)}`} + + ) : null} + + + {id !== DEFAULT_MAP_ID && ( + + )} - )} - - + + isOpen} /> - + ); }; const styles = StyleSheet.create({ + flex: { flex: 1 }, container: { padding: 20, + justifyContent: "space-between", }, - button: { - marginTop: 20, + detailsText: { + fontSize: 14, + color: MEDIUM_GREY, }, - deleteButton: { - color: MAPEO_BLUE, - fontWeight: "700", - letterSpacing: 0.5, - fontSize: 16, + buttonTextIconContainer: { marginRight: 4 }, + buttonText: { + fontSize: 18, + fontWeight: "bold", }, deleteButtonContainer: { display: "flex", diff --git a/src/frontend/screens/Settings/MapSettings/BackgroundMaps.tsx b/src/frontend/screens/Settings/MapSettings/BackgroundMaps.tsx index c07cbcd83..4e737cc40 100644 --- a/src/frontend/screens/Settings/MapSettings/BackgroundMaps.tsx +++ b/src/frontend/screens/Settings/MapSettings/BackgroundMaps.tsx @@ -1,42 +1,24 @@ import * as React from "react"; -import * as DocumentPicker from "expo-document-picker"; -import { defineMessages, FormattedMessage, useIntl } from "react-intl"; -import { ScrollView, StyleSheet, Text, View } from "react-native"; -import { LIGHT_GREY, MEDIUM_GREY, RED } from "../../../lib/styles"; -import { BGMapCard } from "../../../sharedComponents/BGMapCard"; -import BottomSheet, { BottomSheetBackdrop } from "@gorhom/bottom-sheet"; +import { defineMessages, useIntl } from "react-intl"; +import { FlatList, StyleSheet, View } from "react-native"; + +import { + MapImportBottomSheet, + MapImportBottomSheetMethods, +} from "./MapImportBottomSheet"; +import { BackgroundMapCard } from "./BackgroundMapCard"; import Button from "../../../sharedComponents/Button"; -import HeaderTitle from "../../../sharedComponents/HeaderTitle"; import Loading from "../../../sharedComponents/Loading"; -import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; -import MaterialIcon from "react-native-vector-icons/MaterialIcons"; -import { TouchableOpacity } from "../../../sharedComponents/Touchables"; -import { - MapServerStyle, - NativeNavigationComponent, -} from "../../../sharedTypes"; -import api from "../../../api"; -import { useMapStyle } from "../../../hooks/useMapStyle"; -import { useDefaultStyleUrl } from "../../../hooks/useDefaultStyleUrl"; -import { useFocusEffect } from "@react-navigation/native"; - -export const DEFAULT_MAP_ID = "default"; +import { NativeNavigationComponent } from "../../../sharedTypes"; +import { useMapStyles } from "../../../hooks/useMapStyles"; const m = defineMessages({ addBGMap: { - id: "screens.Settings.MapSettings.BackgroundMaps", + id: "screens.Settings.MapSettings.addBGMap", defaultMessage: "Add Background Map", }, - close: { - id: "screens.Settings.MapSettings.close", - defaultMessage: "Close", - }, - importFromFile: { - id: "screens.Settings.MapSettings.importFromFile", - defaultMessage: "Import from File", - }, - BackgroundMapTitle: { - id: "screens.Settings.MapSettings.BackgroundMapTitle", + backgroundMapTitle: { + id: "screens.Settings.MapSettings.backgroundMapTitle", defaultMessage: "Background Maps", }, deleteMapTitle: { @@ -49,210 +31,62 @@ const m = defineMessages({ defaultMessage: "Yes, Delete", description: "Confirm delete map modal button", }, - importError: { - id: "screens.Settings.MapSettings.importError", - defaultMessage: "Error Importing Map, please try a different file.", - description: "Error importing map warning", - }, }); -type ModalContent = "import" | "error"; - -export const BackgroundMaps: NativeNavigationComponent<"BackgroundMaps"> = ({ - navigation, -}) => { - const sheetRef = React.useRef(null); - - const { styleUrl } = useMapStyle(); - - const defaultStyleUrl = useDefaultStyleUrl(); - - const [modalContent, setModalContent] = React.useState( - "import" +const ListEmpty = () => { + return ( + + + ); +}; - const [snapPoints, setSnapPoints] = React.useState<(number | string)[]>([ - 0, - "30%", - ]); - - const [backgroundMapList, setBackgroundMapList] = React.useState< - MapServerStyle[] - >(); - - const getStylesAndPopulateList = React.useCallback(() => { - api.maps - .getStyleList() - .then(setBackgroundMapList) - .catch(err => { - console.log("COULD NOT FETCH STYLES", err); - setBackgroundMapList([]); - }); - }, []); - - useFocusEffect(getStylesAndPopulateList); - +const ListHeader = ({ onPress }: { onPress: () => void }) => { const { formatMessage: t } = useIntl(); + return ( + + + + ); +}; - function openModal() { - sheetRef.current?.snapTo(1); - } - - async function handleImportPress() { - const results = await DocumentPicker.getDocumentAsync(); - - if (results.type === "cancel") { - sheetRef.current?.close(); - return; - } +export const BackgroundMaps: NativeNavigationComponent<"BackgroundMaps"> = () => { + const bottomSheetRef = React.useRef(null); - if (results.type === "success") { - try { - await api.maps.importTileset(results.uri); - const list = await api.maps.getStyleList(); - setBackgroundMapList(list); - sheetRef.current?.close(); - } catch (err) { - console.log("FAILED TO IMPORT", err); - setModalContent("error"); - } - } - } + const { status, styles: mapStyles, selectedStyleId } = useMapStyles(); return ( - - - - - {/* Default BG map card */} - {defaultStyleUrl && ( - {}} - isSelected={styleUrl === defaultStyleUrl} - styleUrl={defaultStyleUrl} - mapTitle="Default Map" - /> - )} - - {backgroundMapList === undefined ? ( - - - - ) : ( - backgroundMapList.map(bgMap => ( - + bottomSheetRef.current?.open()} /> + } + renderItem={({ item }) => ( + + - )) + )} - - - null} - > - { - const { height } = e.nativeEvent.layout; - setSnapPoints([0, height]); - }} - style={{ padding: 20 }} - > - - {t(m.BackgroundMapTitle)} - - - {modalContent === "import" ? ( - - - - - {t(m.importFromFile)} - - - {"( .mbtiles )"} - - - - ) : ( - - - {" "} - {t(m.importError)}{" "} - - - )} - - - - - + /> + + ); }; -BackgroundMaps.navTitle = m.BackgroundMapTitle; - -export function convertBytesToMb(bytes: number) { - return bytes / 1024 ** 2; -} +BackgroundMaps.navTitle = m.backgroundMapTitle; const styles = StyleSheet.create({ - button: { - marginTop: 40, - width: 280, - }, - noDownloads: { - fontSize: 16, - color: MEDIUM_GREY, - textAlign: "center", - marginTop: 20, - }, - container: { + scrollContentContainer: { paddingHorizontal: 20, paddingVertical: 40 }, + addMapButtonContainer: { paddingHorizontal: 20, - marginBottom: 20, - }, - importButton: { - backgroundColor: LIGHT_GREY, - padding: 40, - marginTop: 20, - marginBottom: 20, - borderRadius: 5, - }, - text: { - fontSize: 16, - }, - importTextAndIcon: { - marginBottom: 20, - display: "flex", - justifyContent: "center", - flexDirection: "row", + paddingBottom: 30, }, + mapCardContainer: { paddingVertical: 10 }, }); diff --git a/src/frontend/screens/Settings/MapSettings/DeleteMapBottomSheet.tsx b/src/frontend/screens/Settings/MapSettings/DeleteMapBottomSheet.tsx index b09ef39de..a6e70d226 100644 --- a/src/frontend/screens/Settings/MapSettings/DeleteMapBottomSheet.tsx +++ b/src/frontend/screens/Settings/MapSettings/DeleteMapBottomSheet.tsx @@ -1,14 +1,12 @@ import * as React from "react"; import { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; import { defineMessages, FormattedMessage, useIntl } from "react-intl"; -import { StyleSheet, View, Text } from "react-native"; import { RED, WHITE } from "../../../lib/styles"; import { DeleteIcon, ErrorIcon } from "../../../sharedComponents/icons"; import api from "../../../api"; import { useNavigationFromRoot } from "../../../hooks/useNavigationWithTypes"; -import { useMapStyle } from "../../../hooks/useMapStyle"; -import { DEFAULT_MAP_ID } from "./BackgroundMaps"; +import { DEFAULT_MAP_ID, useMapStyles } from "../../../hooks/useMapStyles"; import { BottomSheetModal, BottomSheetContent, @@ -38,15 +36,16 @@ interface DeleteMapBottomSheetProps { mapName: string; mapId: string; closeSheet: () => void; + onHardwareBackPress: () => boolean; } export const DeleteMapBottomSheet = React.forwardRef< BottomSheetModalMethods, DeleteMapBottomSheetProps ->(({ mapName, closeSheet, mapId }, sheetRef) => { +>(({ mapName, closeSheet, mapId, onHardwareBackPress }, sheetRef) => { const { navigate } = useNavigationFromRoot(); const { formatMessage: t } = useIntl(); - const { styleId, setStyleId } = useMapStyle(); + const { selectedStyleId, setSelectedStyleId } = useMapStyles(); function deleteMap() { // Cannot delete Default Map @@ -58,8 +57,8 @@ export const DeleteMapBottomSheet = React.forwardRef< .deleteStyle(mapId) .then(() => { // If user is deleting the map that is currently being used, we want to set the map to be the default map - if (styleId === mapId) { - setStyleId(DEFAULT_MAP_ID); + if (selectedStyleId === mapId) { + setSelectedStyleId(DEFAULT_MAP_ID); } navigate("BackgroundMaps"); }) @@ -69,7 +68,11 @@ export const DeleteMapBottomSheet = React.forwardRef< } return ( - + - - {t(m.deleteMap)} - - ), + text: t(m.deleteMap), + icon: , variation: "filled", onPress: deleteMap, dangerous: true, @@ -104,26 +103,3 @@ export const DeleteMapBottomSheet = React.forwardRef< ); }); - -const styles = StyleSheet.create({ - btmSheetContainer: { - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 20, - paddingTop: 20, - }, - deleteButtonText: { - fontWeight: "700", - letterSpacing: 0.5, - fontSize: 16, - color: WHITE, - }, - deleteButtonContainer: { - display: "flex", - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - }, -}); diff --git a/src/frontend/screens/Settings/MapSettings/MapImportBottomSheet.tsx b/src/frontend/screens/Settings/MapSettings/MapImportBottomSheet.tsx new file mode 100644 index 000000000..914b1842f --- /dev/null +++ b/src/frontend/screens/Settings/MapSettings/MapImportBottomSheet.tsx @@ -0,0 +1,329 @@ +import * as React from "react"; +import { defineMessages, useIntl } from "react-intl"; +import { StyleSheet, Text, View, useWindowDimensions } from "react-native"; +import MaterialIcon from "react-native-vector-icons/MaterialIcons"; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; +import * as DocumentPicker from "expo-document-picker"; + +import { TouchableOpacity } from "../../../sharedComponents/Touchables"; +import { LIGHT_GREY, MEDIUM_GREY, RED } from "../../../lib/styles"; +import { BottomSheetContent } from "../../../sharedComponents/BottomSheet"; +import { ErrorIcon } from "../../../sharedComponents/icons"; +import api, { extractHttpErrorResponse } from "../../../api"; +import { useMapImportsManager } from "../../../hooks/useMapImports"; + +const MIN_SHEET_HEIGHT = 400; + +const m = defineMessages({ + addBGMap: { + id: "screens.Settings.MapSettings.BackgroundMaps", + defaultMessage: "Add Background Map", + }, + close: { + id: "screens.Settings.MapSettings.close", + defaultMessage: "Close", + }, + importFromFile: { + id: "screens.Settings.MapSettings.importFromFile", + defaultMessage: "Import from File", + }, + BackgroundMapTitle: { + id: "screens.Settings.MapSettings.BackgroundMapTitle", + defaultMessage: "Background Maps", + }, + deleteMapTitle: { + id: "screens.Settings.MapSettings.deleteMapTitle", + defaultMessage: "Delete Map", + description: "Title for the delete map modal", + }, + confirmDelete: { + id: "screens.Settings.MapSettings.confirmDelete", + defaultMessage: "Yes, Delete", + description: "Confirm delete map modal button", + }, + importErrorTitle: { + id: "screens.Settings.MapSettings.importErrorTitle", + defaultMessage: "Import Error", + description: "Title for import error in bottom sheet content", + }, + importErrorDescription: { + id: "screens.Settings.MapSettings.importErrorDescription", + defaultMessage: + "Unable to import {styleNames}. Re-import the map {fileCount, plural, one {file} other {files}} to try again.", + description: "Description for import error in bottom sheet content", + }, + fileErrorDescription: { + id: "screens.Settings.MapSettings.fileErrorDescription", + defaultMessage: "Error importing file, please try a different file.", + description: "Description for file error in bottom sheet content", + }, + defaultMap: { + id: "screens.Settings.MapSettings.defaultMap", + defaultMessage: "Default Map", + description: "Name of default map", + }, + processingFile: { + id: "screens.Settings.MapSettings.processingFile", + defaultMessage: "Processing file...", + description: "Description for when a file is being processed for import", + }, +}); + +/** + * Bottom sheet content for the user to select a file to import + */ +const SelectImportContent = ({ + onPressImport, + onPressClose, +}: { + onPressImport: () => void; + onPressClose: () => void; +}) => { + const { formatMessage: t } = useIntl(); + return ( + + + + + {t(m.importFromFile)} + + + {"( .mbtiles )"} + + + + ); +}; + +/** + * Bottom sheet content for when an imported file is processing, checking the + * format etc. is correct (shows until the import endpoint returns with the + * import id). User is unable to close this sheet. + */ +const ImportingContent = () => { + const { formatMessage: t } = useIntl(); + + return ; +}; + +/** + * Bottom sheet content for when there is an error with the selected file + */ +const FileErrorContent = ({ onPressClose }: { onPressClose: () => void }) => { + const { formatMessage: t } = useIntl(); + return ( + } + description={t(m.fileErrorDescription)} + buttonConfigs={[ + { + onPress: onPressClose, + text: t(m.close), + variation: "outlined", + }, + ]} + /> + ); +}; + +type BottomSheetStates = "select" | "importing" | "error"; + +export interface MapImportBottomSheetMethods { + /** Open the bottom sheet by expanding it to its maximum snap point */ + open: () => void; +} + +/** Custom backdrop that will close sheet when pressed */ +const BackdropPressable = (props: BottomSheetBackdropProps) => { + return ( + + ); +}; + +/** Custom backdrop that will not close sheet when pressed */ +const BackdropNonPressable = (props: BottomSheetBackdropProps) => { + return ( + + ); +}; + +const MapImportBottomSheet = React.forwardRef( + (_props, ref) => { + const [state, setState] = React.useState("select"); + const bottomSheetRef = React.useRef(null); + const { snapPoints, updateSheetHeight } = useSnapPointsCalculator(); + const { add: addMapImport } = useMapImportsManager(); + + React.useImperativeHandle(ref, () => { + return { + open: () => bottomSheetRef.current?.expand(), + }; + }); + + const closeSheet = () => { + // Set state on close so when it opens again this is what you see + setState("select"); + bottomSheetRef.current?.close(); + }; + + const handlePressImport = async () => { + setState("importing"); + let results; + try { + results = await DocumentPicker.getDocumentAsync(); + } catch (err) { + setState("error"); + return; + } + + if (results.type === "cancel") { + closeSheet(); + return; + } + + try { + const { + import: { id: importId }, + style, + } = await api.maps.importTileset(results.uri); + + if (style) { + addMapImport({ styleId: style.id, importId }); + } + + closeSheet(); + } catch (err) { + console.log("IMPORT ERROR", err); + const parsedError = await extractHttpErrorResponse(err)?.json(); + + if ( + parsedError && + parsedError.statusCode >= 400 && + parsedError.statusCode < 500 + ) { + setState("error"); + return; + } + + // TODO: How to handle different kind of error? + closeSheet(); + } + }; + + const BackdropComponent = + state === "importing" ? BackdropNonPressable : BackdropPressable; + + const bottomSheetContent = + state === "select" ? ( + + ) : state === "importing" ? ( + + ) : ( + + ); + + return ( + null} + ref={bottomSheetRef} + backdropComponent={BackdropComponent} + snapPoints={snapPoints} + > + + {bottomSheetContent} + + + ); + } +); + +export { MapImportBottomSheet }; + +function useSnapPointsCalculator() { + const [sheetHeight, setSheetHeight] = React.useState(0); + + const { height: windowHeight } = useWindowDimensions(); + + const snapPoints = React.useMemo(() => [0, sheetHeight], [sheetHeight]); + + const updateSheetHeight = React.useCallback( + ({ + nativeEvent: { + layout: { height }, + }, + }) => { + const newSheetHeight = Math.max( + Math.min(windowHeight * 0.75, height), + MIN_SHEET_HEIGHT + ); + + setSheetHeight(newSheetHeight); + }, + [windowHeight] + ); + + return { snapPoints, updateSheetHeight }; +} + +const styles = StyleSheet.create({ + bottomSheetView: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 30, + }, + scrollContentContainer: { paddingHorizontal: 20, paddingVertical: 40 }, + addMapButtonContainer: { + paddingHorizontal: 20, + paddingBottom: 30, + }, + mapCardContainer: { paddingVertical: 10 }, + noDownloads: { + fontSize: 16, + color: MEDIUM_GREY, + textAlign: "center", + marginTop: 20, + }, + importButton: { + backgroundColor: LIGHT_GREY, + padding: 40, + borderRadius: 8, + }, + text: { fontSize: 16 }, + importTextAndIcon: { + marginBottom: 20, + display: "flex", + justifyContent: "center", + flexDirection: "row", + alignItems: "center", + }, + bottomSheetContentContainer: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 30, + }, + errorIcon: { position: "relative" }, +}); diff --git a/src/frontend/screens/Settings/MapSettings/index.tsx b/src/frontend/screens/Settings/MapSettings/index.tsx index 5863ee97c..46d5868e8 100644 --- a/src/frontend/screens/Settings/MapSettings/index.tsx +++ b/src/frontend/screens/Settings/MapSettings/index.tsx @@ -4,6 +4,10 @@ import { ScrollView } from "react-native"; import { List, ListItem, ListItemText } from "../../../sharedComponents/List"; import { NativeNavigationComponent } from "../../../sharedTypes"; +export function bytesToMegabytes(bytes: number) { + return bytes / 2 ** 20; +} + const m = defineMessages({ backgroundMaps: { id: "screens.Settings.MapSettings.backgroundMaps", diff --git a/src/frontend/sharedComponents/BGMapCard.tsx b/src/frontend/sharedComponents/BGMapCard.tsx deleted file mode 100644 index 712a272d7..000000000 --- a/src/frontend/sharedComponents/BGMapCard.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import * as React from "react"; -import { defineMessages, useIntl } from "react-intl"; -import { StyleSheet, View, Text } from "react-native"; -import MapboxGL from "@react-native-mapbox-gl/maps"; - -import { LIGHT_GREY, MEDIUM_GREY } from "../lib/styles"; -import { ViewStyleProp } from "../sharedTypes"; -import { Pill } from "./Pill"; -import LocationContext from "../context/LocationContext"; -import { useNavigationFromRoot } from "../hooks/useNavigationWithTypes"; -import { TouchableOpacity } from "react-native-gesture-handler"; - -const m = defineMessages({ - currentMap: { - id: "sharedComponents.BGMapCard.currentMap", - defaultMessage: "Current Map", - }, - abbrevMegabyte: { - id: "sharedComponents.BGMapCard.abbrevMegabyte", - defaultMessage: "MB", - description: "The abbreviation for megabyte", - }, - unnamedStyle: { - id: "sharedComponents.BGMapCard.unamedStyle", - defaultMessage: "Unnamed Style", - description: "The name for the default map style", - }, -}); - -// ToDo: API calls to get styleURL, zoom level, center coordinate, etc. - -interface BGMapCardProps { - mapId: string; - mapTitle: string | null; - style?: ViewStyleProp; - styleUrl: string; - onPress?: (() => void) | null; - isSelected: boolean; - bytesStored?: number; -} - -export const BGMapCard = ({ - mapTitle, - style, - isSelected, - styleUrl, - bytesStored, - mapId, -}: BGMapCardProps) => { - const { formatMessage: t } = useIntl(); - const { position } = React.useContext(LocationContext); - const { navigate } = useNavigationFromRoot(); - return ( - - navigate("BackgroundMapInfo", { - bytesStored, - id: mapId, - name: mapTitle || "", - styleUrl, - }) - } - > - - - - - - - {mapTitle || t(m.unnamedStyle)} - - {isSelected && ( - - )} - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - borderColor: MEDIUM_GREY, - borderWidth: 1, - borderRadius: 2, - flexDirection: "row", - minHeight: 100, - }, - textContainer: { - padding: 10, - backgroundColor: LIGHT_GREY, - flex: 1, - }, - text: { - fontSize: 14, - }, - map: { - width: 84, - }, -}); diff --git a/src/frontend/sharedComponents/BottomSheetModal/Content.tsx b/src/frontend/sharedComponents/BottomSheet/Content.tsx similarity index 54% rename from src/frontend/sharedComponents/BottomSheetModal/Content.tsx rename to src/frontend/sharedComponents/BottomSheet/Content.tsx index 10915cdec..368425f88 100644 --- a/src/frontend/sharedComponents/BottomSheetModal/Content.tsx +++ b/src/frontend/sharedComponents/BottomSheet/Content.tsx @@ -10,6 +10,7 @@ interface BaseActionButtonConfig { onPress: () => void; text: React.ReactNode; variation: "filled" | "outlined"; + icon?: React.ReactNode; } interface PrimaryActionButtonConfig extends BaseActionButtonConfig { @@ -25,7 +26,7 @@ type ActionButtonConfig = | PrimaryActionButtonConfig | SecondaryActionButtonConfig; -interface Props { +export interface Props extends React.PropsWithChildren<{}> { buttonConfigs: ActionButtonConfig[]; description?: React.ReactNode; icon?: React.ReactNode; @@ -35,6 +36,7 @@ interface Props { } export const Content = ({ + children, icon, buttonConfigs, description, @@ -43,7 +45,7 @@ export const Content = ({ descriptionStyle, }: Props) => ( - + {icon && {icon}} {title} @@ -53,49 +55,59 @@ export const Content = ({ )} + {!!children && {children}} - {buttonConfigs.map((config, index) => ( - - ))} + + {config.icon ? ( + + {config.icon} + + ) : null} + + {config.text} + + + + ); + })} ); @@ -124,6 +136,15 @@ const styles = StyleSheet.create({ fontSize: 20, textAlign: "center", }, + buttonTextContainer: { + display: "flex", + flexDirection: "row", + alignContent: "center", + alignItems: "center", + }, + buttonTextIconContainer: { + marginRight: 4, + }, buttonText: { fontSize: 18, }, diff --git a/src/frontend/sharedComponents/BottomSheet/index.tsx b/src/frontend/sharedComponents/BottomSheet/index.tsx new file mode 100644 index 000000000..613da7b58 --- /dev/null +++ b/src/frontend/sharedComponents/BottomSheet/index.tsx @@ -0,0 +1,4 @@ +export { + Content as BottomSheetContent, + Props as BottomSheetContentProps, +} from "./Content"; diff --git a/src/frontend/sharedComponents/BottomSheetModal/index.tsx b/src/frontend/sharedComponents/BottomSheetModal/index.tsx index e56465af5..9b946a84c 100644 --- a/src/frontend/sharedComponents/BottomSheetModal/index.tsx +++ b/src/frontend/sharedComponents/BottomSheetModal/index.tsx @@ -23,19 +23,19 @@ export const useBottomSheetModal = ({ }) => { const initiallyOpenedRef = React.useRef(false); const sheetRef = React.useRef(null); - const isOpen = React.useRef(false); + const [isOpen, setIsOpen] = React.useState(false); const closeSheet = React.useCallback(() => { if (sheetRef.current) { sheetRef.current.dismiss(); - isOpen.current = false; + setIsOpen(false); } }, []); const openSheet = React.useCallback(() => { if (sheetRef.current) { sheetRef.current.present(); - isOpen.current = true; + setIsOpen(true); } }, []); @@ -46,14 +46,17 @@ export const useBottomSheetModal = ({ } }, [openOnMount, openSheet]); - return { sheetRef, closeSheet, openSheet, isOpen: isOpen.current }; + return { sheetRef, closeSheet, openSheet, isOpen }; }; -const useBackPressHandler = (onHardwareBackPress?: () => void) => { +const useBackPressHandler = (onHardwareBackPress?: () => void | boolean) => { React.useEffect(() => { const onBack = () => { if (onHardwareBackPress) { - onHardwareBackPress(); + const backPress = onHardwareBackPress(); + if (typeof backPress === "boolean") { + return backPress; + } } // We don't allow the back press to navigate/dismiss this modal by default @@ -95,7 +98,7 @@ const useSnapPointsCalculator = () => { interface Props extends React.PropsWithChildren<{}> { onDismiss: () => void; - onHardwareBackPress?: () => void; + onHardwareBackPress?: () => void | boolean; snapPoints?: (string | number)[]; disableBackrop?: boolean; } @@ -134,4 +137,4 @@ export const BottomSheetModal = React.forwardRef( } ); -export { Content as BottomSheetContent } from "./Content"; +export { BottomSheetContent } from "../BottomSheet"; diff --git a/src/frontend/sharedComponents/Map/MapView.js b/src/frontend/sharedComponents/Map/MapView.js index 4a6f4bd38..b89818aaa 100644 --- a/src/frontend/sharedComponents/Map/MapView.js +++ b/src/frontend/sharedComponents/Map/MapView.js @@ -11,11 +11,9 @@ import { LocationFollowingIcon, LocationNoFollowIcon } from "../icons"; import IconButton from "../IconButton"; import type { LocationContextType } from "../../context/LocationContext"; import type { ObservationsMap } from "../../context/ObservationsContext"; -import { MapTypes, fallbackStyleURL } from "../../context/MapStyleContext"; import { useIsFullyFocused } from "../../hooks/useIsFullyFocused"; import bugsnag from "../../lib/logger"; import config from "../../../config.json"; -import Loading from "../Loading"; import { OfflineMapLayers } from "../OfflineMapLayers"; import { UserLocation } from "./UserLocation"; @@ -150,8 +148,8 @@ const ObservationMapLayer = ({ type Props = { observations: ObservationsMap, - styleURL: string | void, - styleType: MapTypes, + styleURL: string, + isOfflineFallback: boolean, location: LocationContextType, onPressObservation: (observationId: string) => any, isFocused: boolean, @@ -291,7 +289,7 @@ class MapView extends React.Component { }; handleLocationPress = () => { - const { location, styleURL } = this.props; + const { location, isOfflineFallback } = this.props; if (!(location.provider && location.provider.locationServicesEnabled)) // TODO: Show alert for the user here so they know why it does not work return; @@ -304,10 +302,10 @@ class MapView extends React.Component { // button. const currentZoom = this.zoomRef; this.setState(state => { - const newZoom = (this.zoomRef = - styleURL === fallbackStyleURL - ? Math.max(currentZoom, DEFAULT_ZOOM_FALLBACK_MAP) - : Math.max(currentZoom, DEFAULT_ZOOM)); + const newZoom = (this.zoomRef = isOfflineFallback + ? Math.max(currentZoom, DEFAULT_ZOOM_FALLBACK_MAP) + : Math.max(currentZoom, DEFAULT_ZOOM)); + return { following: !state.following, zoom: newZoom, @@ -319,7 +317,7 @@ class MapView extends React.Component { const { observations, styleURL, - styleType, + isOfflineFallback, isFocused, location, } = this.props; @@ -329,77 +327,73 @@ class MapView extends React.Component { return ( <> - {styleType === "loading" && styleURL === null ? ( - - ) : ( - - bugsnag.notify(new Error("Failed to load map"), report => { - report.severity = "error"; - report.context = "onDidFailLoadingMap"; - }) - } - onDidFinishLoadingStyle={this.handleDidFinishLoadingStyle} - onDidFinishRenderingMap={() => - bugsnag.leaveBreadcrumb("onDidFinishRenderingMap") + + bugsnag.notify(new Error("Failed to load map"), report => { + report.severity = "error"; + report.context = "onDidFailLoadingMap"; + }) + } + onDidFinishLoadingStyle={this.handleDidFinishLoadingStyle} + onDidFinishRenderingMap={() => + bugsnag.leaveBreadcrumb("onDidFinishRenderingMap") + } + onDidFinishRenderingMapFully={() => { + if (!isOfflineFallback) { + // For the fallback offline map (that does not contain much + // detail) we stay at zoom 4, but if we do load a style then we + // zoom in to DEFAULT_ZOOM (zoom 12) once the map loads + this.zoomRef = DEFAULT_ZOOM; + this.setState({ zoom: DEFAULT_ZOOM }); } - onDidFinishRenderingMapFully={() => { - if (styleURL !== fallbackStyleURL) { - // For the fallback offline map (that does not contain much - // detail) we stay at zoom 4, but if we do load a style then we - // zoom in to zoom 12 once the map loads - this.zoomRef = DEFAULT_ZOOM; - this.setState({ zoom: DEFAULT_ZOOM }); - } - bugsnag.leaveBreadcrumb("onDidFinishRenderingMapFully"); + bugsnag.leaveBreadcrumb("onDidFinishRenderingMapFully"); + }} + onWillStartLoadingMap={() => + bugsnag.leaveBreadcrumb("onWillStartLoadingMap") + } + onDidFinishLoadingMap={() => + bugsnag.leaveBreadcrumb("onDidFinishLoadingMap") + } + compassEnabled={false} + styleURL={styleURL} + onRegionWillChange={this.handleRegionWillChange} + onRegionIsChanging={this.handleRegionIsChanging} + onRegionDidChange={this.handleRegionDidChange} + > + - bugsnag.leaveBreadcrumb("onWillStartLoadingMap") - } - onDidFinishLoadingMap={() => - bugsnag.leaveBreadcrumb("onDidFinishLoadingMap") - } - compassEnabled={false} - styleURL={styleURL} - onRegionWillChange={this.handleRegionWillChange} - onRegionIsChanging={this.handleRegionIsChanging} - onRegionDidChange={this.handleRegionDidChange} - > - + {this.state.hasFinishedLoadingStyle && ( + - {this.state.hasFinishedLoadingStyle && ( - - )} - {styleURL === fallbackStyleURL ? : null} - {locationServicesEnabled ? ( - - ) : null} - - )} + )} + {isOfflineFallback ? : null} + {locationServicesEnabled ? ( + + ) : null} + ; +}) { + const { position } = React.useContext(LocationContext); + + // TODO: need a background image that shows if the map does not load (e.g. if + // the user is offline and the map is not available offline) + + return ( + + + + ); +} diff --git a/src/frontend/sharedTypes.ts b/src/frontend/sharedTypes.ts index 4ce25833f..0487300cd 100644 --- a/src/frontend/sharedTypes.ts +++ b/src/frontend/sharedTypes.ts @@ -1,4 +1,5 @@ // TS port of /src/frontend/types.js +import { ImportsApi } from "@mapeo/map-server/dist/api/imports"; import { StylesApi } from "@mapeo/map-server/dist/api/styles"; import { TilesetsApi } from "@mapeo/map-server/dist/api/tilesets"; import { BottomTabScreenProps } from "@react-navigation/bottom-tabs"; @@ -57,5 +58,6 @@ export type NativeHomeTabsNavigationProps< NativeStackScreenProps >; -export type MapServerStyle = Unpacked>; +export type MapServerStyleInfo = Unpacked>; +export type MapServerImport = Unpacked>; export type Tileset = Unpacked>;