Adopted and enforced code style from other iNat Javascript projects

These rules are largely based on the AirBnB ones, which are not quite standard
for the React Native world, where Prettier seems to be more common, but I
think they add a lot of useful checks, and unlike Prettier we can customize
them. This also just makes it easier for people on the iNat team to work on
the mobile app.

Some specific changes:

* Added eslint-plugin-react-hooks to eslint rules
* Added eslint-plugin-simple-import-sort to eslint rules
* Bugfix: could not import photo from gallery
* Added support for react-native/no-inline-styles eslint rule
* useUser should not bother fetching a user for a blank userId
This commit is contained in:
Ken-ichi
2022-07-13 13:55:59 -07:00
committed by GitHub
parent dbd39ff605
commit e929764c25
239 changed files with 4157 additions and 3627 deletions

View File

@@ -1,22 +1,88 @@
module.exports = {
root: true,
extends: ["@react-native-community", "plugin:i18next/recommended"],
parser: "@babel/eslint-parser",
parserOptions: {
requireConfigFile: false,
babelOptions: {
presets: ["@babel/preset-react"]
}
},
extends: ["airbnb", "plugin:i18next/recommended"],
plugins: [
"react-hooks",
"react-native",
"simple-import-sort"
],
globals: {
FormData: true
},
rules: {
quotes: [2, "double"],
"arrow-parens": [2, "as-needed"],
"comma-dangle": [2, "never"],
"space-in-parens": [2, "always"],
"prettier/prettier": 0,
"consistent-return": [2, { treatUndefinedAsUnspecified: true }],
"func-names": 0,
"global-require": 0,
"i18next/no-literal-string": [2, {
words: {
// Minor change to the default to disallow all-caps string literals as well
exclude: ["[0-9!-/:-@[-`{-~]+"]
}
}],
"no-var": 1
// The AirBNB approach at
// https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/imports.js#L71
// is quite particular and forbids imports of devDependencies anywhere
// outside of tests and conventional config files, which causes problem
// if you have, say, some utility scripts that have nothing to do with
// your actually application. Here I'm trying to use the defaults
// (https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-extraneous-dependencies.md,
// which allow imports of anything declared in package.json, but should
// raise alarms when you try to import things not declared in
// package.json.
"import/no-extraneous-dependencies": ["error", {}],
"max-len": ["error", 100, 2, {
ignoreUrls: true,
ignoreComments: false,
ignoreRegExpLiterals: true,
ignoreStrings: false,
ignoreTemplateLiterals: false
}],
"no-alert": 0,
"no-underscore-dangle": 0,
"no-unused-vars": ["error", {
vars: "all",
args: "after-used",
// Overriding airbnb to allow leading underscore to indicate unused var
argsIgnorePattern: "^_",
ignoreRestSiblings: true
}],
"no-void": 0,
"prefer-destructuring": [2, { object: true, array: false }],
quotes: [2, "double"],
"space-in-parens": [2, "always"],
"no-restricted-globals": 0,
"no-param-reassign": 0,
"no-var": 1,
"prefer-const": [2, { destructuring: "all" }],
// "react/forbid-prop-types": 0,
"react/prop-types": 0,
"react/destructuring-assignment": 0,
"react/jsx-filename-extension": 0,
"react/function-component-definition": [2, { namedComponents: "arrow-function" }],
"react/require-default-props": 0,
// React-Hooks Plugin
// The following rules are made available via `eslint-plugin-react-hooks`
"react-hooks/rules-of-hooks": 2,
"react-hooks/exhaustive-deps": 2,
"react-native/no-inline-styles": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
},
// need this so jest doesn't show as undefined in jest.setup.js
env: {
"jest": true
jest: true
},
ignorePatterns: ["/coverage/*"]
};

View File

@@ -1,15 +1,16 @@
// @flow
import "react-native-gesture-handler";
import { AppRegistry } from "react-native";
import inatjs from "inaturalistjs";
import Config from "react-native-config";
import App from "./src/navigation/rootNavigation";
import {name as appName} from "./app.json";
import "./src/i18n";
import inatjs from "inaturalistjs";
import { AppRegistry } from "react-native";
import Config from "react-native-config";
import { startNetworkLogging } from "react-native-network-logger";
import { name as appName } from "./app.json";
import App from "./src/navigation/rootNavigation";
startNetworkLogging();
// Configure inatjs to use the chosen URLs

View File

@@ -559,7 +559,7 @@ SPEC CHECKSUMS:
FBReactNativeSpec: 81ce99032d5b586fddd6a38d450f8595f7e04be4
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 476ee3e89abb49e07f822b48323c51c57124b572
Permission-LocationWhenInUse: 006c85c8de0c05b5d8be8e8029e4f6b813270293
Permission-LocationWhenInUse: 50736c907dff5a77d80a3503d3b333831148288b
RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8
RCTRequired: 3e917ea5377751094f38145fdece525aa90545a0
RCTTypeSafety: c43c072a4bd60feb49a9570b0517892b4305c45e
@@ -573,15 +573,15 @@ SPEC CHECKSUMS:
React-jsiexecutor: b7b553412f2ec768fe6c8f27cd6bafdb9d8719e6
React-jsinspector: c5989c77cb89ae6a69561095a61cce56a44ae8e8
React-logger: a0833912d93b36b791b7a521672d8ee89107aff1
react-native-cameraroll: 2957f2bce63ae896a848fbe0d5352c1bd4d20866
react-native-cameraroll: 60ac50a5209777cbccfe8d7a62d0743a9da87060
react-native-config: 6502b1879f97ed5ac570a029961fc35ea606cd14
react-native-geocoder-reborn: c31cbc630d9307ebbceea1dea2746d0054be35c4
react-native-geolocation-service: c0efb872258ed9240f1003a70fca9e9757e5c785
react-native-image-picker: cffb727cf2f59bd5c0408e30b3dbe0b935f88835
react-native-image-resizer: d9fb629a867335bdc13230ac2a58702bb8c8828f
react-native-image-picker: 9f9eb160a4f4fc0702f9d0a60fa453b413258ef8
react-native-image-resizer: 506412a2bdd70dde64a61e13505ce10f61a04369
react-native-maps: 8b8bfada2c86205a7f5a07dd1f92f29b33ea83aa
react-native-netinfo: 3671b091c4843fda5e153612866ef4024b8f5d62
react-native-safe-area-context: f98b0b16d1546d208fc293b4661e3f81a895afd9
react-native-netinfo: ebbcd8fbe1a0ce7035e43cd18c5a545dcb93dd08
react-native-safe-area-context: a95ad1a0e18341f58c5d6f53b0e2d43efade1a67
react-native-sensitive-info: d44e909d065f9c0e15734245e5dd6a24b82e3dcd
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
React-RCTActionSheet: 547fe42fdb4b6089598d79f8e1d855d7c23e2162
@@ -596,20 +596,20 @@ SPEC CHECKSUMS:
React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
RealmJS: 772520fb85c19b65c2ea0c8f9aa6e790a905a377
RNAudioRecorderPlayer: 001f01049754e978c43af97162452c8563d5794a
RNAudioRecorderPlayer: 4482e03d9336c8d8ba41825af227f74f41ba0a01
RNCAsyncStorage: d81ee5c3db1060afd49ea7045ad460eff82d2b7d
RNCCheckbox: ed1b4ca295475b41e7251ebae046360a703b6eb5
RNCCheckbox: 934e11d33abb6b0db105cfa5a22a42bd89dfb4b9
RNCPicker: abc646b53a3d28ccfa3232c927a0ca52e0cf024d
RNDateTimePicker: 04b805a3cb4d386e5e6aff54b47ace7bad706fda
RNDeviceInfo: aad3c663b25752a52bf8fce93f2354001dd185aa
RNDateTimePicker: 47ab186e06826a4af7051b8a8199fac9477f339e
RNDeviceInfo: af47a7d1a09d64c8cbb8b0c883db9bca422a9813
RNFS: fc610f78fdf8bfc89a9e5cc2f898519f4dba1002
RNGestureHandler: 4f4986408310a43f1606c391f38f76e0d6e790d5
RNLocalize: cbcb55d0e19c78086ea4eea20e03fe8000bbbced
RNPermissions: 34d678157c800b25b22a488e4d8babb57456e796
RNLocalize: 414c99a0f6625a58dfee7f5780678d98c4ffabc2
RNPermissions: 7ad8d93b004e6bc85c89e27a102afcde90e07efa
RNReanimated: e42de406edd11350af29016cf6802ef16ee364d0
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
RNVectorIcons: 7923e585eaeb139b9f4531d25a125a1500162a0b
VisionCamera: c1c171fcdbf18c438987847f785829c5638f3a4c
VisionCamera: 420cba086c205b4dc102c3ad1158f72e41ad36c2
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
PODFILE CHECKSUM: 51a6b3d8767f92f6783584e73a5acbcb4074e65d

2063
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,10 @@
"translate": "node src/i18n/i18ncli.js build"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.17.7",
"@babel/eslint-parser": "^7.18.2",
"@babel/preset-react": "^7.18.6",
"@gorhom/bottom-sheet": "^4.3.1",
"@react-native-async-storage/async-storage": "^1.17.7",
"@react-native-community/cameraroll": "^4.1.2",
"@react-native-community/checkbox": "^0.5.12",
"@react-native-community/datetimepicker": "^6.1.0",
@@ -37,6 +39,7 @@
"i18next-resources-to-backend": "^1.0.0",
"inaturalistjs": "github:inaturalist/inaturalistjs",
"intl-pluralrules": "^1.3.1",
"lodash": "^4.17.21",
"radio-buttons-react-native": "^1.0.4",
"react": "17.0.2",
"react-i18next": "^11.16.1",
@@ -77,19 +80,20 @@
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^3.0.3",
"@testing-library/react-native": "^9.0.0",
"babel-jest": "^26.6.3",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-flowtype": "^7.0.0",
"eslint-plugin-i18next": "^6.0.0-2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-native": "^4.0.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"factoria": "^3.2.2",
"faker": "^5.5.3",
"flow-bin": "^0.162.0",

View File

@@ -1,11 +1,11 @@
// @flow
import React from "react";
import { getVersion, getBuildNumber } from "react-native-device-info";
import type { Node } from "react";
import ViewWithFooter from "./SharedComponents/ViewWithFooter";
import React from "react";
import { getBuildNumber, getVersion } from "react-native-device-info";
import PlaceholderText from "./PlaceholderText";
import ViewWithFooter from "./SharedComponents/ViewWithFooter";
const AboutScreen = ( ): Node => {
const appVersion = getVersion( );

View File

@@ -1,12 +1,12 @@
// @flow
import * as React from "react";
import { View, Pressable } from "react-native";
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import { Pressable, View } from "react-native";
import { Avatar } from "react-native-paper";
import { textStyles, viewStyles } from "../../styles/sharedComponents/modal";
import { ObsEditContext } from "../../providers/contexts";
import { textStyles, viewStyles } from "../../styles/sharedComponents/modal";
import TranslatedText from "../SharedComponents/TranslatedText";
type Props = {
@@ -50,29 +50,21 @@ const CameraOptionsModal = ( { closeModal }: Props ): React.Node => {
<TranslatedText text="Record-a-sound" />
<TranslatedText text="Submit-without-evidence" />
</View>
<Pressable
onPress={navToStandardCamera}
>
<Pressable onPress={navToStandardCamera}>
<Avatar.Icon size={40} icon="camera" />
</Pressable>
{!currentObs && (
<Pressable
onPress={navToPhotoGallery}
>
<Pressable onPress={navToPhotoGallery}>
<Avatar.Icon size={40} icon="folder-multiple-image" />
</Pressable>
)}
{!hasSound && (
<Pressable
onPress={navToSoundRecorder}
>
<Pressable onPress={navToSoundRecorder}>
<Avatar.Icon size={40} icon="microphone" />
</Pressable>
)}
{!currentObs && (
<Pressable
onPress={navToObsEdit}
>
<Pressable onPress={navToObsEdit}>
<Avatar.Icon size={40} icon="square-edit-outline" />
</Pressable>
)}

View File

@@ -1,14 +1,16 @@
// @flow
import React, { useRef, useState, useEffect } from "react";
import { StyleSheet, Animated } from "react-native";
import { Camera } from "react-native-vision-camera";
import type { Node } from "react";
import { useIsFocused } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Animated, StyleSheet } from "react-native";
import { PinchGestureHandler, TapGestureHandler } from "react-native-gesture-handler";
import Reanimated, { Extrapolate, interpolate, useAnimatedGestureHandler, useAnimatedProps, useSharedValue } from "react-native-reanimated";
import Reanimated, {
Extrapolate, interpolate, useAnimatedGestureHandler, useAnimatedProps, useSharedValue
} from "react-native-reanimated";
import { Camera } from "react-native-vision-camera";
import FocusSquare from "./FocusSquare";
import { useIsForeground } from "./hooks/useIsForeground";
import useIsForeground from "./hooks/useIsForeground";
// a lot of the camera functionality (pinch to zoom, etc.) is lifted from the example library:
// https://github.com/mrousavy/react-native-vision-camera/blob/7335883969c9102b8a6d14ca7ed871f3de7e1389/example/src/CameraPage.tsx
@@ -43,11 +45,13 @@ const CameraView = ( { camera, device }: Props ): Node => {
zoom: z
};
}, [maxZoom, minZoom, zoom] );
//#endregion
// #endregion
//#region Pinch to Zoom Gesture
// The gesture handler maps the linear pinch gesture (0 - 1) to an exponential curve since a camera's zoom
// function does not appear linear to the user. (aka zoom 0.1 -> 0.2 does not look equal in difference as 0.8 -> 0.9)
// #region Pinch to Zoom Gesture
// The gesture handler maps the linear pinch gesture (0 - 1) to an
// exponential curve since a camera's zoom function does not appear linear
// to the user. (aka zoom 0.1 -> 0.2 does not look equal in difference as
// 0.8 -> 0.9)
const onPinchGesture = useAnimatedGestureHandler( {
onStart: ( _, context ) => {
context.startZoom = zoom.value;
@@ -55,13 +59,23 @@ const CameraView = ( { camera, device }: Props ): Node => {
onActive: ( event, context ) => {
// we're trying to map the scale gesture to a linear zoom here
const startZoom = context.startZoom ?? 0;
const scale = interpolate( event.scale, [1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM], [-1, 0, 1], Extrapolate.CLAMP );
zoom.value = interpolate( scale, [-1, 0, 1], [minZoom, startZoom, maxZoom], Extrapolate.CLAMP );
const scale = interpolate(
event.scale,
[1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM],
[-1, 0, 1],
Extrapolate.CLAMP
);
zoom.value = interpolate(
scale,
[-1, 0, 1],
[minZoom, startZoom, maxZoom],
Extrapolate.CLAMP
);
}
} );
//#endregion
// #endregion
//#region Effects
// #region Effects
const neutralZoom = device?.neutralZoom ?? 1;
useEffect( ( ) => {
// Run everytime the neutralZoomScaled value changes. (reset zoom when device changes)

View File

@@ -1,8 +1,8 @@
// @flow
import React, { useEffect } from "react";
import { View, Animated } from "react-native";
import type { Node } from "react";
import React, { useEffect } from "react";
import { Animated, View } from "react-native";
import { viewStyles } from "../../styles/camera/standardCamera";
@@ -35,7 +35,8 @@ const FocusSquare = ( { tappedCoordinates, tapToFocusAnimation }: Props ): Node
left: tappedCoordinates.x,
top: tappedCoordinates.y
}
]} />
]}
/>
</Animated.View>
);
};

View File

@@ -1,15 +1,15 @@
// @flow
import React, { useState } from "react";
import { Text } from "react-native";
import type { Node } from "react";
import { Portal, Modal } from "react-native-paper";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { Modal, Portal } from "react-native-paper";
import { viewStyles, textStyles } from "../../styles/camera/standardCamera";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
import { textStyles, viewStyles } from "../../styles/camera/standardCamera";
import MediaViewer from "../MediaViewer/MediaViewer";
import DeletePhotoDialog from "../SharedComponents/DeletePhotoDialog";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
type Props = {
photoUris: Array<string>,
@@ -26,7 +26,7 @@ const PhotoPreview = ( { photoUris, setPhotoUris }: Props ): Node => {
const showModal = ( ) => setMediaViewerVisible( true );
const hideModal = ( ) => setMediaViewerVisible( false );
const handleSelection = ( photoUri ) => {
const handleSelection = photoUri => {
setInitialPhotoSelected( photoUri );
showModal( );
};
@@ -38,7 +38,7 @@ const PhotoPreview = ( { photoUris, setPhotoUris }: Props ): Node => {
setDeleteDialogVisible( false );
};
const handleDelete = ( photoUri ) => {
const handleDelete = photoUri => {
setPhotoUriToDelete( photoUri );
showDialog( );
};

View File

@@ -1,21 +1,23 @@
// @flow
import React, { useRef, useState, useContext, useEffect } from "react";
import { Text, View, Pressable } from "react-native";
import { Camera, useCameraDevices } from "react-native-vision-camera";
import type { Node } from "react";
import { useNavigation, useRoute } from "@react-navigation/native";
import { Avatar, useTheme } from "react-native-paper";
import Realm from "realm";
import { t } from "i18next";
import type { Node } from "react";
import React, {
useContext, useEffect, useRef, useState
} from "react";
import { Pressable, Text, View } from "react-native";
import { Avatar, useTheme } from "react-native-paper";
import { Camera, useCameraDevices } from "react-native-vision-camera";
import Realm from "realm";
import { viewStyles } from "../../styles/camera/standardCamera";
import { ObsEditContext } from "../../providers/contexts";
import CameraView from "./CameraView";
import PhotoPreview from "./PhotoPreview";
import { textStyles } from "../../styles/obsDetails/obsDetails";
import realmConfig from "../../models/index";
import Photo from "../../models/Photo";
import { ObsEditContext } from "../../providers/contexts";
import { viewStyles } from "../../styles/camera/standardCamera";
import { textStyles } from "../../styles/obsDetails/obsDetails";
import CameraView from "./CameraView";
import PhotoPreview from "./PhotoPreview";
const StandardCamera = ( ): Node => {
const { colors } = useTheme( );
@@ -72,7 +74,9 @@ const StandardCamera = ( ): Node => {
}
}, [photos] );
const renderCameraButton = ( icon ) => <Avatar.Icon size={40} icon={icon} style={{ backgroundColor: colors.background }} />;
const renderCameraButton = icon => (
<Avatar.Icon size={40} icon={icon} style={{ backgroundColor: colors.background }} />
);
return (
<View style={viewStyles.container}>

View File

@@ -1,8 +1,7 @@
import { useState } from "react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
export const useIsForeground = ( ): boolean => {
export default ( ): boolean => {
const [isForeground, setIsForeground] = useState( true );
useEffect( ( ) => {

View File

@@ -1,22 +1,24 @@
// @flow
import React from "react";
import {
DrawerContentScrollView,
DrawerItem
} from "@react-navigation/drawer";
import type { Node } from "react";
import React from "react";
type Props = {
props: any
state: any,
navigation: any,
descriptors: any
}
const CustomDrawerContent = ( { ...props }: Props ): Node => {
// $FlowFixMe
const { navigation } = props;
const { state, navigation, descriptors } = props;
return (
<DrawerContentScrollView {...props}>
<DrawerContentScrollView state={state} navigation={navigation} descriptors={descriptors}>
<DrawerItem
label="identify"
onPress={( ) => navigation.navigate( "identify" )}

View File

@@ -1,13 +1,13 @@
// @flow
import React, { useContext } from "react";
import type { Node } from "react";
import React, { useContext } from "react";
import { View } from "react-native";
import { viewStyles } from "../../styles/explore/explore";
import DropdownPicker from "./DropdownPicker";
import { ExploreContext } from "../../providers/contexts";
import { viewStyles } from "../../styles/explore/explore";
import TranslatedText from "../SharedComponents/TranslatedText";
import DropdownPicker from "./DropdownPicker";
import FiltersIcon from "./FiltersIcon";
const Explore = ( ): Node => {
@@ -19,14 +19,14 @@ const Explore = ( ): Node => {
location,
setLocation
} = useContext( ExploreContext );
const setTaxonId = ( getValue ) => {
const setTaxonId = getValue => {
setExploreFilters( {
...exploreFilters,
taxon_id: getValue( )
} );
};
const setPlaceId = ( getValue ) => {
const setPlaceId = getValue => {
setExploreFilters( {
...exploreFilters,
place_id: getValue( )

View File

@@ -1,13 +1,13 @@
// flow
import React from "react";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Image } from "react-native";
// TODO: we'll probably need a custom dropdown picker which looks like a search bar
// and allows users to input immediately instead of first tapping the dropdown
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import { t } from "i18next";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { imageStyles, viewStyles } from "../../styles/explore/explore";
@@ -41,54 +41,51 @@ const DropdownPicker = ( {
}: Props ): Node => {
const searchResults = useRemoteSearchResults( searchQuery, sources );
const placesItem = place => {
return {
label: place.name,
value: place.uuid
};
};
const placesItem = place => ( {
label: place.name,
value: place.uuid
} );
const taxonIcon = taxa => <Image source={{ uri: taxa.default_photo.url }} style={imageStyles.pickerIcon} />;
const userIcon = user => <Image source={{ uri: user.icon }} style={imageStyles.circularPickerIcon} />;
const projectIcon = project => <Image source={{ uri: project.icon }} style={imageStyles.pickerIcon} />;
const taxonIcon = taxa => (
<Image source={{ uri: taxa.default_photo.url }} style={imageStyles.pickerIcon} />
);
const userIcon = user => (
<Image source={{ uri: user.icon }} style={imageStyles.circularPickerIcon} />
);
const projectIcon = project => (
<Image source={{ uri: project.icon }} style={imageStyles.pickerIcon} />
);
const taxonItem = taxa => {
// console.log( taxa, "taxa in item" );
return {
// TODO: match styling on the web; only show matched_term if the common name isn't clearly
// linked to the search result
label: `${taxa.preferred_common_name} (${taxa.matched_term})`,
value: taxa.id,
icon: taxonIcon( taxa )
};
};
const taxonItem = taxa => ( {
// TODO: match styling on the web; only show matched_term if the common name isn't clearly
// linked to the search result
label: `${taxa.preferred_common_name} (${taxa.matched_term})`,
value: taxa.id,
icon: taxonIcon( taxa )
} );
const userItem = user => ( {
label: user.login,
value: user.id,
icon: userIcon( user )
} );
const userItem = user => {
return {
label: user.login,
value: user.id,
icon: userIcon( user )
};
};
const projectItem = project => {
return {
label: project.title,
value: project.id,
icon: projectIcon( project )
};
};
const projectItem = project => ( {
label: project.title,
value: project.id,
icon: projectIcon( project )
} );
const displayItems = ( ) => {
if ( sources === "places" ) {
return searchResults.map( item => placesItem( item ) );
} else if ( sources === "taxa" ) {
} if ( sources === "taxa" ) {
return searchResults.map( item => taxonItem( item ) );
} else if ( sources === "users" ) {
} if ( sources === "users" ) {
return searchResults.map( item => userItem( item ) );
} else if ( sources === "projects" ) {
} if ( sources === "projects" ) {
return searchResults.map( item => projectItem( item ) );
}
return [];
};
return (
@@ -101,8 +98,8 @@ const DropdownPicker = ( {
value={value}
items={displayItems( )}
setValue={setValue}
searchable={true}
disableLocalSearch={true}
searchable
disableLocalSearch
onChangeSearchText={setSearchQuery}
placeholder={t( placeholder )}
style={viewStyles.dropdown}

View File

@@ -1,12 +1,12 @@
// @flow
import React, { useContext } from "react";
import type { Node } from "react";
import React, { useContext } from "react";
import { Dimensions } from "react-native";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import { ExploreContext } from "../../providers/contexts";
import ObservationViews from "../SharedComponents/ObservationViews/ObservationViews";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import BottomCard from "./BottomCard";
const { height } = Dimensions.get( "screen" );

View File

@@ -1,22 +1,22 @@
// @flow
import React, { useState, useContext } from "react";
import CheckBox from "@react-native-community/checkbox";
import { t } from "i18next";
import RadioButtonRN from "radio-buttons-react-native";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import { View } from "react-native";
import RNPickerSelect from "react-native-picker-select";
import type { Node } from "react";
import CheckBox from "@react-native-community/checkbox";
import RadioButtonRN from "radio-buttons-react-native";
import { t } from "i18next";
import { pickerSelectStyles, viewStyles } from "../../styles/explore/exploreFilters";
import { ExploreContext } from "../../providers/contexts";
import DropdownPicker from "./DropdownPicker";
import TaxonLocationSearch from "./TaxonLocationSearch";
import { pickerSelectStyles, viewStyles } from "../../styles/explore/exploreFilters";
import InputField from "../SharedComponents/InputField";
import ScrollNoFooter from "../SharedComponents/ScrollNoFooter";
import TranslatedText from "../SharedComponents/TranslatedText";
import DropdownPicker from "./DropdownPicker";
import ExploreFooter from "./ExploreFooter";
import InputField from "../SharedComponents/InputField";
import ResetFiltersButton from "./ResetFiltersButton";
import TaxonLocationSearch from "./TaxonLocationSearch";
const ExploreFilters = ( ): Node => {
const [project, setProject] = useState( "" );
@@ -28,14 +28,14 @@ const ExploreFilters = ( ): Node => {
setUnappliedFilters
} = useContext( ExploreContext );
const setProjectId = ( getValue ) => {
const setProjectId = getValue => {
setUnappliedFilters( {
...unappliedFilters,
project_id: getValue( )
} );
};
const setUserId = ( getValue ) => {
const setUserId = getValue => {
setUnappliedFilters( {
...unappliedFilters,
user_id: getValue( )
@@ -97,7 +97,7 @@ const ExploreFilters = ( ): Node => {
{ label: t( "Ranks-stateofmatter" ), value: "stateofmatter" },
{ label: t( "Ranks-kingdom" ), value: "kingdom" },
{ label: t( "Ranks-subkingdom" ), value: "subkingdom" },
{ label: t( "Ranks-phylum" ), value: "phylum" },
{ label: t( "Ranks-phylum" ), value: "phylum" },
{ label: t( "Ranks-subphylum" ), value: "subphylum" },
{ label: t( "Ranks-superclass" ), value: "superclass" },
{ label: t( "Ranks-class" ), value: "class" },
@@ -135,7 +135,7 @@ const ExploreFilters = ( ): Node => {
const projectId = unappliedFilters ? unappliedFilters.project_id : null;
const userId = unappliedFilters ? unappliedFilters.user_id : null;
const renderQualityGradeCheckbox = ( qualityGrade ) => {
const renderQualityGradeCheckbox = qualityGrade => {
const filter = unappliedFilters.quality_grade;
const hasFilter = filter.includes( qualityGrade );
@@ -163,7 +163,7 @@ const ExploreFilters = ( ): Node => {
);
};
const renderMediaCheckbox = ( mediaType ) => {
const renderMediaCheckbox = mediaType => {
const { sounds, photos } = unappliedFilters;
return (
<CheckBox
@@ -188,8 +188,10 @@ const ExploreFilters = ( ): Node => {
);
};
const renderStatusCheckbox = ( status ) => {
const { native, captive, introduced, threatened } = unappliedFilters;
const renderStatusCheckbox = status => {
const {
native, captive, introduced, threatened
} = unappliedFilters;
let value;
@@ -220,9 +222,9 @@ const ExploreFilters = ( ): Node => {
);
};
const renderRankPicker = ( rank ) => (
const renderRankPicker = rank => (
<RNPickerSelect
onValueChange={( itemValue ) => {
onValueChange={itemValue => {
setUnappliedFilters( {
...unappliedFilters,
// $FlowFixMe
@@ -242,7 +244,7 @@ const ExploreFilters = ( ): Node => {
const includesMonth = value => unappliedFilters.months.includes( value );
const fillInMonths = ( itemValue ) => {
const fillInMonths = itemValue => {
months.forEach( ( { value } ) => {
if ( value >= firstMonth && value <= itemValue && !includesMonth( value ) ) {
unappliedFilters.months.push( value );
@@ -257,7 +259,7 @@ const ExploreFilters = ( ): Node => {
return (
<>
<RNPickerSelect
onValueChange={( itemValue ) => {
onValueChange={itemValue => {
unappliedFilters.months = [itemValue];
setUnappliedFilters( { ...unappliedFilters } );
}}
@@ -267,7 +269,7 @@ const ExploreFilters = ( ): Node => {
value={firstMonth}
/>
<RNPickerSelect
onValueChange={( itemValue ) => fillInMonths( itemValue )}
onValueChange={itemValue => fillInMonths( itemValue )}
items={months}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
@@ -287,20 +289,20 @@ const ExploreFilters = ( ): Node => {
initial={1}
boxStyle={viewStyles.radioButtonBox}
selectedBtn={( { type } ) => {
if ( type === "desc" || type === "asc" ) {
setExploreFilters( {
...exploreFilters,
order: type,
order_by: "created_at"
} );
} else {
if ( type === "desc" || type === "asc" ) {
setExploreFilters( {
...exploreFilters,
order: type,
order_by: "created_at"
} );
} else {
// votes or observed_on only sort by most recent
setExploreFilters( {
...exploreFilters,
order: "desc",
order_by: type
} );
}
setExploreFilters( {
...exploreFilters,
order: "desc",
order_by: type
} );
}
}}
/>
<View style={viewStyles.filtersRow}>
@@ -396,7 +398,7 @@ const ExploreFilters = ( ): Node => {
/>
<TranslatedText text="Photo-Licensing" />
<RNPickerSelect
onValueChange={( itemValue ) => {
onValueChange={itemValue => {
setUnappliedFilters( {
...unappliedFilters,
photo_license: itemValue === "all" ? [] : [itemValue]
@@ -405,11 +407,15 @@ const ExploreFilters = ( ): Node => {
items={photoLicenses}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
value={unappliedFilters.photo_license.length > 0 ? unappliedFilters.photo_license[0] : "all"}
value={
unappliedFilters.photo_license.length > 0
? unappliedFilters.photo_license[0]
: "all"
}
/>
<TranslatedText text="Description-Tags" />
<InputField
handleTextChange={( q ) => {
handleTextChange={q => {
setUnappliedFilters( {
...unappliedFilters,
q

View File

@@ -1,10 +1,10 @@
// @flow
import React from "react";
import { View } from "react-native";
import type { Node } from "react";
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { View } from "react-native";
import { ExploreContext } from "../../providers/contexts";
import { viewStyles } from "../../styles/explore/exploreFilters";
@@ -26,7 +26,7 @@ const ExploreFooter = ( ): Node => {
return (
<View style={viewStyles.footer}>
<HeaderBackButton onPress={clearFiltersAndNavigate} style={viewStyles.element}/>
<HeaderBackButton onPress={clearFiltersAndNavigate} style={viewStyles.element} />
<RoundGreenButton
handlePress={applyFiltersAndNavigate}
buttonText="Apply Filters"

View File

@@ -1,16 +1,16 @@
// @flow
import React, { useContext } from "react";
import type { Node } from "react";
import { View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useContext } from "react";
import { View } from "react-native";
import { textStyles, viewStyles } from "../../styles/explore/explore";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
// import DropdownPicker from "./DropdownPicker";
import { ExploreContext } from "../../providers/contexts";
import { textStyles, viewStyles } from "../../styles/explore/explore";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import TranslatedText from "../SharedComponents/TranslatedText";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import FiltersIcon from "./FiltersIcon";
import TaxonLocationSearch from "./TaxonLocationSearch";

View File

@@ -1,8 +1,8 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import { Pressable } from "react-native";
import { useNavigation } from "@react-navigation/native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { viewStyles } from "../../styles/observations/messagesIcon";

View File

@@ -3,8 +3,8 @@
import * as React from "react";
import { Pressable } from "react-native";
import { viewStyles } from "../../styles/observations/messagesIcon";
import { ExploreContext } from "../../providers/contexts";
import { viewStyles } from "../../styles/observations/messagesIcon";
import TranslatedText from "../SharedComponents/TranslatedText";
const ResetFiltersButton = ( ): React.Node => {

View File

@@ -1,11 +1,11 @@
// @flow
import React, { useContext, useState, useCallback } from "react";
import type { Node } from "react";
import React, { useCallback, useContext, useState } from "react";
import DropdownPicker from "./DropdownPicker";
import { ExploreContext } from "../../providers/contexts";
import TranslatedText from "../SharedComponents/TranslatedText";
import DropdownPicker from "./DropdownPicker";
const TaxonLocationSearch = ( ): Node => {
const {
@@ -35,14 +35,14 @@ const TaxonLocationSearch = ( ): Node => {
setTaxonOpen( false );
}, [] );
const setTaxonId = ( getValue ) => {
const setTaxonId = getValue => {
setExploreFilters( {
...exploreFilters,
taxon_id: getValue( )
} );
};
const setPlaceId = ( getValue ) => {
const setPlaceId = getValue => {
setExploreFilters( {
...exploreFilters,
place_id: getValue( )

View File

@@ -1,23 +1,21 @@
// @flow
import React, { useState } from "react";
import { Text, View, Image } from "react-native";
import type { Node } from "react";
import React, { useState } from "react";
import { Image, Text, View } from "react-native";
import TinderCard from "react-tinder-card";
import { viewStyles, imageStyles, textStyles } from "../../styles/identify/identify";
import Observation from "../../models/Observation";
import markAsReviewed from "./helpers/markAsReviewed";
import createIdentification from "./helpers/createIdentification";
import { imageStyles, textStyles, viewStyles } from "../../styles/identify/identify";
import PlaceholderText from "../PlaceholderText";
import createIdentification from "./helpers/createIdentification";
import markAsReviewed from "./helpers/markAsReviewed";
type Props = {
loading: boolean,
observationList: Array<Object>,
testID: string
}
const CardSwipeView = ( { loading, observationList, testID }: Props ): Node => {
const CardSwipeView = ( { observationList }: Props ): Node => {
const [totalSwiped, setTotalSwiped] = useState( 0 );
const onSwipe = async ( direction, id, isSpecies, agreeParams ) => {
if ( direction === "left" ) {
@@ -26,7 +24,7 @@ const CardSwipeView = ( { loading, observationList, testID }: Props ): Node => {
const agreed = await createIdentification( agreeParams );
console.log( agreed, "agreed in card swipe" );
}
console.log( "You swiped: " + direction );
console.log( `You swiped: ${direction}` );
};
const onCardLeftScreen = ( ) => {
@@ -56,7 +54,7 @@ const CardSwipeView = ( { loading, observationList, testID }: Props ): Node => {
return (
<TinderCard
key={obs.id}
onSwipe={( dir ) => onSwipe( dir, obs.uuid, isSpecies, agreeParams )}
onSwipe={dir => onSwipe( dir, obs.uuid, isSpecies, agreeParams )}
onCardLeftScreen={onCardLeftScreen}
preventSwipe={preventSwipeDirections}
>

View File

@@ -1,11 +1,17 @@
// @flow
import React, { useState } from "react";
import { Pressable, Text, Image, View, ActivityIndicator } from "react-native";
import type { Node } from "react";
import React, { useState } from "react";
import {
ActivityIndicator, Image, Pressable, Text, View
} from "react-native";
import Observation from "../../models/Observation";
import { textStyles, imageStyles, viewStyles } from "../../styles/sharedComponents/observationViews/gridItem";
import {
imageStyles,
textStyles,
viewStyles
} from "../../styles/sharedComponents/observationViews/gridItem";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import createIdentification from "./helpers/createIdentification";
@@ -16,7 +22,9 @@ type Props = {
setReviewedIds: Function
}
const GridItem = ( { item, handlePress, reviewedIds, setReviewedIds }: Props ): Node => {
const GridItem = ( {
item, handlePress, reviewedIds, setReviewedIds
}: Props ): Node => {
const [showLoadingWheel, setShowLoadingWheel] = useState( false );
const commonName = item.taxon && item.taxon.preferred_common_name;
const name = item.taxon ? item.taxon.name : "unknown";
@@ -31,7 +39,10 @@ const GridItem = ( { item, handlePress, reviewedIds, setReviewedIds }: Props ):
const agreeWithObservation = async ( ) => {
setShowLoadingWheel( true );
const results = await createIdentification( { observation_id: item.uuid, taxon_id: item.taxon.id } );
const results = await createIdentification( {
observation_id: item.uuid,
taxon_id: item.taxon.id
} );
if ( results === 1 ) {
const ids = Array.from( reviewedIds );
ids.push( item.id );

View File

@@ -1,30 +1,24 @@
// @flow
import React, { useState } from "react";
import { FlatList, ActivityIndicator } from "react-native";
// import { useNavigation, useRoute } from "@react-navigation/native";
import type { Node } from "react";
import React, { useState } from "react";
import { ActivityIndicator, FlatList } from "react-native";
import GridItem from "./GridItem";
type Props = {
loading: boolean,
observationList: Array<Object>,
testID: string,
taxonId?: number
testID: string
}
const GridView = ( {
loading,
observationList,
testID,
taxonId
testID
}: Props ): Node => {
const [reviewedIds, setReviewedIds] = useState( [] );
// const navigation = useNavigation( );
// const { name } = useRoute( );
// const navToObsDetails = observation => navigation.navigate( "ObsDetails", { uuid: observation.uuid } );
const renderGridItem = ( { item } ) => (
<GridItem

View File

@@ -1,16 +1,16 @@
// @flow
import React, { useState } from "react";
import type { Node } from "react";
import { Text, View, Pressable } from "react-native";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import useObservations from "./hooks/useObservations";
import GridView from "./GridView";
import { viewStyles } from "../../styles/identify/identify";
import DropdownPicker from "../Explore/DropdownPicker";
import { viewStyles } from "./../../styles/identify/identify";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import CardSwipeView from "./CardSwipeView";
import GridView from "./GridView";
import useObservations from "./hooks/useObservations";
const Identify = ( ): Node => {
const [view, setView] = React.useState( "grid" );
@@ -20,8 +20,8 @@ const Identify = ( ): Node => {
const [taxonId, setTaxonId] = useState( null );
const { observations, loading } = useObservations( placeId, taxonId );
const updatePlaceId = ( getValue ) => setPlaceId( getValue( ) );
const updateTaxonId = ( getValue ) => setTaxonId( getValue( ) );
const updatePlaceId = getValue => setPlaceId( getValue( ) );
const updateTaxonId = getValue => setTaxonId( getValue( ) );
const setGridView = ( ) => setView( "grid" );
const setCardView = ( ) => setView( "card" );
@@ -29,21 +29,16 @@ const Identify = ( ): Node => {
const renderView = ( ) => {
if ( view === "card" ) {
return (
<CardSwipeView
loading={loading}
observationList={observations}
testID="Identify.cardView"
/>
);
} else {
return (
<GridView
loading={loading}
observationList={observations}
testID="Identify.observationGrid"
/>
<CardSwipeView observationList={observations} />
);
}
return (
<GridView
loading={loading}
observationList={observations}
testID="Identify.observationGrid"
/>
);
};
const { t } = useTranslation( );
@@ -51,7 +46,7 @@ const Identify = ( ): Node => {
return (
<ViewWithFooter>
<View style={viewStyles.toggleViewRow}>
<Pressable
<Pressable
onPress={setCardView}
accessibilityRole="button"
>
@@ -64,7 +59,7 @@ const Identify = ( ): Node => {
>
<Text>{ t( "Grid-View" ) }</Text>
</Pressable>
</View>
</View>
<DropdownPicker
searchQuery={location}
setSearchQuery={setLocation}
@@ -79,7 +74,7 @@ const Identify = ( ): Node => {
sources="taxa"
value={taxonId}
/>
{renderView( )}
{renderView( )}
</ViewWithFooter>
);
};

View File

@@ -1,11 +1,10 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import Observation from "../../../models/Observation";
const useObservations = ( placeId: ?string, taxonId: ?number ): {
observations: Array<Object>,
loading: boolean
@@ -39,14 +38,14 @@ const useObservations = ( placeId: ?string, taxonId: ?number ): {
params.fields.observation_photos.photo.medium_url = true;
const response = await inatjs.observations.search( params );
const results = response.results;
const { results } = response;
if ( !isCurrent ) { return; }
setLoading( false );
setObservations( results );
} catch ( e ) {
setLoading( false );
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch observations for identify:", JSON.stringify( e.response ), );
console.log( "Couldn't fetch observations for identify:", JSON.stringify( e.response ) );
}
};

View File

@@ -1,58 +1,68 @@
// @flow
import { create } from "apisauce";
import { Platform } from "react-native";
import Config from "react-native-config";
import SInfo from "react-native-sensitive-info";
import {
getBuildNumber, getDeviceType, getSystemName, getSystemVersion, getVersion
} from "react-native-device-info";
import jwt from "react-native-jwt-io";
import * as RNLocalize from "react-native-localize";
import RNSInfo from "react-native-sensitive-info";
import jwt from "react-native-jwt-io";
import {Platform} from "react-native";
import {getBuildNumber, getDeviceType, getSystemName, getSystemVersion, getVersion} from "react-native-device-info";
import Realm from "realm";
// eslint-disable-next-line import/no-cycle
import realmConfig from "../../models/index";
// Base API domain can be overridden (in case we want to use staging URL) - either by placing it in .env file, or
// in an environment variable.
// Base API domain can be overridden (in case we want to use staging URL) -
// either by placing it in .env file, or in an environment variable.
const API_HOST: string = Config.OAUTH_API_URL || process.env.OAUTH_API_URL || "https://www.inaturalist.org";
// User agent being used, when calling the iNat APIs
// eslint-disable-next-line max-len
const USER_AGENT = `iNaturalistRN/${getVersion()} ${getDeviceType()} (Build ${getBuildNumber()}) ${getSystemName()}/${getSystemVersion()}`;
const JWT_TOKEN_EXPIRATION_MINS = 25; // JWT Tokens expire after 30 mins - consider 25 mins as the max time (safe margin)
// JWT Tokens expire after 30 mins - consider 25 mins as the max time (safe margin)
const JWT_TOKEN_EXPIRATION_MINS = 25;
/**
* Creates base API client for all requests
* @param additionalHeaders any additional headers that will be passed to the API
*/
const createAPI = ( additionalHeaders: any ) => {
return create( {
baseURL: API_HOST,
headers: { "User-Agent": USER_AGENT, ...additionalHeaders }
} );
};
const createAPI = ( additionalHeaders: any ) => create( {
baseURL: API_HOST,
headers: { "User-Agent": USER_AGENT, ...additionalHeaders }
} );
/**
* Returns the API access token to be used with all iNaturalist API calls
* Returns whether we're currently logged in.
*
* @param useJWT if true, we'll use JSON Web Token instead of the "regular" access token
* @param allowAnonymousJWTToken (optional=false) if true and user is not logged-in, use anonymous JWT
* @returns {Promise<string|*>} access token, null if not logged in
* @returns {Promise<boolean>}
*/
const getAPIToken = async ( useJWT: boolean = false, allowAnonymousJWTToken: boolean = false ): Promise<?string> => {
let loggedIn = await isLoggedIn();
if ( !loggedIn ) {
return null;
}
if ( useJWT ) {
return getJWTToken( allowAnonymousJWTToken );
} else {
const accessToken = await RNSInfo.getItem( "accessToken", {} );
return `Bearer ${accessToken}`;
}
const isLoggedIn = async (): Promise<boolean> => {
const accessToken = await RNSInfo.getItem( "accessToken", {} );
return typeof accessToken === "string";
};
/**
* Returns the access token to be used in case of an anonymous JWT (e.g. used when getting taxon suggestions)
* Signs out the user
*
* @returns {Promise<void>}
*/
const signOut = async (
options: { deleteRealm: boolean }
) => {
if ( options?.deleteRealm ) {
Realm.deleteFile( realmConfig );
}
await RNSInfo.deleteItem( "jwtToken", {} );
await RNSInfo.deleteItem( "jwtTokenExpiration", {} );
await RNSInfo.deleteItem( "username", {} );
await RNSInfo.deleteItem( "accessToken", {} );
};
/**
* Returns the access token to be used in case of an anonymous JWT (e.g. used
* when getting taxon suggestions)
* @returns encoded anonymous JWT
*/
const getAnonymousJWTToken = () => {
@@ -67,7 +77,8 @@ const getAnonymousJWTToken = () => {
/**
* Returns most recent JWT (JSON Web Token) for API authentication - renews the token if necessary
*
* @param allowAnonymousJWTToken (optional=false) if true and user is not logged-in, use anonymous JWT
* @param allowAnonymousJWTToken (optional=false) if true and user is not
* logged-in, use anonymous JWT
* @returns {Promise<string|*>}
*/
const getJWTToken = async ( allowAnonymousJWTToken: boolean = false ): Promise<?string> => {
@@ -77,7 +88,7 @@ const getJWTToken = async ( allowAnonymousJWTToken: boolean = false ): Promise<?
jwtTokenExpiration = parseInt( jwtTokenExpiration, 10 );
}
let loggedIn = await isLoggedIn();
const loggedIn = await isLoggedIn();
if ( !loggedIn && allowAnonymousJWTToken ) {
// User not logged in, and anonymous JWT is allowed - return it
@@ -89,18 +100,20 @@ const getJWTToken = async ( allowAnonymousJWTToken: boolean = false ): Promise<?
}
if (
!jwtToken ||
( Date.now() - jwtTokenExpiration ) / 1000 > JWT_TOKEN_EXPIRATION_MINS * 60
!jwtToken
|| ( Date.now() - jwtTokenExpiration ) / 1000 > JWT_TOKEN_EXPIRATION_MINS * 60
) {
// JWT Tokens expire after 30 mins - if the token is non-existent or older than 25 mins (safe margin) - ask for a new one
// JWT Tokens expire after 30 mins - if the token is non-existent or older
// than 25 mins (safe margin) - ask for a new one
const accessToken = await RNSInfo.getItem( "accessToken", {} );
const api = createAPI( { Authorization: `Bearer ${accessToken}` } );
const response = await api.get( "/users/api_token.json" );
if ( !response.ok ) {
// this deletes the user JWT and saved login details when a user is not actually signed in anymore
// for example, if they installed, deleted, and reinstalled the app without logging out
// this deletes the user JWT and saved login details when a user is not
// actually signed in anymore for example, if they installed, deleted,
// and reinstalled the app without logging out
if ( response.status === 401 ) {
signOut( { deleteRealm: true } );
}
@@ -114,105 +127,37 @@ const getJWTToken = async ( allowAnonymousJWTToken: boolean = false ): Promise<?
jwtToken = response.data.api_token;
jwtTokenExpiration = Date.now();
await SInfo.setItem( "jwtToken", jwtToken, {} );
await SInfo.setItem( "jwtTokenExpiration", jwtTokenExpiration.toString(), {} );
await RNSInfo.setItem( "jwtToken", jwtToken, {} );
await RNSInfo.setItem( "jwtTokenExpiration", jwtTokenExpiration.toString(), {} );
return jwtToken;
} else {
// Current JWT token is still fresh/valid - return it as-is
return jwtToken;
}
// Current JWT token is still fresh/valid - return it as-is
return jwtToken;
};
/**
* Authenticates a user and saves authentication details to secure storage, to be used when calling iNat APIs.
* Returns the API access token to be used with all iNaturalist API calls
*
* @param username
* @param password
* @returns false in case of authentication error, true otherwise.
* @param useJWT if true, we'll use JSON Web Token instead of the "regular" access token
* @param allowAnonymousJWTToken (optional=false) if true and user is not
* logged-in, use anonymous JWT
* @returns {Promise<string|*>} access token, null if not logged in
*/
const authenticateUser = async (
username: string,
password: string
): Promise<boolean> => {
const userDetails = await verifyCredentials( username, password );
if ( !userDetails ) {
return false;
const getAPIToken = async (
useJWT: boolean = false,
allowAnonymousJWTToken: boolean = false
): Promise<?string> => {
const loggedIn = await isLoggedIn();
if ( !loggedIn ) {
return null;
}
const { userId, username: remoteUsername, accessToken } = userDetails;
if ( !userId ) {
return false;
if ( useJWT ) {
return getJWTToken( allowAnonymousJWTToken );
}
// Save authentication details to secure storage
await SInfo.setItem( "username", remoteUsername, {} );
await SInfo.setItem( "accessToken", accessToken, {} );
// await SInfo.setItem( "userId", userId, {} );
// Save userId to local, encrypted storage
const currentUser = { id: userId, login: remoteUsername, signedIn: true };
const realm = await Realm.open( realmConfig );
realm.write( ( ) => {
realm?.create( "User", currentUser, "modified" );
} );
realm.close( );
return true;
};
/**
* Registers a new user
*
* @param email
* @param username
* @param password
* @param license (optional)
* @param time_zone (optional)
*
* @returns null if successful, otherwise an error string
*/
const registerUser = async (
email: string,
username: string,
password: string,
license: void | string,
time_zone: void | string
): any => {
const formData = new FormData();
formData.append( "username", username );
formData.append( "user[email]", email );
formData.append( "user[login]", username );
formData.append( "user[password]", password );
formData.append( "user[password_confirmation]", password );
// TODO - support for iNat site_id
if ( license ) {
formData.append( "user[preferred_observation_license]", license );
formData.append( "user[preferred_photo_license]", license );
formData.append( "user[preferred_sound_license]", license );
}
const locales = RNLocalize.getLocales();
formData.append( "user[locale]", locales[0].languageCode );
if ( time_zone ) {
formData.append( "user[time_zone]", time_zone );
}
const api = createAPI();
let response = await api.post( "/users.json", formData );
if ( !response.ok ) {
console.error(
"registerUser failed when calling /users.json - ",
response.problem,
response.status
);
return response.data.errors[0];
}
// console.info( "registerUser - success" );
return null;
const accessToken = await RNSInfo.getItem( "accessToken", {} );
return `Bearer ${accessToken}`;
};
/**
@@ -220,7 +165,8 @@ const registerUser = async (
*
* @param username
* @param password
* @return null in case of error, otherwise an object of accessToken, username (=iNaturalist username)
* @return null in case of error, otherwise an object of accessToken,
* username (=iNaturalist username)
*/
const verifyCredentials = async (
username: string,
@@ -276,20 +222,98 @@ const verifyCredentials = async (
// console.log( "verifyCredentials - logged in username ", iNatUsername );
return {
accessToken: accessToken,
accessToken,
username: iNatUsername,
userId: iNatID
};
};
/**
* Returns whether we're currently logged in.
* Authenticates a user and saves authentication details to secure storage, to
* be used when calling iNat APIs.
*
* @returns {Promise<boolean>}
* @param username
* @param password
* @returns false in case of authentication error, true otherwise.
*/
const isLoggedIn = async (): Promise<boolean> => {
const accessToken = await RNSInfo.getItem( "accessToken", {} );
return typeof accessToken === "string";
const authenticateUser = async (
username: string,
password: string
): Promise<boolean> => {
const userDetails = await verifyCredentials( username, password );
if ( !userDetails ) {
return false;
}
const { userId, username: remoteUsername, accessToken } = userDetails;
if ( !userId ) {
return false;
}
// Save authentication details to secure storage
await RNSInfo.setItem( "username", remoteUsername, {} );
await RNSInfo.setItem( "accessToken", accessToken, {} );
// await SInfo.setItem( "userId", userId, {} );
// Save userId to local, encrypted storage
const currentUser = { id: userId, login: remoteUsername, signedIn: true };
const realm = await Realm.open( realmConfig );
realm.write( ( ) => {
realm?.create( "User", currentUser, "modified" );
} );
realm.close( );
return true;
};
/**
* Registers a new user
*
* @param email
* @param username
* @param password
* @param license (optional)
* @param time_zone (optional)
*
* @returns null if successful, otherwise an error string
*/
const registerUser = async (
email: string,
username: string,
password: string,
license: void | string
): any => {
const formData = new FormData();
formData.append( "username", username );
formData.append( "user[email]", email );
formData.append( "user[login]", username );
formData.append( "user[password]", password );
formData.append( "user[password_confirmation]", password );
// TODO - support for iNat site_id
if ( license ) {
formData.append( "user[preferred_observation_license]", license );
formData.append( "user[preferred_photo_license]", license );
formData.append( "user[preferred_sound_license]", license );
}
const locales = RNLocalize.getLocales();
formData.append( "user[locale]", locales[0].languageCode );
const api = createAPI();
const response = await api.post( "/users.json", formData );
if ( !response.ok ) {
console.error(
"registerUser failed when calling /users.json - ",
response.problem,
response.status
);
return response.data.errors[0];
}
// console.info( "registerUser - success" );
return null;
};
/**
@@ -297,10 +321,7 @@ const isLoggedIn = async (): Promise<boolean> => {
*
* @returns {Promise<boolean>}
*/
const getUsername = async (): Promise<string> => {
return await RNSInfo.getItem( "username", {} );
};
const getUsername = async (): Promise<string> => RNSInfo.getItem( "username", {} );
/**
* Returns the logged-in user
@@ -312,48 +333,30 @@ const getUser = async (): Promise<Object | null> => {
return realm.objects( "User" ).filtered( "signedIn == true" )[0];
};
/**
* Returns the logged-in userId
*
* @returns {Promise<boolean>}
*/
const getUserId = async (): Promise<string | null> => {
const getUserId = async (): Promise<string | null> => {
const realm = await Realm.open( realmConfig );
const currentUser = realm.objects( "User" ).filtered( "signedIn == true" )[0];
const currentUserId = currentUser?.id?.toString( );
// TODO: still need to figure out the right way/time to close realm
// but omitting for now bc it interferes with a user being able to save a local observation if they're logged out
// realm.close( );
// TODO: still need to figure out the right way/time to close realm but
// omitting for now bc it interferes with a user being able to save a local
// observation if they're logged out realm.close( );
return currentUserId;
};
/**
* Signs out the user
*
* @returns {Promise<void>}
*/
const signOut = async ( options: {
deleteRealm: boolean
} ) => {
if ( options?.deleteRealm ) {
Realm.deleteFile( realmConfig );
}
await SInfo.deleteItem( "jwtToken", {} );
await SInfo.deleteItem( "jwtTokenExpiration", {} );
await SInfo.deleteItem( "username", {} );
await SInfo.deleteItem( "accessToken", {} );
};
export {
API_HOST,
authenticateUser,
registerUser,
getAPIToken,
isLoggedIn,
getUsername,
signOut,
getJWTToken,
getUser,
getUserId
getUserId,
getUsername,
isLoggedIn,
registerUser,
signOut
};

View File

@@ -1,6 +1,9 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Image,
KeyboardAvoidingView,
@@ -10,15 +13,20 @@ import {
TouchableOpacity,
View
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import {Button, Paragraph, Dialog, Portal, Text, TextInput} from "react-native-paper";
import {
Button, Dialog, Paragraph, Portal, Text, TextInput
} from "react-native-paper";
import { textStyles, viewStyles, imageStyles } from "../../styles/login/login";
import { isLoggedIn, authenticateUser, getUsername, getUserId, signOut } from "./AuthenticationService";
import colors from "../../styles/colors";
import { imageStyles, textStyles, viewStyles } from "../../styles/login/login";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import { useTranslation } from "react-i18next";
import {colors} from "../../styles/global";
import {
authenticateUser,
getUserId,
getUsername,
isLoggedIn,
signOut
} from "./AuthenticationService";
const Login = ( ): Node => {
const { t } = useTranslation( );
@@ -38,7 +46,7 @@ const Login = ( ): Node => {
let isCurrent = true;
const fetchLoggedIn = async ( ) => {
if ( !isCurrent ) {return;}
if ( !isCurrent ) { return; }
setLoggedIn( await isLoggedIn( ) );
if ( loggedIn ) {
@@ -60,7 +68,6 @@ const Login = ( ): Node => {
password
);
if ( !success ) {
setError( t( "Invalid-login" ) );
setLoading( false );
@@ -119,7 +126,11 @@ const Login = ( ): Node => {
const loginForm = (
<>
<Image style={imageStyles.logo} resizeMode="contain" source={require( "../../images/inat_logo.png" )} />
<Image
style={imageStyles.logo}
resizeMode="contain"
source={require( "../../images/inat_logo.png" )}
/>
<Text style={textStyles.header}>{t( "Login-header" )}</Text>
<Text style={textStyles.subtitle}>{t( "Login-sub-title" )}</Text>
<Text style={textStyles.fieldText}>{t( "Username-or-Email" )}</Text>
@@ -144,7 +155,7 @@ const Login = ( ): Node => {
setPassword( text );
}}
value={password}
secureTextEntry={true}
secureTextEntry
testID="Login.password"
selectionColor={colors.black}
/>

View File

@@ -1,11 +1,13 @@
// @flow
import React, { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
import type { Node } from "react";
import { t } from "i18next";
import type { Node } from "react";
import React, { useState } from "react";
import {
Button, Text, TextInput, View
} from "react-native";
import { viewStyles, textStyles } from "../../styles/login/login";
import { textStyles, viewStyles } from "../../styles/login/login";
import { registerUser } from "./AuthenticationService";
const SignUp = (): Node => {
@@ -47,7 +49,7 @@ const SignUp = (): Node => {
style={viewStyles.input}
onChangeText={setPassword}
value={password}
secureTextEntry={true}
secureTextEntry
/>
<Button title="Register" onPress={register} />
</View>

View File

@@ -1,18 +1,18 @@
// @flow
import React, { useState, useEffect } from "react";
import { Image, Dimensions } from "react-native";
import type { Node } from "react";
import { Appbar, Button } from "react-native-paper";
import { useTranslation } from "react-i18next";
import { HeaderBackButton } from "@react-navigation/elements";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, Image } from "react-native";
import ImageZoom from "react-native-image-pan-zoom";
import { Appbar, Button } from "react-native-paper";
import Photo from "../../models/Photo";
import { imageStyles, viewStyles } from "../../styles/mediaViewer/mediaViewer";
import DeletePhotoDialog from "../SharedComponents/DeletePhotoDialog";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import DeletePhotoDialog from "../SharedComponents/DeletePhotoDialog";
import Photo from "../../models/Photo";
const { width, height } = Dimensions.get( "screen" );
const selectedImageHeight = height - 350;
@@ -24,14 +24,16 @@ type Props = {
hideModal: Function
}
const MediaViewer = ( { photoUris, setPhotoUris, initialPhotoSelected, hideModal }: Props ): Node => {
const MediaViewer = ( {
photoUris, setPhotoUris, initialPhotoSelected, hideModal
}: Props ): Node => {
const { t } = useTranslation( );
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState( initialPhotoSelected );
const [deleteDialogVisible, setDeleteDialogVisible] = useState( false );
const numOfPhotos = photoUris.length;
const handlePhotoSelection = ( index ) => setSelectedPhotoIndex( index );
const handlePhotoSelection = index => setSelectedPhotoIndex( index );
const showDialog = ( ) => setDeleteDialogVisible( true );
const hideDialog = ( ) => setDeleteDialogVisible( false );

View File

@@ -1,7 +1,8 @@
// @flow
import * as React from "react";
import { FlatList, Text, ActivityIndicator } from "react-native";
import { ActivityIndicator, FlatList, Text } from "react-native";
import { textStyles } from "../../styles/messages/messages";
type Props = {
@@ -15,20 +16,17 @@ const MessageList = ( {
messageList,
testID
}: Props ): React.Node => {
if ( loading ) {
return (
<ActivityIndicator
testID={"Messages.activityIndicator"}
testID="Messages.activityIndicator"
/>
);
}
const renderMessages = ( { item } ) => {
return (
<Text style={textStyles.projectName}>{item.subject}</Text>
);
};
const renderMessages = ( { item } ) => (
<Text style={textStyles.projectName}>{item.subject}</Text>
);
return (
<FlatList
@@ -40,4 +38,3 @@ const MessageList = ( {
};
export default MessageList;

View File

@@ -1,14 +1,13 @@
// @flow
import React from "react";
import type { Node } from "react";
import React from "react";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import MessageList from "./MessageList";
import useMessages from "./hooks/useMessages";
import MessageList from "./MessageList";
const Messages = ( ): Node => {
// TODO: Reload when accessing again
const { messages, loading } = useMessages( );

View File

@@ -1,10 +1,10 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import MESSAGE_FIELDS from "../../../providers/fields";
import { getJWTToken } from "../../LoginSignUp/AuthenticationService";
import { MESSAGE_FIELDS } from "../../../providers/fields";
const useMessages = ( ): {
messages: Array<Object>,
@@ -37,7 +37,7 @@ const useMessages = ( ): {
setLoading( false );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch messages:", e.message, );
console.log( "Couldn't fetch messages:", e.message );
setLoading( false );
}
};

View File

@@ -1,8 +1,8 @@
// @flow
import type { Node } from "react";
import React from "react";
import NetworkLogger from "react-native-network-logger";
import type { Node } from "react";
import ViewWithFooter from "./SharedComponents/ViewWithFooter";
@@ -14,4 +14,3 @@ const NetworkLogging = ( ): Node => (
);
export default NetworkLogging;

View File

@@ -1,17 +1,17 @@
// @flow
import React, { useState, useEffect } from "react";
import { Text, View, Pressable } from "react-native";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { Pressable, Text, View } from "react-native";
import UserIcon from "../SharedComponents/UserIcon";
import SmallSquareImage from "./SmallSquareImage";
import { textStyles, viewStyles } from "../../styles/obsDetails/obsDetails";
import Taxon from "../../models/Taxon";
import User from "../../models/User";
import KebabMenu from "./KebabMenu";
import { timeAgo } from "../../sharedHelpers/dateAndTime";
import { textStyles, viewStyles } from "../../styles/obsDetails/obsDetails";
import PlaceholderText from "../PlaceholderText";
import UserIcon from "../SharedComponents/UserIcon";
import KebabMenu from "./KebabMenu";
import SmallSquareImage from "./SmallSquareImage";
type Props = {
item: Object,
@@ -20,10 +20,12 @@ type Props = {
toggleRefetch: Function
}
const ActivityItem = ( { item, navToTaxonDetails, handlePress, toggleRefetch }: Props ): Node => {
const ActivityItem = ( {
item, navToTaxonDetails, handlePress, toggleRefetch
}: Props ): Node => {
const [currentUser, setCurrentUser] = useState( null );
const taxon = item.taxon;
const user = item.user;
const { taxon } = item;
const { user } = item;
useEffect( ( ) => {
const isCurrentUser = async ( ) => {
@@ -66,7 +68,11 @@ const ActivityItem = ( { item, navToTaxonDetails, handlePress, toggleRefetch }:
<SmallSquareImage uri={Taxon.uri( taxon )} />
<View>
<Text style={textStyles.commonNameText}>{taxon.preferredCommonName}</Text>
<Text style={textStyles.scientificNameText}>{taxon.rank} {taxon.name}</Text>
<Text style={textStyles.scientificNameText}>
{taxon.rank}
{" "}
{taxon.name}
</Text>
</View>
</Pressable>
)}

View File

@@ -1,8 +1,8 @@
// @flow
import * as React from "react";
import { View, Text } from "react-native";
import { t } from "i18next";
import * as React from "react";
import { Text, View } from "react-native";
import ActivityItem from "./ActivityItem";
@@ -14,7 +14,9 @@ type Props = {
toggleRefetch: Function
}
const ActivityTab = ( { comments, ids, navToTaxonDetails, navToUserProfile, toggleRefetch }: Props ): React.Node => {
const ActivityTab = ( {
comments, ids, navToTaxonDetails, navToUserProfile, toggleRefetch
}: Props ): React.Node => {
if ( comments.length === 0 && ids.length === 0 ) {
return <Text>{t( "No-comments-or-ids-to-display" )}</Text>;
}
@@ -28,7 +30,12 @@ const ActivityTab = ( { comments, ids, navToTaxonDetails, navToUserProfile, togg
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/components/activity_item.jsx
return (
<View key={item.uuid}>
<ActivityItem item={item} handlePress={handlePress} navToTaxonDetails={navToTaxonDetails} toggleRefetch={toggleRefetch} />
<ActivityItem
item={item}
handlePress={handlePress}
navToTaxonDetails={navToTaxonDetails}
toggleRefetch={toggleRefetch}
/>
</View>
);
} );

View File

@@ -1,13 +1,13 @@
// @flow
import React, { useState } from "react";
import { View, Text } from "react-native";
import type { Node } from "react";
import { t } from "i18next";
import type { Node } from "react";
import React, { useState } from "react";
import { Text, View } from "react-native";
import { textStyles } from "../../styles/obsDetails/obsDetails";
import Map from "../SharedComponents/Map";
import DropdownPicker from "../Explore/DropdownPicker";
import Map from "../SharedComponents/Map";
import addToProject from "./helpers/addToProject";
import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase";
@@ -23,7 +23,7 @@ const DataTab = ( { observation }: Props ): Node => {
const attribution = observation.taxon && observation.taxon.default_photo
&& observation.taxon.default_photo.attribution;
const selectProjectId = ( getValue ) => {
const selectProjectId = getValue => {
addToProject( getValue( ), observation.uuid );
setProjectId( getValue( ) );
};
@@ -51,8 +51,12 @@ const DataTab = ( { observation }: Props ): Node => {
{checkCamelAndSnakeCase( observation, "placeGuess" )}
</Text>
<Text style={textStyles.dataTabText}>{t( "Date" )}</Text>
<Text style={textStyles.dataTabText}>{`${t( "Date-observed-colon" )} ${displayTimeObserved( )}`}</Text>
<Text style={textStyles.dataTabText}>{`${t( "Date-uploaded-colon" )} ${observation._synced_at}`}</Text>
<Text style={textStyles.dataTabText}>
{`${t( "Date-observed-colon" )} ${displayTimeObserved( )}`}
</Text>
<Text style={textStyles.dataTabText}>
{`${t( "Date-uploaded-colon" )} ${observation._synced_at}`}
</Text>
<Text style={textStyles.dataTabText}>{t( "Projects" )}</Text>
{/* TODO: create a custom dropdown that doesn't use FlatList */}
<DropdownPicker

View File

@@ -1,16 +1,18 @@
// @flow
import React, { useState } from "react";
import type { Node } from "react";
import { Button, Menu, Provider, DefaultTheme } from "react-native-paper";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import Realm from "realm";
import { View } from "react-native";
import {
Button, DefaultTheme, Menu, Provider
} from "react-native-paper";
import Realm from "realm";
import { viewStyles } from "../../styles/obsDetails/obsDetails";
import Comment from "../../models/Comment";
import realmConfig from "../../models/index";
import { colors } from "../../styles/global";
import colors from "../../styles/colors";
import { viewStyles } from "../../styles/obsDetails/obsDetails";
type Props = {
uuid: string,
@@ -35,7 +37,8 @@ const KebabMenu = ( { uuid, toggleRefetch }: Props ): Node => {
contentStyle={viewStyles.textPadding}
anchor={
<Button onPress={openMenu} icon="dots-horizontal" textColor={colors.logInGray} />
}>
}
>
<Menu.Item
onPress={async ( ) => {
const realm = await Realm.open( realmConfig );

View File

@@ -1,33 +1,35 @@
// @flow
import _ from "lodash";
import React, {useState, useContext, useEffect} from "react";
import type { Node } from "react";
import { useNavigation, useRoute } from "@react-navigation/native";
import { formatISO } from "date-fns";
import { Text, View, Image, Pressable, ScrollView, LogBox, Alert } from "react-native";
import _ from "lodash";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert, Image, LogBox, Pressable, ScrollView, Text, View
} from "react-native";
import ActivityTab from "./ActivityTab";
import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase";
import createComment from "./helpers/createComment";
import createIdentification from "../Identify/helpers/createIdentification";
import DataTab from "./DataTab";
import faveObservation from "./helpers/faveObservation";
import InputField from "../SharedComponents/InputField";
import ObsDetailsHeader from "./ObsDetailsHeader";
import PhotoScroll from "../SharedComponents/PhotoScroll";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import Taxon from "../../models/Taxon";
import TranslatedText from "../SharedComponents/TranslatedText";
import User from "../../models/User";
import { ObsEditContext } from "../../providers/contexts";
import { formatObsListTime } from "../../sharedHelpers/dateAndTime";
import { textStyles, viewStyles } from "../../styles/obsDetails/obsDetails";
import createIdentification from "../Identify/helpers/createIdentification";
import { getUser } from "../LoginSignUp/AuthenticationService";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import InputField from "../SharedComponents/InputField";
import PhotoScroll from "../SharedComponents/PhotoScroll";
import TranslatedText from "../SharedComponents/TranslatedText";
import UserIcon from "../SharedComponents/UserIcon";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import { ObsEditContext } from "../../providers/contexts";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useRemoteObservation } from "./hooks/useRemoteObservation";
import { viewStyles, textStyles } from "../../styles/obsDetails/obsDetails";
import { formatObsListTime } from "../../sharedHelpers/dateAndTime";
import { getUser } from "../LoginSignUp/AuthenticationService";
import ActivityTab from "./ActivityTab";
import DataTab from "./DataTab";
import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase";
import createComment from "./helpers/createComment";
import faveObservation from "./helpers/faveObservation";
import useRemoteObservation from "./hooks/useRemoteObservation";
import ObsDetailsHeader from "./ObsDetailsHeader";
// this is getting triggered by passing dates, like _created_at, through
// react navigation via the observation object. it doesn't seem to
@@ -43,7 +45,7 @@ const ObsDetails = ( ): Node => {
const [comment, setComment] = useState( "" );
const { addObservations } = useContext( ObsEditContext );
const { params } = useRoute( );
let observation = params.observation;
let { observation } = params;
const [tab, setTab] = useState( 0 );
const navigation = useNavigation( );
const [ids, setIds] = useState( [] );
@@ -61,19 +63,16 @@ const ObsDetails = ( ): Node => {
const toggleRefetch = ( ) => setRefetch( !refetch );
useEffect( () => {
if ( observation ) {setIds( observation.identifications.map( i => i ) );}
if ( observation ) { setIds( observation.identifications.map( i => i ) ); }
}, [observation] );
if ( !observation ) { return null; }
const comments = observation.comments.map( c => c );
const photos = _.compact( observation.observationPhotos.map( op => op.photo ) );
const user = observation.user;
const taxon = observation.taxon;
const uuid = observation.uuid;
const { taxon, uuid, user } = observation;
const onIDAdded = async ( identification ) => {
const onIDAdded = async identification => {
console.log( "onIDAdded", identification );
// Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it)
@@ -89,20 +88,25 @@ const ObsDetails = ( ): Node => {
created_at: formatISO( Date.now() ),
uuid: identification.uuid,
vision: false,
// This tells us to render is ghosted (since it's temporarily visible until getting a response from the server)
// This tells us to render is ghosted (since it's temporarily visible
// until getting a response from the server)
temporary: true
};
setIds( [ ...ids, newId ] );
setIds( [...ids, newId] );
let error = null;
try {
const results = await createIdentification( { observation_id: observation.uuid, taxon_id: newId.taxon.id, body: newId.body } );
const results = await createIdentification( {
observation_id: observation.uuid,
taxon_id: newId.taxon.id,
body: newId.body
} );
if ( results === 1 ) {
// Remove ghosted highlighting
newId.temporary = false;
setIds( [ ...ids, newId ] );
setIds( [...ids, newId] );
} else {
// Couldn't create ID
error = t( "Couldnt-create-identification", { error: t( "Unknown-error" ) } );
@@ -130,7 +134,7 @@ const ObsDetails = ( ): Node => {
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } );
const navToAddID = ( ) => {
addObservations( [observation] );
navigation.push( "AddID", { onIDAdded: onIDAdded, goBackOnSave: true } );
navigation.push( "AddID", { onIDAdded, goBackOnSave: true } );
};
const openCommentBox = ( ) => setShowCommentBox( true );
const submitComment = async ( ) => {
@@ -146,7 +150,7 @@ const ObsDetails = ( ): Node => {
if ( !taxon ) { return <Text>{t( "Unknown-organism" )}</Text>; }
return (
<>
<Image source={Taxon.uri( taxon )} style={viewStyles.imageBackground} />
<Image source={Taxon.uri( taxon )} style={viewStyles.imageBackground} />
<Pressable
style={viewStyles.obsDetailsColumn}
onPress={navToTaxonDetails}
@@ -173,9 +177,9 @@ const ObsDetails = ( ): Node => {
}
};
const displayCreatedAt = ( ) => observation.createdAt
const displayCreatedAt = ( ) => ( observation.createdAt
? observation.createdAt
: formatObsListTime( observation._created_at );
: formatObsListTime( observation._created_at ) );
return (
<ViewWithFooter>
@@ -207,7 +211,9 @@ const ObsDetails = ( ): Node => {
<View>
<Text style={textStyles.text}>{observation.identifications.length}</Text>
<Text style={textStyles.text}>{observation.comments.length}</Text>
<Text style={textStyles.text}>{checkCamelAndSnakeCase( observation, "qualityGrade" )}</Text>
<Text style={textStyles.text}>
{checkCamelAndSnakeCase( observation, "qualityGrade" )}
</Text>
</View>
</View>
<Text style={textStyles.locationText}>
@@ -239,7 +245,7 @@ const ObsDetails = ( ): Node => {
/>
)
: <DataTab observation={observation} />}
<View style={viewStyles.row}>
<View style={viewStyles.row}>
<View style={viewStyles.button}>
{/* TODO: get this button working. Not sure why createIdentification isn't working here
but it doesn't appear to be working on staging either (Mar 11, 2022) */}

View File

@@ -1,16 +1,16 @@
// @flow
import React, { useEffect, useState } from "react";
import type { Node } from "react";
import { useTranslation } from "react-i18next";
import { Headline, Button } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { View } from "react-native";
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Button, Headline } from "react-native-paper";
import { ObsEditContext } from "../../providers/contexts";
import colors from "../../styles/colors";
import { viewStyles } from "../../styles/obsDetails/obsDetailsHeader";
import { colors } from "../../styles/global";
type Props = {
observationUUID: string

View File

@@ -23,8 +23,9 @@ const addToProject = async ( projectId: number, obsId: string ): Promise<?number
console.log( response, "response in project add" );
return response.total_results;
} catch ( e ) {
console.log( "Couldn't add to project:", JSON.stringify( e.response ), );
console.log( "Couldn't add to project:", JSON.stringify( e.response ) );
}
return 0;
};
export default addToProject;

View File

@@ -6,5 +6,4 @@ const checkCamelAndSnakeCase = ( object: Object, camelCaseKey: string ): ?string
return object[camelCaseKey] || object[snakeCaseKey];
};
export default checkCamelAndSnakeCase;

View File

@@ -22,8 +22,9 @@ const createComment = async ( body: string, uuid: string ): Promise<?number> =>
const response = await inatjs.comments.create( apiParams, options );
return response.total_results;
} catch ( e ) {
console.log( "Couldn't create comment:", JSON.stringify( e.response ), );
console.log( "Couldn't create comment:", JSON.stringify( e.response ) );
}
return 0;
};
export default createComment;

View File

@@ -18,8 +18,9 @@ const faveObservation = async ( uuid: string, endpoint: string ): Promise<?numbe
const response = await inatjs.observations[endpoint]( params, options );
return response.total_results;
} catch ( e ) {
console.log( "Couldn't fave observation:", JSON.stringify( e.response ), );
console.log( "Couldn't fave observation:", JSON.stringify( e.response ) );
}
return 0;
};
export default faveObservation;

View File

@@ -27,8 +27,9 @@ const flagObservation = async ( id: number ): Promise<?number> => {
const response = await inatjs.flags.create( params, options );
return response.total_results;
} catch ( e ) {
console.log( "Couldn't flag observation:", JSON.stringify( e.response ), );
console.log( "Couldn't flag observation:", JSON.stringify( e.response ) );
}
return 0;
};
export default flagObservation;

View File

@@ -1,8 +1,8 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import NetInfo from "@react-native-community/netinfo";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import Observation from "../../../models/Observation";
import User from "../../../models/User";
@@ -46,11 +46,11 @@ const useRemoteObservation = ( observation: Object, refetch: boolean ): Object =
};
const response = await inatjs.observations.fetch( observation.uuid, params );
const results = response.results;
const { results } = response;
const obs = Observation.mimicRealmMappedPropertiesSchema( results[0] );
if ( !isCurrent ) { return; }
if ( obs.faves ) {
const userFavedObs = obs.faves.find( fave =>fave.user.login === currentUserLogin );
const userFavedObs = obs.faves.find( fave => fave.user.login === currentUserLogin );
if ( userFavedObs ) {
setCurrentUserFaved( true );
} else {
@@ -60,7 +60,7 @@ const useRemoteObservation = ( observation: Object, refetch: boolean ): Object =
setRemoteObservation( obs );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch observation with uuid ${observation.uuid}: `, e.message, );
console.log( `Couldn't fetch observation with uuid ${observation.uuid}: `, e.message );
}
};
@@ -80,6 +80,4 @@ const useRemoteObservation = ( observation: Object, refetch: boolean ): Object =
};
};
export {
useRemoteObservation
};
export default useRemoteObservation;

View File

@@ -1,28 +1,31 @@
// @flow
import * as React from "react";
import {
View,
Pressable,
TouchableOpacity, FlatList, Image
} from "react-native";
import {useNavigation} from "@react-navigation/native";
import { viewStyles, textStyles } from "../../styles/obsDetails/addID";
import { useTranslation } from "react-i18next";
import { TextInput as NativeTextInput } from "react-native";
import AddIDHeader from "./AddIDHeader";
import {useRef, useState} from "react";
import {
BottomSheetBackdrop,
BottomSheetModal,
BottomSheetModalProvider
} from "@gorhom/bottom-sheet";
import {Button, Headline, Text, TextInput} from "react-native-paper";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import {colors} from "../../styles/global";
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FlatList,
Image,
Pressable, TextInput as NativeTextInput, TouchableOpacity,
View
} from "react-native";
import {
Button, Headline, Text, TextInput
} from "react-native-paper";
import uuid from "react-native-uuid";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/obsDetails/addID";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import AddIDHeader from "./AddIDHeader";
type Props = {
route: {
@@ -34,6 +37,11 @@ type Props = {
}
}
const SearchTaxonIcon = (
<TextInput.Icon
name={() => <Icon style={textStyles.taxonSearchIcon} name="magnify" size={25} />}
/>
);
const AddID = ( { route }: Props ): React.Node => {
const { t } = useTranslation( );
@@ -42,29 +50,34 @@ const AddID = ( { route }: Props ): React.Node => {
const { onIDAdded, goBackOnSave, hideComment } = route.params;
const bottomSheetModalRef = useRef( null );
const [taxonSearch, setTaxonSearch] = useState( "" );
const taxonList = useRemoteSearchResults( taxonSearch, "taxa", "taxon.name,taxon.preferred_common_name,taxon.default_photo.square_url,taxon.rank" );
const taxonList = useRemoteSearchResults(
taxonSearch,
"taxa",
"taxon.name,taxon.preferred_common_name,taxon.default_photo.square_url,taxon.rank"
);
const navigation = useNavigation( );
const renderBackdrop = ( props ) => (
<BottomSheetBackdrop {...props} pressBehavior={"close"}
appearsOnIndex={0}
disappearsOnIndex={-1}
const renderBackdrop = props => (
<BottomSheetBackdrop
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
pressBehavior="close"
appearsOnIndex={0}
disappearsOnIndex={-1}
/>
);
const editComment = ( event ) => {
const editComment = ( ) => {
setCommentDraft( comment );
bottomSheetModalRef.current?.present();
};
const createPhoto = ( photo ) => {
return {
id: photo.id,
url: photo.square_url
};
};
const createPhoto = photo => ( {
id: photo.id,
url: photo.square_url
} );
const createID = ( taxon ) => {
const createID = taxon => {
const newTaxon = {
id: taxon.id,
default_photo: taxon.default_photo ? createPhoto( taxon.default_photo ) : null,
@@ -81,42 +94,72 @@ const AddID = ( { route }: Props ): React.Node => {
return newID;
};
const renderTaxonResult = ( {item} ) => {
const taxonImage = item.default_photo ? { uri: item.default_photo.square_url } : Icon.getImageSourceSync( "leaf", 50, colors.inatGreen );
const renderTaxonResult = ( { item } ) => {
const taxonImage = item.default_photo
? { uri: item.default_photo.square_url }
: Icon.getImageSourceSync( "leaf", 50, colors.inatGreen );
return <View style={viewStyles.taxonResult} testID={`Search.taxa.${item.id}`}>
<Image style={viewStyles.taxonResultIcon} source={taxonImage} testID={`Search.taxa.${item.id}.photo`}
/>
<View style={viewStyles.taxonResultNameContainer}>
<Text style={textStyles.taxonResultName}>{item.name}</Text>
<Text style={textStyles.taxonResultScientificName}>{item.preferred_common_name}</Text>
return (
<View style={viewStyles.taxonResult} testID={`Search.taxa.${item.id}`}>
<Image
style={viewStyles.taxonResultIcon}
source={taxonImage}
testID={`Search.taxa.${item.id}.photo`}
/>
<View style={viewStyles.taxonResultNameContainer}>
<Text style={textStyles.taxonResultName}>{item.name}</Text>
<Text style={textStyles.taxonResultScientificName}>{item.preferred_common_name}</Text>
</View>
<Pressable
style={viewStyles.taxonResultInfo}
onPress={() => navigation.navigate( "TaxonDetails", { id: item.id } )}
accessibilityRole="link"
>
<Icon style={textStyles.taxonResultInfoIcon} name="information-outline" size={25} />
</Pressable>
<Pressable
style={viewStyles.taxonResultSelect}
onPress={( ) => {
onIDAdded( createID( item ) );
if ( goBackOnSave ) { navigation.goBack(); }
}}
accessibilityRole="link"
>
<Icon style={textStyles.taxonResultSelectIcon} name="check-bold" size={25} />
</Pressable>
</View>
<Pressable style={viewStyles.taxonResultInfo} onPress={() => navigation.navigate( "TaxonDetails", { id: item.id } )} accessibilityRole="link"><Icon style={textStyles.taxonResultInfoIcon} name="information-outline" size={25} /></Pressable>
<Pressable style={viewStyles.taxonResultSelect} onPress={() => { onIDAdded( createID( item ) ); if ( goBackOnSave ) {navigation.goBack();} }} accessibilityRole="link"><Icon style={textStyles.taxonResultSelectIcon} name="check-bold" size={25} /></Pressable>
</View>;
);
};
const taxonSearchIcon = ( ) => <Icon style={textStyles.taxonSearchIcon} name={"magnify"} size={25} />;
return (
<BottomSheetModalProvider>
<ViewNoFooter>
<AddIDHeader showEditComment={!hideComment && comment.length === 0} onEditCommentPressed={editComment} />
<AddIDHeader
showEditComment={!hideComment && comment.length === 0}
onEditCommentPressed={editComment}
/>
<View>
<View style={viewStyles.scrollView}>
{comment.length > 0 && <View>
{comment.length > 0 && (
<View>
<Text>{t( "ID-Comment" )}</Text>
<View style={viewStyles.commentContainer}>
<Icon style={textStyles.commentLeftIcon} name="chat-processing-outline" size={25} />
<Text style={textStyles.comment}>{comment}</Text>
<Pressable style={viewStyles.commentRightIconContainer} onPress={editComment} accessibilityRole="link"><Icon style={textStyles.commentRightIcon} name="pencil" size={25} /></Pressable>
<Pressable
style={viewStyles.commentRightIconContainer}
onPress={editComment}
accessibilityRole="link"
>
<Icon style={textStyles.commentRightIcon} name="pencil" size={25} />
</Pressable>
</View>
</View>
}
)}
<Text>{t( "Search-Taxon-ID" )}</Text>
<TextInput
testID={"SearchTaxon"}
left={<TextInput.Icon name={taxonSearchIcon} />}
testID="SearchTaxon"
left={SearchTaxonIcon}
style={viewStyles.taxonSearch}
value={taxonSearch}
onChangeText={setTaxonSearch}
@@ -140,7 +183,9 @@ const AddID = ( { route }: Props ): React.Node => {
backdropComponent={renderBackdrop}
style={viewStyles.bottomModal}
>
<Headline style={textStyles.commentHeader}>{comment.length > 0 ? t( "Edit-comment" ) : t( "Add-optional-comment" )}</Headline>
<Headline style={textStyles.commentHeader}>
{comment.length > 0 ? t( "Edit-comment" ) : t( "Add-optional-comment" )}
</Headline>
<View style={viewStyles.commentInputContainer}>
<TextInput
keyboardType="default"
@@ -151,8 +196,9 @@ const AddID = ( { route }: Props ): React.Node => {
autoFocus
multiline
onChangeText={setCommentDraft}
render={( innerProps ) => (
render={innerProps => (
<NativeTextInput
// eslint-disable-next-line react/jsx-props-no-spreading
{...innerProps}
style={[
innerProps.style,
@@ -163,9 +209,16 @@ const AddID = ( { route }: Props ): React.Node => {
/>
<TouchableOpacity
style={viewStyles.commentClear}
onPress={() => setCommentDraft( "" )}>
onPress={() => setCommentDraft( "" )}
>
<Text
style={[viewStyles.commentClearText, commentDraft.length === 0 ? textStyles.disabled : null]}>{t( "Clear" )}</Text>
style={[
viewStyles.commentClearText,
commentDraft.length === 0 ? textStyles.disabled : null
]}
>
{t( "Clear" )}
</Text>
</TouchableOpacity>
</View>
@@ -176,7 +229,10 @@ const AddID = ( { route }: Props ): React.Node => {
color={colors.midGray}
onPress={() => {
bottomSheetModalRef.current?.dismiss();
}}>{t( "Cancel" )}</Button>
}}
>
{t( "Cancel" )}
</Button>
<Button
style={viewStyles.commentButton}
uppercase={false}
@@ -186,7 +242,10 @@ const AddID = ( { route }: Props ): React.Node => {
onPress={() => {
setComment( commentDraft );
bottomSheetModalRef.current?.dismiss();
}}>{comment.length > 0 ? t( "Edit-comment" ) : t( "Add-comment" )}</Button>
}}
>
{comment.length > 0 ? t( "Edit-comment" ) : t( "Add-comment" )}
</Button>
</View>
</BottomSheetModal>
</ViewNoFooter>
@@ -195,4 +254,3 @@ const AddID = ( { route }: Props ): React.Node => {
};
export default AddID;

View File

@@ -1,13 +1,14 @@
// @flow
import React from "react";
import type { Node } from "react";
import { useTranslation } from "react-i18next";
import { Headline } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import {Pressable, View} from "react-native";
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Headline } from "react-native-paper";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { viewStyles } from "../../styles/obsDetails/addID";
type Props = {
@@ -23,8 +24,16 @@ const AddIDHeader = ( { showEditComment, onEditCommentPressed }: Props ): Node =
<View style={viewStyles.headerRow}>
<HeaderBackButton onPress={( ) => navigation.goBack( )} />
<Headline>{t( "Add-ID-Header" )}</Headline>
{showEditComment ?
<Pressable onPress={onEditCommentPressed} accessibilityRole="link"><Icon name="chat-processing-outline" size={25} /></Pressable> : <View />}
{showEditComment
? (
<Pressable
onPress={onEditCommentPressed}
accessibilityRole="link"
>
<Icon name="chat-processing-outline" size={25} />
</Pressable>
)
: <View />}
</View>
);
};

View File

@@ -1,14 +1,14 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useContext } from "react";
import { View } from "react-native";
import type { Node } from "react";
import { useNavigation } from "@react-navigation/native";
import { viewStyles } from "../../styles/obsEdit/obsEdit";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import { ObsEditContext } from "../../providers/contexts";
import { viewStyles } from "../../styles/obsEdit/obsEdit";
import PlaceholderText from "../PlaceholderText";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
const BottomModal = ( ): Node => {
const navigation = useNavigation( );

View File

@@ -1,21 +1,23 @@
// @flow
import React, { useContext, useState } from "react";
import type { Node } from "react";
import { View, Text, FlatList, ActivityIndicator, Pressable, Image } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { Searchbar } from "react-native-paper";
import { t } from "i18next";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import {
ActivityIndicator, FlatList, Image, Pressable, Text, View
} from "react-native";
import { Searchbar } from "react-native-paper";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import { ObsEditContext } from "../../providers/contexts";
import useCVSuggestions from "./hooks/useCVSuggestions";
import { viewStyles, textStyles } from "../../styles/obsEdit/cvSuggestions";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import useLoggedIn from "../../sharedHooks/useLoggedIn";
import useRemoteObsEditSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { useLoggedIn } from "../../sharedHooks/useLoggedIn";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
import { textStyles, viewStyles } from "../../styles/obsEdit/cvSuggestions";
import PlaceholderText from "../PlaceholderText";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import useCVSuggestions from "./hooks/useCVSuggestions";
const CVSuggestions = ( ): Node => {
const {
@@ -32,7 +34,11 @@ const CVSuggestions = ( ): Node => {
const currentObs = observations[currentObsIndex];
const hasPhotos = currentObs.observationPhotos;
const { suggestions, status } = useCVSuggestions( currentObs, showSeenNearby, selectedPhotoIndex );
const { suggestions, status } = useCVSuggestions(
currentObs,
showSeenNearby,
selectedPhotoIndex
);
const renderNavButtons = ( updateIdentification, id ) => {
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id } );
@@ -52,7 +58,9 @@ const CVSuggestions = ( ): Node => {
const renderSuggestions = ( { item } ) => {
const taxon = item && item.taxon;
// destructuring so this doesn't cause a crash
const mediumUrl = ( taxon && taxon.taxon_photos && taxon.taxon_photos[0].photo ) ? taxon.taxon_photos[0].photo.medium_url : null;
const mediumUrl = ( taxon && taxon.taxon_photos && taxon.taxon_photos[0].photo )
? taxon.taxon_photos[0].photo.medium_url
: null;
const uri = { uri: mediumUrl };
const updateIdentification = ( ) => updateTaxon( taxon );
@@ -104,12 +112,23 @@ const CVSuggestions = ( ): Node => {
const emptySuggestionsList = ( ) => {
if ( !isLoggedIn ) {
return <PlaceholderText style={[textStyles.explainerText]} text="you must be logged in to see computer vision suggestions" />;
} else if ( status === "no_results" ) {
return <PlaceholderText style={[textStyles.explainerText]} text="no computervision suggestions found" />;
} else {
return <ActivityIndicator />;
return (
<PlaceholderText
style={[textStyles.explainerText]}
text="you must be logged in to see computer vision suggestions"
/>
);
}
if ( status === "no_results" ) {
return (
<PlaceholderText
style={[textStyles.explainerText]}
text="no computervision suggestions found"
/>
);
}
return <ActivityIndicator />;
};
const displaySuggestions = ( ) => (
@@ -127,13 +146,9 @@ const CVSuggestions = ( ): Node => {
/>
);
const displayPhotos = ( ) => {
return currentObs.observationPhotos.map( p => {
return {
uri: p.photo?.url || p?.photo?.localFilePath
};
} );
};
const displayPhotos = ( ) => currentObs.observationPhotos.map( p => ( {
uri: p.photo?.url || p?.photo?.localFilePath
} ) );
return (
<ViewNoFooter>

View File

@@ -1,12 +1,12 @@
// @flow
import React, { useState } from "react";
import { Text, Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import type { Node } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text } from "react-native";
import { textStyles } from "../../styles/obsEdit/obsEdit";
import { displayDateTimeObsEdit } from "../../sharedHelpers/dateAndTime";
import { textStyles } from "../../styles/obsEdit/obsEdit";
import DateTimePicker from "../SharedComponents/DateTimePicker";
type Props = {
@@ -21,7 +21,7 @@ const DatePicker = ( { handleDatePicked, currentObs }: Props ): Node => {
const openModal = () => setShowModal( true );
const closeModal = () => setShowModal( false );
const handlePicked = ( value ) => {
const handlePicked = value => {
handleDatePicked( value );
closeModal();
};
@@ -29,7 +29,7 @@ const DatePicker = ( { handleDatePicked, currentObs }: Props ): Node => {
const displayDate = ( ) => {
if ( currentObs.observed_on_string ) {
return displayDateTimeObsEdit( currentObs.observed_on_string );
} else if ( currentObs.time_observed_at ) {
} if ( currentObs.time_observed_at ) {
// this is for observations already uploaded to iNat
return displayDateTimeObsEdit( currentObs.time_observed_at );
}

View File

@@ -1,16 +1,16 @@
// @flow
import React, { useContext } from "react";
import { Text } from "react-native";
import type { Node } from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { textStyles } from "../../styles/obsEdit/obsEdit";
// import LocationPicker from "./LocationPicker";
import { ObsEditContext } from "../../providers/contexts";
import DatePicker from "./DatePicker";
import { createObservedOnStringForUpload } from "../../sharedHelpers/dateAndTime";
import { textStyles } from "../../styles/obsEdit/obsEdit";
import PhotoCarousel from "../SharedComponents/PhotoCarousel";
import DatePicker from "./DatePicker";
type Props = {
handleSelection: Function,
@@ -58,7 +58,7 @@ const EvidenceSection = ( {
// setObservations( updatedObs );
// };
const handleDatePicked = ( selectedDate ) => {
const handleDatePicked = selectedDate => {
if ( selectedDate ) {
const dateString = createObservedOnStringForUpload( selectedDate );
updateObservedOn( dateString );
@@ -95,7 +95,10 @@ const EvidenceSection = ( {
photoUris={photoUris}
setSelectedPhotoIndex={handleSelection}
/>
{/* TODO: bring back the location picker when it works on Android and allows navigation back */}
{/*
TODO: bring back the location picker when it works on Android and
allows navigation back
*/}
{/* renderLocationPickerModal( ) */}
{/*
<Pressable

View File

@@ -1,17 +1,19 @@
// @flow
import React, { useContext } from "react";
import {Text, Pressable, FlatList, View} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import {
FlatList, Pressable, Text, View
} from "react-native";
import { Avatar, useTheme } from "react-native-paper";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import { textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import { iconicTaxaIds, iconicTaxaNames } from "../../dictionaries/iconicTaxaIds";
import { ObsEditContext } from "../../providers/contexts";
import { textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import PlaceholderText from "../PlaceholderText";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
const IdentificationSection = ( ): Node => {
const {
@@ -26,14 +28,14 @@ const IdentificationSection = ( ): Node => {
const currentObs = observations[currentObsIndex];
const identification = currentObs.taxon;
const onIDAdded = async ( id ) => {
const updateIdentification = taxon => updateTaxon( taxon );
const onIDAdded = async id => {
console.log( "onIDAdded", id );
updateIdentification( id.taxon );
};
const updateIdentification = ( taxon ) => updateTaxon( taxon );
const navToAddID = ( ) => navigation.push( "AddID", { onIDAdded: onIDAdded, hideComment: true } );
const navToAddID = ( ) => navigation.push( "AddID", { onIDAdded, hideComment: true } );
const renderIconicTaxaButton = ( { item } ) => {
const id = iconicTaxaIds[item];
@@ -76,30 +78,29 @@ const IdentificationSection = ( ): Node => {
</Pressable>
</View>
);
} else {
return (
<>
<RoundGreenButton
handlePress={navToAddID}
buttonText="View Identification Suggestions"
testID="ObsEdit.Suggestions"
/>
<Text style={textStyles.text}>
{identification && identification.id && t( iconicTaxaNames[identification.id] )}
</Text>
</>
);
}
return (
<>
<RoundGreenButton
handlePress={navToAddID}
buttonText="View Identification Suggestions"
testID="ObsEdit.Suggestions"
/>
<Text style={textStyles.text}>
{identification && identification.id && t( iconicTaxaNames[identification.id] )}
</Text>
</>
);
};
return (
<>
{displayIdentification( )}
<FlatList
data={Object.keys( iconicTaxaIds )}
horizontal
renderItem={renderIconicTaxaButton}
/>
<FlatList
data={Object.keys( iconicTaxaIds )}
horizontal
renderItem={renderIconicTaxaButton}
/>
</>
);
};

View File

@@ -1,15 +1,15 @@
// @flow
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import type { Node } from "react";
import InputField from "../SharedComponents/InputField";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import Map from "../SharedComponents/Map";
import useLocationName from "../../sharedHooks/useLocationName";
import { viewStyles } from "../../styles/obsEdit/locationPicker";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import useLocationName from "../../sharedHooks/useLocationName";
import InputField from "../SharedComponents/InputField";
import Map from "../SharedComponents/Map";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import useCoords from "./hooks/useCoords";
type Props = {
@@ -44,7 +44,7 @@ const LocationPicker = ( { closeLocationPicker, updateLocation }: Props ): Node
}
}, [newCoords, region, searchQuery] );
const updateCoords = ( newMapRegion ) => {
const updateCoords = newMapRegion => {
setSearchQuery( "" );
setRegion( newMapRegion );
};

View File

@@ -1,13 +1,13 @@
// @flow
import React, { useState, useEffect } from "react";
import { Keyboard } from "react-native";
import type { Node } from "react";
import { t } from "i18next";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { Keyboard } from "react-native";
import { TextInput } from "react-native-paper";
import colors from "../../styles/colors";
import { textStyles } from "../../styles/obsEdit/obsEdit";
import { colors } from "../../styles/global";
type Props = {
addNotes: Function,
@@ -18,10 +18,10 @@ const Notes = ( { addNotes, description }: Props ): Node => {
const [keyboardOffset, setKeyboardOffset] = useState( 0 );
useEffect( ( ) => {
const showSubscription = Keyboard.addListener( "keyboardDidShow", ( e ) => {
const showSubscription = Keyboard.addListener( "keyboardDidShow", e => {
setKeyboardOffset( e.endCoordinates.height );
} );
const hideSubscription = Keyboard.addListener( "keyboardDidHide", ( e ) => {
const hideSubscription = Keyboard.addListener( "keyboardDidHide", ( ) => {
setKeyboardOffset( 0 );
} );

View File

@@ -1,24 +1,24 @@
// @flow
import React, { useContext, useEffect, useState } from "react";
import { Text, Pressable, View } from "react-native";
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { HeaderBackButton } from "@react-navigation/elements";
import { Headline, Portal, Modal } from "react-native-paper";
import { Pressable, Text, View } from "react-native";
import { Headline, Modal, Portal } from "react-native-paper";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import ScrollNoFooter from "../SharedComponents/ScrollNoFooter";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import { textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import Photo from "../../models/Photo";
import { ObsEditContext } from "../../providers/contexts";
import { useLoggedIn } from "../../sharedHooks/useLoggedIn";
import useLoggedIn from "../../sharedHooks/useLoggedIn";
import { textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import MediaViewer from "../MediaViewer/MediaViewer";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import ScrollNoFooter from "../SharedComponents/ScrollNoFooter";
import EvidenceSection from "./EvidenceSection";
import IdentificationSection from "./IdentificationSection";
import OtherDataSection from "./OtherDataSection";
import EvidenceSection from "./EvidenceSection";
import MediaViewer from "../MediaViewer/MediaViewer";
import Photo from "../../models/Photo";
const ObsEdit = ( ): Node => {
const {
@@ -46,8 +46,8 @@ const ObsEdit = ( ): Node => {
const showNextObservation = ( ) => setCurrentObsIndex( currentObsIndex + 1 );
const showPrevObservation = ( ) => setCurrentObsIndex( currentObsIndex - 1 );
const renderArrowNavigation = ( ) => {
if ( observations.length === 0 ) { return; }
const renderArrowNavigation = ( ): Node | null => {
if ( observations.length === 0 ) { return null; }
const handleBackButtonPress = ( ) => {
if ( lastScreen === "StandardCamera" ) {
@@ -89,32 +89,33 @@ const ObsEdit = ( ): Node => {
);
};
const setPhotos = ( uris ) => {
const currentObs = observations[currentObsIndex];
const setPhotos = uris => {
const updatedObservations = observations;
const updatedObsPhotos = currentObs.observationPhotos.filter( obsPhoto => {
const { photo } = obsPhoto;
if ( uris.includes( photo.url || photo.localFilePath ) ) {
return obsPhoto;
}
return false;
} );
currentObs.observationPhotos = updatedObsPhotos;
setObservations( [...updatedObservations] );
};
const handleSelection = ( photo ) => {
const handleSelection = photo => {
setInitialPhotoSelected( photo );
showModal( );
};
const currentObs = observations[currentObsIndex];
useEffect( ( ) => {
if ( !currentObs || !currentObs.observationPhotos ) { return; }
const uris = currentObs.observationPhotos.map( ( obsPhoto => {
return Photo.displayLocalOrRemoteSquarePhoto( obsPhoto.photo );
} ) );
const uris = currentObs.observationPhotos.map(
obsPhoto => Photo.displayLocalOrRemoteSquarePhoto( obsPhoto.photo )
);
setPhotoUris( uris );
}, [currentObs ] );
}, [currentObs] );
if ( !currentObs ) { return null; }

View File

@@ -1,11 +1,13 @@
// @flow
import * as React from "react";
import { FlatList, Pressable, Text, Image } from "react-native";
import {
FlatList, Image, Pressable, Text
} from "react-native";
import useRemoteObsEditSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { imageStyles, viewStyles } from "../../styles/search/search";
import InputField from "../SharedComponents/InputField";
import { viewStyles, imageStyles } from "../../styles/search/search";
type Props = {
source: string,
@@ -31,26 +33,29 @@ const ObsEditSearch = ( {
style={viewStyles.row}
testID={`ObsEditSearch.taxa.${item.id}`}
>
<Image source={imageUrl} style={imageStyles.squareImage} testID={`ObsEditSearch.taxa.${item.id}.photo`} />
<Image
source={imageUrl}
style={imageStyles.squareImage}
testID={`ObsEditSearch.taxa.${item.id}.photo`}
/>
<Text>{`${item.preferred_common_name} (${item.rank} ${item.name})`}</Text>
</Pressable>
);
} else {
return (
<Pressable
onPress={( ) => handlePress( item.id )}
style={viewStyles.row}
testID={`ObsEditSearch.project.${item.id}`}
>
<Image
source={{ uri: item.icon }}
style={imageStyles.squareImage}
testID={`ObsEditSearch.project.${item.id}.photo`}
/>
<Text>{item.title}</Text>
</Pressable>
);
}
return (
<Pressable
onPress={( ) => handlePress( item.id )}
style={viewStyles.row}
testID={`ObsEditSearch.project.${item.id}`}
>
<Image
source={{ uri: item.icon }}
style={imageStyles.squareImage}
testID={`ObsEditSearch.project.${item.id}.photo`}
/>
<Text>{item.title}</Text>
</Pressable>
);
};
return (

View File

@@ -1,17 +1,16 @@
// @flow
import type { Node } from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import RNPickerSelect from "react-native-picker-select";
import type { Node } from "react";
import { useTranslation } from "react-i18next";
import { pickerSelectStyles, textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import { ObsEditContext } from "../../providers/contexts";
import { pickerSelectStyles, textStyles, viewStyles } from "../../styles/obsEdit/obsEdit";
import TranslatedText from "../SharedComponents/TranslatedText";
import Notes from "./Notes";
const OtherDataSection = ( ): Node => {
const {
currentObsIndex,

View File

@@ -1,10 +1,10 @@
// @flow
import { useEffect, useState } from "react";
import inatjs, { FileUpload } from "inaturalistjs";
import { useEffect, useState } from "react";
import { getJWTToken } from "../../LoginSignUp/AuthenticationService";
import Photo from "../../../models/Photo";
import { getJWTToken } from "../../LoginSignUp/AuthenticationService";
const TAXON_FIELDS = {
name: true,
@@ -16,22 +16,27 @@ const PHOTO_FIELDS = {
};
const FIELDS = {
taxon: Object.assign( {}, TAXON_FIELDS, {
taxon: {
...TAXON_FIELDS,
taxon_photos: {
photo: PHOTO_FIELDS
}
} )
}
};
const useCVSuggestions = ( currentObs: Object, showSeenNearby: boolean, selectedPhoto: number ): Object => {
const useCVSuggestions = (
currentObs: Object,
showSeenNearby: boolean,
selectedPhoto: number
): Object => {
const [suggestions, setSuggestions] = useState( [] );
const [status, setStatus] = useState( null );
useEffect( ( ) => {
if ( !currentObs || !currentObs.observationPhotos ) { return; }
if ( !currentObs || !currentObs.observationPhotos ) { return ( ) => { }; }
const uri = currentObs.observationPhotos && currentObs.observationPhotos[selectedPhoto].uri;
const latitude = currentObs.latitude;
const longitude = currentObs.longitude;
const { latitude } = currentObs;
const { longitude } = currentObs;
let isCurrent = true;
const fetchCVSuggestions = async ( ): Promise<Object> => {
@@ -70,7 +75,6 @@ const useCVSuggestions = ( currentObs: Object, showSeenNearby: boolean, selected
if ( !isCurrent ) { return; }
} catch ( e ) {
console.log( JSON.stringify( e.response ), "couldn't fetch CV suggestions" );
if ( !isCurrent ) { return; }
}
};
@@ -87,5 +91,3 @@ const useCVSuggestions = ( currentObs: Object, showSeenNearby: boolean, selected
};
export default useCVSuggestions;

View File

@@ -25,7 +25,6 @@ const useCoords = ( location: string ): Object => {
} );
} catch ( e ) {
console.log( e, "couldn't fetch coords by location name" );
if ( !isCurrent ) { return; }
}
};
@@ -43,5 +42,3 @@ const useCoords = ( location: string ): Object => {
};
export default useCoords;

View File

@@ -1,13 +1,13 @@
// @flow
import React from "react";
import { Pressable } from "react-native";
import type { Node } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Pressable } from "react-native";
import { Text } from "react-native-paper";
import { viewStyles, textStyles } from "../../styles/observations/loggedOutCard";
import { textStyles, viewStyles } from "../../styles/observations/loggedOutCard";
type Props = {
numObsToUpload: number
@@ -20,7 +20,9 @@ const LoggedOutCard = ( { numObsToUpload }: Props ): Node => {
return (
<Pressable style={viewStyles.loggedOutCard} onPress={( ) => navigation.navigate( "login" )}>
<Text variant="titleLarge" style={textStyles.centerText}>{t( "Log-in-to-iNaturalist" )}</Text>
<Text variant="bodyLarge" style={textStyles.centerText}>{t( "X-unuploaded-observations", { observationCount: numObsToUpload } )}</Text>
<Text variant="bodyLarge" style={textStyles.centerText}>
{t( "X-unuploaded-observations", { observationCount: numObsToUpload } )}
</Text>
</Pressable>
);
};

View File

@@ -1,14 +1,14 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Text } from "react-native";
import type { Node } from "react";
import { Button } from "react-native-paper";
import { t } from "i18next";
import { useNavigation } from "@react-navigation/native";
import { viewStyles, textStyles } from "../../styles/observations/obsList";
import { colors } from "../../styles/global";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/observations/obsList";
const LoginPrompt = ( ): Node => {
const navigation = useNavigation( );

View File

@@ -1,23 +1,25 @@
// @flow
import React, { useEffect } from "react";
import type { Node } from "react";
import { useRoute } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect } from "react";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import ObservationViews from "../SharedComponents/ObservationViews/ObservationViews";
import UserCard from "./UserCard";
import { useCurrentUser } from "./hooks/useCurrentUser";
import BottomSheet from "../SharedComponents/BottomSheet";
import ObservationViews from "../SharedComponents/ObservationViews/ObservationViews";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import useUser from "../UserProfile/hooks/useUser";
import useCurrentUser from "./hooks/useCurrentUser";
import useObservations from "./hooks/useObservations";
import LoggedOutCard from "./LoggedOutCard";
import { useUser } from "../UserProfile/hooks/useUser";
import LoginPrompt from "./LoginPrompt";
import UploadPrompt from "./UploadPrompt";
import UserCard from "./UserCard";
const ObsList = ( ): Node => {
const { params } = useRoute( );
const { observationList, loading, syncObservations, fetchNextObservations, obsToUpload } = useObservations( );
const {
observationList, loading, syncObservations, fetchNextObservations, obsToUpload
} = useObservations( );
const id = params && params.userId;
const userId = useCurrentUser( ) || id;
@@ -36,7 +38,11 @@ const ObsList = ( ): Node => {
return (
<ViewWithFooter>
{user ? <UserCard userId={userId} user={user} /> : <LoggedOutCard numObsToUpload={numObsToUpload} />}
{
user
? <UserCard userId={userId} user={user} />
: <LoggedOutCard numObsToUpload={numObsToUpload} />
}
<ObservationViews
loading={loading}
observationList={observationList}
@@ -49,8 +55,7 @@ const ObsList = ( ): Node => {
<BottomSheet>
{!userId
? <LoginPrompt />
: <UploadPrompt obsToUpload={obsToUpload} />
}
: <UploadPrompt obsToUpload={obsToUpload} />}
</BottomSheet>
)}
</ViewWithFooter>

View File

@@ -1,15 +1,15 @@
// @flow
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Text } from "react-native";
import type { Node } from "react";
import { Button } from "react-native-paper";
import { t } from "i18next";
import uploadObservation from "../../providers/uploadHelpers/uploadObservation";
import Observation from "../../models/Observation";
import { viewStyles, textStyles } from "../../styles/observations/obsList";
import { colors } from "../../styles/global";
import Observation from "../../models/Observation";
import uploadObservation from "../../providers/uploadHelpers/uploadObservation";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/observations/obsList";
type Props = {
obsToUpload: Array<Object>

View File

@@ -1,14 +1,14 @@
// @flow
import React from "react";
import { Text, View, Pressable } from "react-native";
import type { Node } from "react";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import UserIcon from "../SharedComponents/UserIcon";
import User from "../../models/User";
import { viewStyles } from "../../styles/observations/userCard";
import UserIcon from "../SharedComponents/UserIcon";
type Props = {
userId: number,

View File

@@ -1,9 +1,9 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { getJWTToken } from "../../../components/LoginSignUp/AuthenticationService";
import { getJWTToken } from "../../LoginSignUp/AuthenticationService";
const useCurrentUser = ( ): Object => {
const [currentUser, setCurrentUser] = useState( null );
@@ -21,7 +21,7 @@ const useCurrentUser = ( ): Object => {
api_token: apiToken
};
const response = await inatjs.users.me( options );
const results = response.results;
const { results } = response;
if ( !isCurrent ) { return; }
setCurrentUser( results[0].id );
} catch ( e ) {
@@ -39,6 +39,4 @@ const useCurrentUser = ( ): Object => {
return currentUser;
};
export {
useCurrentUser
};
export default useCurrentUser;

View File

@@ -1,19 +1,23 @@
// @flow
import { useEffect, useCallback, useRef, useState } from "react";
import inatjs from "inaturalistjs";
import {
useCallback, useEffect, useRef, useState
} from "react";
import Realm from "realm";
import realmConfig from "../../../models/index";
import Observation from "../../../models/Observation";
import { getUsername, getUserId } from "../../../components/LoginSignUp/AuthenticationService";
import { getUserId, getUsername } from "../../LoginSignUp/AuthenticationService";
const perPage = 6;
const useObservations = ( ): Object => {
const [loading, setLoading] = useState( true );
const [observationList, setObservationList] = useState( [] );
const nextPageToFetch = observationList.length > 0 ? Math.ceil( observationList.length / perPage ) : 1;
const nextPageToFetch = observationList.length > 0
? Math.ceil( observationList.length / perPage )
: 1;
const [page, setPage] = useState( nextPageToFetch );
const [userLogin, setUserLogin] = useState( null );
const [obsToUpload, setObsToUpload] = useState( [] );
@@ -39,12 +43,14 @@ const useObservations = ( ): Object => {
const realm = await Realm.open( realmConfig );
realmRef.current = realm;
// When querying a realm to find objects (e.g. realm.objects('Observation')) the result we get back
// and the objects in it are "live" and will always reflect the latest state.
// When querying a realm to find objects (e.g. realm.objects
// ('Observation')) the result we get back and the objects in it
// are "live" and will always reflect the latest state.
const obs = realm.objects( "Observation" );
const localObservations = obs.sorted( "_created_at", true );
// includes obs which have never been synced or which have been updated locally since the last sync
// includes obs which have never been synced or which have been updated
// locally since the last sync
const notUploadedObs = obs.filtered( "_synced_at == null || _synced_at <= _updated_at" );
if ( localObservations?.length ) {
@@ -90,7 +96,7 @@ const useObservations = ( ): Object => {
return closeRealm;
}, [openRealm, closeRealm] );
const writeToDatabase = useCallback( ( results ) => {
const writeToDatabase = useCallback( results => {
if ( results.length === 0 ) { return; }
const realm = realmRef.current;
results.forEach( obs => {
@@ -128,13 +134,13 @@ const useObservations = ( ): Object => {
fields: Observation.FIELDS
};
const response = await inatjs.observations.search( params );
const results = response.results;
const { results } = response;
if ( !isCurrent ) { return; }
writeToDatabase( results );
} catch ( e ) {
setLoading( false );
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch observations:", e.message, );
console.log( "Couldn't fetch observations:", e.message );
}
};

View File

@@ -1,17 +1,21 @@
// @flow
import React, { useContext, useState } from "react";
import { Pressable, Image, FlatList, ActivityIndicator, Text, View } from "react-native";
import type { Node } from "react";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import {
ActivityIndicator, FlatList, Image, Pressable, Text, View
} from "react-native";
import Realm from "realm";
import { imageStyles, viewStyles, textStyles } from "../../styles/photoLibrary/photoGallery";
import GroupPhotosHeader from "./GroupPhotosHeader";
import realmConfig from "../../models/index";
import Observation from "../../models/Observation";
import { ObsEditContext, PhotoGalleryContext } from "../../providers/contexts";
import { imageStyles, textStyles, viewStyles } from "../../styles/photoLibrary/photoGallery";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import GroupPhotosFooter from "./GroupPhotosFooter";
import Observation from "../../models/Observation";
import { orderByTimestamp, flattenAndOrderSelectedPhotos } from "./helpers/groupPhotoHelpers";
import GroupPhotosHeader from "./GroupPhotosHeader";
import { flattenAndOrderSelectedPhotos, orderByTimestamp } from "./helpers/groupPhotoHelpers";
const GroupPhotos = ( ): Node => {
const { addObservations } = useContext( ObsEditContext );
@@ -24,13 +28,13 @@ const GroupPhotos = ( ): Node => {
const [obsToEdit, setObsToEdit] = useState( { observations } );
const [selectedObservations, setSelectedObservations] = useState( [] );
const updateFlatList = ( rerenderFlatList ) => {
const updateFlatList = rerenderFlatList => {
setObsToEdit( {
...obsToEdit,
// there might be a better way to do this, but adding this key forces the FlatList
// to rerender anytime an observation is unselected
rerenderFlatList
} );
} );
};
const selectObservationPhotos = ( isSelected, observation ) => {
@@ -128,7 +132,7 @@ const GroupPhotos = ( ): Node => {
// make sure at least one set of combined photos is selected
if ( maxCombinedPhotos < 2 ) { return; }
let separatedPhotos = [];
const separatedPhotos = [];
const orderedPhotos = flattenAndOrderSelectedPhotos( selectedObservations );
// create a list of grouped photos, with selected photos split into individual observations
@@ -148,15 +152,17 @@ const GroupPhotos = ( ): Node => {
};
const removePhotos = ( ) => {
let removedPhotos = {};
let removedFromGroup = [];
const removedPhotos = {};
const removedFromGroup = [];
const orderedPhotos = flattenAndOrderSelectedPhotos( );
// create a list of selected photos in each album, with selected photos removed
albums.forEach( album => {
const currentAlbum = selectedPhotos[album];
const filteredAlbum = currentAlbum && currentAlbum.filter( item => !orderedPhotos.includes( item ) );
const filteredAlbum = currentAlbum && currentAlbum.filter(
item => !orderedPhotos.includes( item )
);
removedPhotos.album = filteredAlbum;
} );
@@ -177,7 +183,9 @@ const GroupPhotos = ( ): Node => {
const navToObsEdit = async ( ) => {
const obs = obsToEdit.observations;
const obsPhotos = await Observation.createMutipleObsFromGalleryPhotos( obs );
const realm = await Realm.open( realmConfig );
const obsPhotos = await Observation.createMutipleObsFromGalleryPhotos( obs, realm );
realm.close( );
addObservations( obsPhotos );
navigation.navigate( "ObsEdit" );
};

View File

@@ -1,13 +1,13 @@
// @flow
import React, { useState, useCallback } from "react";
import { View, Pressable } from "react-native";
import type { Node } from "react";
import React, { useCallback, useState } from "react";
import { Pressable, View } from "react-native";
import { viewStyles, textStyles } from "../../styles/photoLibrary/photoGalleryHeader";
import TranslatedText from "../SharedComponents/TranslatedText";
import Modal from "../SharedComponents/Modal";
import { textStyles, viewStyles } from "../../styles/photoLibrary/photoGalleryHeader";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import Modal from "../SharedComponents/Modal";
import TranslatedText from "../SharedComponents/TranslatedText";
type Props = {
combinePhotos: Function,

View File

@@ -1,13 +1,13 @@
// @flow
import React from "react";
import { View, Text } from "react-native";
import type { Node } from "react";
import { useNavigation } from "@react-navigation/native";
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { viewStyles, textStyles } from "../../styles/photoLibrary/photoGalleryHeader";
import { textStyles, viewStyles } from "../../styles/photoLibrary/photoGalleryHeader";
import TranslatedText from "../SharedComponents/TranslatedText";
type Props = {
@@ -24,10 +24,12 @@ const GroupPhotosHeader = ( { photos, observations }: Props ): Node => {
return (
<>
<View style={viewStyles.header}>
<HeaderBackButton onPress={navBack} />
<TranslatedText style={textStyles.header} text="Group-Photos" />
<HeaderBackButton onPress={navBack} />
<TranslatedText style={textStyles.header} text="Group-Photos" />
</View>
<Text style={textStyles.header}>{t( "X-photos-X-observations", { photoCount: photos, observationCount: observations } )}</Text>
<Text style={textStyles.header}>
{t( "X-photos-X-observations", { photoCount: photos, observationCount: observations } )}
</Text>
<TranslatedText style={textStyles.text} text="Combine-photos-onboarding" />
</>
);

View File

@@ -1,16 +1,18 @@
// @flow
import React, { useContext, useEffect } from "react";
import { Pressable, Image, FlatList, ActivityIndicator, View, Text } from "react-native";
import type { Node } from "react";
import { useNavigation } from "@react-navigation/native";
import { t } from "i18next";
import type { Node } from "react";
import React, { useContext, useEffect } from "react";
import {
ActivityIndicator, FlatList, Image, Pressable, Text, View
} from "react-native";
import { imageStyles, viewStyles } from "../../styles/photoLibrary/photoGallery";
import PhotoGalleryHeader from "./PhotoGalleryHeader";
import { PhotoGalleryContext } from "../../providers/contexts";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import { imageStyles, viewStyles } from "../../styles/photoLibrary/photoGallery";
import RoundGreenButton from "../SharedComponents/Buttons/RoundGreenButton";
import ViewNoFooter from "../SharedComponents/ViewNoFooter";
import PhotoGalleryHeader from "./PhotoGalleryHeader";
const options = {
first: 28,
@@ -63,13 +65,13 @@ const PhotoGallery = ( ): Node => {
const photosByAlbum = photoGallery[selectedAlbum];
const photosSelectedInAlbum = selectedPhotos[selectedAlbum] || [];
const updatePhotoGallery = ( rerenderFlatList ) => {
const updatePhotoGallery = rerenderFlatList => {
setPhotoGallery( {
...photoGallery,
// there might be a better way to do this, but adding this key forces the FlatList
// to rerender anytime an image is unselected
rerenderFlatList
} );
} );
};
const selectPhoto = ( isSelected, item ) => {
@@ -126,9 +128,8 @@ const PhotoGallery = ( ): Node => {
const renderEmptyList = ( ) => {
if ( fetchingPhotos ) {
return <ActivityIndicator />;
} else {
return <Text>{t( "No-photos-found" )}</Text>;
}
return <Text>{t( "No-photos-found" )}</Text>;
};
return (

View File

@@ -1,14 +1,14 @@
// @flow
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React from "react";
import { View } from "react-native";
import type { Node } from "react";
import RNPickerSelect from "react-native-picker-select";
import { useNavigation } from "@react-navigation/native";
import { HeaderBackButton } from "@react-navigation/elements";
import usePhotoAlbums from "./hooks/usePhotoAlbums";
import { viewStyles } from "../../styles/photoLibrary/photoGalleryHeader";
import usePhotoAlbums from "./hooks/usePhotoAlbums";
type Props = {
updateAlbum: Function
@@ -17,7 +17,7 @@ type Props = {
const PhotoGalleryHeader = ( { updateAlbum }: Props ): Node => {
const navigation = useNavigation( );
const changeAlbum = ( newAlbum ) => {
const changeAlbum = newAlbum => {
updateAlbum( newAlbum !== "All" ? newAlbum : null );
};

View File

@@ -2,7 +2,10 @@
const sortByTime = array => array.sort( ( a, b ) => b.timestamp - a.timestamp );
const orderByTimestamp = ( albums: Array<Object>, selectedPhotos: Array<Object> ): Array<Object> => {
const orderByTimestamp = (
albums: Array<Object>,
selectedPhotos: Array<Object>
): Array<Object> => {
let unorderedPhotos = [];
albums.forEach( album => {
unorderedPhotos = unorderedPhotos.concat( selectedPhotos[album] );
@@ -12,11 +15,9 @@ const orderByTimestamp = ( albums: Array<Object>, selectedPhotos: Array<Object>
const ordered = sortByTime( unorderedPhotos );
// nest under photos
return ordered.map( photo => {
return {
photos: [photo]
};
} );
return ordered.map( photo => ( {
photos: [photo]
} ) );
};
const flattenAndOrderSelectedPhotos = ( selectedObservations: ?Array<Object> ): Array<Object> => {
@@ -27,10 +28,10 @@ const flattenAndOrderSelectedPhotos = ( selectedObservations: ?Array<Object> ):
} );
// sort selected observations by timestamp and avoid duplicates
return [...new Set( sortByTime( combinedPhotos ) ) ];
return [...new Set( sortByTime( combinedPhotos ) )];
};
export {
orderByTimestamp,
flattenAndOrderSelectedPhotos
flattenAndOrderSelectedPhotos,
orderByTimestamp
};

View File

@@ -1,7 +1,7 @@
// @flow
import { useEffect, useState } from "react";
import CameraRoll from "@react-native-community/cameraroll";
import { useEffect, useState } from "react";
const cameraRoll = [{
label: "camera roll",
@@ -31,7 +31,6 @@ const usePhotoAlbums = ( ): Array<Object> => {
setPhotoAlbums( names );
} catch ( e ) {
console.log( e, "couldn't fetch photo albums" );
return;
}
};
@@ -46,5 +45,3 @@ const usePhotoAlbums = ( ): Array<Object> => {
};
export default usePhotoAlbums;

View File

@@ -1,7 +1,7 @@
// @flow
import { useEffect, useState, useCallback } from "react";
import CameraRoll from "@react-native-community/cameraroll";
import { useCallback, useEffect, useState } from "react";
// import uuid from "react-native-uuid";
// import { formatDateAndTime } from "../../../sharedHelpers/dateAndTime";
@@ -24,14 +24,20 @@ const initialStatus = {
* now, e.g. if permissions have been granted (Android), or if it's ok to
* request permissions (iOS)
*/
const usePhotos = ( options: Object, isScrolling: boolean, canRequestPhotos: boolean = true ): Object => {
const usePhotos = (
options: Object,
isScrolling: boolean,
canRequestPhotos: boolean = true
): Object => {
const [photoFetchStatus, setPhotoFetchStatus] = useState( initialStatus );
const fetchPhotos = useCallback( async ( ) => {
const { lastCursor, photos, fetchingPhotos, hasNextPage } = photoFetchStatus;
const {
lastCursor, photos, fetchingPhotos, hasNextPage
} = photoFetchStatus;
const mapPhotoUris = ( p ) => p.edges.map( ( { node } ) => {
return node;
const mapPhotoUris = p => p.edges.map(
( { node } ) => node
// const latitude = node.location && node.location.latitude;
// const longitude = node.location && node.location.longitude;
// return {
@@ -43,7 +49,7 @@ const usePhotos = ( options: Object, isScrolling: boolean, canRequestPhotos: boo
// // adding a uuid here makes it easier to prevent duplicates in uploader
// uuid: uuid.v4( )
// };
} );
);
try {
// keep track of the last photo fetched
@@ -93,7 +99,7 @@ const usePhotos = ( options: Object, isScrolling: boolean, canRequestPhotos: boo
const changedAlbum = ( ) => {
if ( options.groupName ) {
return photoFetchStatus.lastAlbum !== options.groupName;
} else if ( !options.groupName && photoFetchStatus.lastAlbum ) {
} if ( !options.groupName && photoFetchStatus.lastAlbum ) {
// switch back to all photos mode
return true;
}

View File

@@ -1,9 +1,10 @@
// @flow
import React from "react";
import ViewWithFooter from "./SharedComponents/ViewWithFooter";
import type { Node } from "react";
import React from "react";
import PlaceholderText from "./PlaceholderText";
import ViewWithFooter from "./SharedComponents/ViewWithFooter";
const PlaceholderComponent = ( ): Node => (
<ViewWithFooter>

View File

@@ -1,8 +1,8 @@
// @flow
import type { Node } from "react";
import React from "react";
import { Text } from "react-native";
import type { Node } from "react";
type Props = {
text: string,
@@ -15,5 +15,4 @@ const PlaceholderText = ( { text, style }: Props ): Node => (
</Text>
);
export default PlaceholderText;

View File

@@ -1,11 +1,11 @@
// @flow
import * as React from "react";
import { Text, Image, ImageBackground } from "react-native";
import { useRoute } from "@react-navigation/native";
import * as React from "react";
import { Image, ImageBackground, Text } from "react-native";
import { imageStyles, textStyles } from "../../styles/projects/projectDetails";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import { textStyles, imageStyles } from "../../styles/projects/projectDetails";
import useProjectDetails from "./hooks/useProjectDetails";
import ProjectObservations from "./ProjectObservations";
@@ -16,25 +16,24 @@ const ProjectDetails = ( ): React.Node => {
return (
<ViewWithFooter>
<ImageBackground
source={{ uri: project.header_image_url }}
<ImageBackground
source={{ uri: project.header_image_url }}
// $FlowFixMe
style={imageStyles.headerImage}
testID="ProjectDetails.headerImage"
>
<Image
source={{ uri: project.icon }}
style={imageStyles.icon}
testID="ProjectDetails.projectIcon"
/>
</ImageBackground>
<Text style={textStyles.descriptionText}>{project.title}</Text>
<Text style={textStyles.descriptionText}>{project.description}</Text>
{/* TODO: support joining or leaving projects once oauth is set up */}
<ProjectObservations id={id} />
style={imageStyles.headerImage}
testID="ProjectDetails.headerImage"
>
<Image
source={{ uri: project.icon }}
style={imageStyles.icon}
testID="ProjectDetails.projectIcon"
/>
</ImageBackground>
<Text style={textStyles.descriptionText}>{project.title}</Text>
<Text style={textStyles.descriptionText}>{project.description}</Text>
{/* TODO: support joining or leaving projects once oauth is set up */}
<ProjectObservations id={id} />
</ViewWithFooter>
);
};
export default ProjectDetails;

View File

@@ -1,10 +1,12 @@
// @flow
import * as React from "react";
import { FlatList, Pressable, Text, Image } from "react-native";
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import {
FlatList, Image, Pressable, Text
} from "react-native";
import { imageStyles, viewStyles, textStyles } from "../../styles/projects/projects";
import { imageStyles, textStyles, viewStyles } from "../../styles/projects/projects";
type Props = {
data: Array<Object>
@@ -21,7 +23,11 @@ const ProjectList = ( { data }: Props ): React.Node => {
style={viewStyles.row}
testID={`Project.${item.id}`}
>
<Image source={{ uri: item.icon }} style={imageStyles.projectIcon} testID={`Project.${item.id}.photo`}/>
<Image
source={{ uri: item.icon }}
style={imageStyles.projectIcon}
testID={`Project.${item.id}.photo`}
/>
<Text style={textStyles.projectName}>{item.title}</Text>
</Pressable>
);
@@ -37,4 +43,3 @@ const ProjectList = ( { data }: Props ): React.Node => {
};
export default ProjectList;

View File

@@ -1,11 +1,11 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import { FlatList } from "react-native";
import { useNavigation } from "@react-navigation/native";
import useProjectObservations from "./hooks/useProjectObservations";
import GridItem from "../SharedComponents/ObservationViews/GridItem";
import useProjectObservations from "./hooks/useProjectObservations";
type Props = {
id: number
@@ -14,9 +14,13 @@ type Props = {
const ProjectObservations = ( { id }: Props ): React.Node => {
const observations = useProjectObservations( id );
const navigation = useNavigation( );
const navToObsDetails = observation => navigation.navigate( "ObsDetails", { uuid: observation.uuid } );
const navToObsDetails = observation => {
navigation.navigate( "ObsDetails", { uuid: observation.uuid } );
};
const renderGridItem = ( { item } ) => <GridItem item={item} handlePress={navToObsDetails} uri="project" />;
const renderGridItem = ( { item } ) => (
<GridItem item={item} handlePress={navToObsDetails} uri="project" />
);
return (
<FlatList
data={observations}
@@ -29,4 +33,3 @@ const ProjectObservations = ( { id }: Props ): React.Node => {
};
export default ProjectObservations;

View File

@@ -1,14 +1,14 @@
// @flow
import { t } from "i18next";
import * as React from "react";
import { Pressable, Text, View } from "react-native";
import { t } from "i18next";
import useUserLocation from "../../sharedHooks/useUserLocation";
import { viewStyles } from "../../styles/projects/projects";
import useMemberId from "./hooks/useMemberId";
import useProjects from "./hooks/useProjects";
import ProjectList from "./ProjectList";
import { useUserLocation } from "../../sharedHooks/useUserLocation";
import useMemberId from "./hooks/useMemberId";
const ProjectTabs = ( ): React.Node => {
const memberId = useMemberId( );

View File

@@ -2,10 +2,10 @@
import * as React from "react";
import InputField from "../SharedComponents/InputField";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import ProjectSearch from "./ProjectSearch";
import ProjectTabs from "./ProjectTabs";
import InputField from "../SharedComponents/InputField";
const Projects = ( ): React.Node => {
const [q, setQ] = React.useState( "" );

View File

@@ -1,7 +1,8 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { getJWTToken } from "../../LoginSignUp/AuthenticationService";
const useMemberId = ( ): ?number => {

View File

@@ -1,7 +1,7 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const FIELDS = {
title: true,
@@ -26,7 +26,7 @@ const useProjectDetails = ( id: number ): Object => {
setProjectDetails( results[0] );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch project details for project_id ${id}:`, e.message, );
console.log( `Couldn't fetch project details for project_id ${id}:`, e.message );
}
};

View File

@@ -1,7 +1,7 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const PHOTO_FIELDS = {
id: true,
@@ -44,7 +44,7 @@ const useProjectObservations = ( id: number ): Object => {
setProjectObservations( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch project observations for project_id ${id}:`, e.message, );
console.log( `Couldn't fetch project observations for project_id ${id}:`, e.message );
}
};

View File

@@ -1,7 +1,7 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const FIELDS = {
title: true,
@@ -28,7 +28,7 @@ const useProjects = ( apiParams: Object ): Array<Object> => {
setProjects( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch projects:", e.message, );
console.log( "Couldn't fetch projects:", e.message );
}
};

View File

@@ -1,14 +1,16 @@
// @flow
import * as React from "react";
import { FlatList, Pressable, Text, Image, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import {
FlatList, Image, Pressable, Text, View
} from "react-native";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import InputField from "../SharedComponents/InputField";
import { viewStyles, imageStyles } from "../../styles/search/search";
import { imageStyles, viewStyles } from "../../styles/search/search";
import PlaceholderText from "../PlaceholderText";
import InputField from "../SharedComponents/InputField";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
const Search = ( ): React.Node => {
const navigation = useNavigation( );
@@ -30,23 +32,30 @@ const Search = ( ): React.Node => {
style={viewStyles.row}
testID={`Search.taxa.${item.id}`}
>
<Image source={imageUrl} style={imageStyles.squareImage} testID={`Search.${item.id}.photo`} />
<Image
source={imageUrl}
style={imageStyles.squareImage}
testID={`Search.${item.id}.photo`}
/>
<Text>{`${item.preferred_common_name} (${item.rank} ${item.name})`}</Text>
</Pressable>
);
} else {
return (
<Pressable
onPress={navToUserProfile}
style={viewStyles.row}
testID={`Search.user.${item.login}`}
>
{/* TODO: add an empty icon when user doesn't have an icon */}
<Image source={{ uri: item.icon }} style={imageStyles.circularImage} testID={`Search.${item.login}.photo`}/>
<Text>{`${item.login} (${item.name})`}</Text>
</Pressable>
);
}
return (
<Pressable
onPress={navToUserProfile}
style={viewStyles.row}
testID={`Search.user.${item.login}`}
>
{/* TODO: add an empty icon when user doesn't have an icon */}
<Image
source={{ uri: item.icon }}
style={imageStyles.circularImage}
testID={`Search.${item.login}.photo`}
/>
<Text>{`${item.login} (${item.name})`}</Text>
</Pressable>
);
};
const setTaxaSearch = ( ) => setQueryType( "taxa" );
@@ -76,11 +85,11 @@ const Search = ( ): React.Node => {
text={q}
type="none"
/>
<FlatList
data={list}
renderItem={renderItem}
testID="Search.listView"
/>
<FlatList
data={list}
renderItem={renderItem}
testID="Search.listView"
/>
</ViewWithFooter>
);
};

View File

@@ -0,0 +1,39 @@
// @flow
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import {
Image,
Pressable,
Text,
View
} from "react-native";
import { viewStyles } from "../../styles/settings/settings";
type Props = {
unblockUser: Function,
user: Object
}
const BlockedUser = ( { user, unblockUser }:Props ): Node => (
<View style={[viewStyles.row, viewStyles.relationshipRow]}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: user.icon }}
/>
<View style={viewStyles.column}>
<Text>{user.login}</Text>
<Text>{user.name}</Text>
</View>
<Pressable
style={viewStyles.removeRelationship}
onPress={() => unblockUser( user )}
>
<Text>{t( "Unblock" )}</Text>
</Pressable>
</View>
);
export default BlockedUser;

View File

@@ -0,0 +1,39 @@
// @flow
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import {
Image,
Pressable,
Text,
View
} from "react-native";
import { viewStyles } from "../../styles/settings/settings";
type Props = {
unmuteUser: Function,
user: Object
}
const MutedUser = ( { user, unmuteUser }: Props ): Node => (
<View style={[viewStyles.row, viewStyles.relationshipRow]}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: user.icon }}
/>
<View style={viewStyles.column}>
<Text>{user.login}</Text>
<Text>{user.name}</Text>
</View>
<Pressable
style={viewStyles.removeRelationship}
onPress={() => unmuteUser( user )}
>
<Text>{t( "Unmute" )}</Text>
</Pressable>
</View>
);
export default MutedUser;

View File

@@ -1,13 +1,16 @@
import React, {useEffect} from "react";
import {useDebounce} from "use-debounce";
import {Image, Text, TextInput, View} from "react-native";
import {textStyles, viewStyles} from "../../styles/settings/settings";
import React, { useEffect } from "react";
import {
Image, Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import {inatPlaceTypes} from "../../dictionaries/places";
import usePlaces from "./hooks/usePlaces";
import usePlaceDetails from "./hooks/usePlaceDetails";
import { useDebounce } from "use-debounce";
const PlaceSearchInput = ( { placeId, onPlaceChanged} ): React.Node => {
import inatPlaceTypes from "../../dictionaries/places";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import usePlaceDetails from "./hooks/usePlaceDetails";
import usePlaces from "./hooks/usePlaces";
const PlaceSearchInput = ( { placeId, onPlaceChanged } ): React.Node => {
const [hideResults, setHideResults] = React.useState( true );
const [placeSearch, setPlaceSearch] = React.useState( "" );
// So we'll start searching only once the user finished typing
@@ -22,25 +25,27 @@ const PlaceSearchInput = ( { placeId, onPlaceChanged} ): React.Node => {
} else {
setPlaceSearch( "" );
}
}, [placeDetails] );
return (
return (
<View style={viewStyles.column}>
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
onChangeText={v => {
setHideResults( false );
setPlaceSearch( v );
}}
value={placeSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setHideResults( true );
setPlaceSearch( "" );
onPlaceChanged( 0 );
}}>
<Pressable
style={viewStyles.clearSearch}
onPress={() => {
setHideResults( true );
setPlaceSearch( "" );
onPlaceChanged( 0 );
}}
>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
@@ -48,19 +53,21 @@ const PlaceSearchInput = ( { placeId, onPlaceChanged} ): React.Node => {
/>
</Pressable>
</View>
{!hideResults && finalPlaceSearch.length > 0 && placeResults.map( ( place ) => (
<Pressable key={place.id} style={[viewStyles.row, viewStyles.placeResultContainer]}
onPress={() => {
setHideResults( true );
onPlaceChanged( place.id );
}}>
{!hideResults && finalPlaceSearch.length > 0 && placeResults.map( place => (
<Pressable
key={place.id}
style={[viewStyles.row, viewStyles.placeResultContainer]}
onPress={() => {
setHideResults( true );
onPlaceChanged( place.id );
}}
>
<Text style={textStyles.resultPlaceName}>{place.display_name}</Text>
<Text style={textStyles.resultPlaceType}>{inatPlaceTypes[place.place_type]}</Text>
</Pressable>
) )}
</View>
);
};
export default PlaceSearchInput;

View File

@@ -0,0 +1,71 @@
// @flow
import CheckBox from "@react-native-community/checkbox";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import {
Image,
Pressable,
Text,
View
} from "react-native";
import colors from "../../styles/colors";
import { viewStyles } from "../../styles/settings/settings";
type Props = {
relationship: Object,
updateRelationship: Function,
askToRemoveRelationship: Function
}
const Relationship = ( {
askToRemoveRelationship,
relationship,
updateRelationship
}: Props ): Node => (
<View style={[viewStyles.column, viewStyles.relationshipRow]}>
<View style={viewStyles.row}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: relationship.friendUser.icon_url }}
/>
<View style={viewStyles.column}>
<Text>{relationship.friendUser.login}</Text>
<Text>{relationship.friendUser.name}</Text>
</View>
<View style={viewStyles.column}>
<View style={[viewStyles.row, viewStyles.notificationCheckbox]}>
<CheckBox
value={relationship.following}
onValueChange={
( ) => { updateRelationship( relationship, { following: !relationship.following } ); }
}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text>{t( "Following" )}</Text>
</View>
<View style={[viewStyles.row, viewStyles.notificationCheckbox]}>
<CheckBox
value={relationship.trust}
onValueChange={
( ) => { updateRelationship( relationship, { trust: !relationship.trust } ); }
}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text>{t( "Trust-with-hidden-coordinates" )}</Text>
</View>
</View>
</View>
<Text>{t( "Added-on-date", { date: relationship.created_at } )}</Text>
<Pressable
style={viewStyles.removeRelationship}
onPress={() => askToRemoveRelationship( relationship )}
>
<Text>{t( "Remove-Relationship" )}</Text>
</Pressable>
</View>
);
export default Relationship;

View File

@@ -1,7 +1,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { useFocusEffect } from "@react-navigation/native";
import { t } from "i18next";
import inatjs from "inaturalistjs";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator, Alert,
ActivityIndicator,
Alert,
Button,
Pressable,
SafeAreaView,
@@ -10,22 +14,20 @@ import {
Text,
View
} from "react-native";
import type { Node } from "react";
import inatjs from "inaturalistjs";
import { viewStyles, textStyles } from "../../styles/settings/settings";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import { getAPIToken } from "../LoginSignUp/AuthenticationService";
import SettingsProfile from "./SettingsProfile";
import {
SettingsNotifications,
EMAIL_NOTIFICATIONS
} from "./SettingsNotifications";
import SettingsAccount from "./SettingsAccount";
import SettingsContentDisplay from "./SettingsContentDisplay";
import SettingsApplications from "./SettingsApplications";
import SettingsRelationships from "./SettingsRelationships";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import useUserMe from "./hooks/useUserMe";
import { t } from "i18next";
import SettingsAccount from "./SettingsAccount";
import SettingsApplications from "./SettingsApplications";
import SettingsContentDisplay from "./SettingsContentDisplay";
import {
EMAIL_NOTIFICATIONS,
SettingsNotifications
} from "./SettingsNotifications";
import SettingsProfile from "./SettingsProfile";
import SettingsRelationships from "./SettingsRelationships";
const TAB_TYPE_PROFILE = "profile";
const TAB_TYPE_ACCOUNT = "account";
@@ -69,86 +71,82 @@ type Props = {
children: React.Node,
};
const SettingsTabs = ( { activeTab, onTabPress } ): React.Node => {
return (
<>
<View style={[viewStyles.tabsRow, viewStyles.shadow]}>
<Pressable
onPress={() => onTabPress( TAB_TYPE_PROFILE )}
accessibilityRole="link"
>
<Text
style={activeTab === TAB_TYPE_PROFILE ? textStyles.activeTab : null}
>
{t( "Profile" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_ACCOUNT )}
accessibilityRole="link"
>
<Text
style={activeTab === TAB_TYPE_ACCOUNT ? textStyles.activeTab : null}
>
{t( "Account" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_NOTIFICATIONS )}
accessibilityRole="link"
>
<Text
style={
const SettingsTabs = ( { activeTab, onTabPress } ): React.Node => (
<View style={[viewStyles.tabsRow, viewStyles.shadow]}>
<Pressable
onPress={() => onTabPress( TAB_TYPE_PROFILE )}
accessibilityRole="link"
>
<Text
style={activeTab === TAB_TYPE_PROFILE ? textStyles.activeTab : null}
>
{t( "Profile" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_ACCOUNT )}
accessibilityRole="link"
>
<Text
style={activeTab === TAB_TYPE_ACCOUNT ? textStyles.activeTab : null}
>
{t( "Account" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_NOTIFICATIONS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_NOTIFICATIONS ? textStyles.activeTab : null
}
>
{t( "Notifications" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_RELATIONSHIPS )}
accessibilityRole="link"
>
<Text
style={
>
{t( "Notifications" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_RELATIONSHIPS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_RELATIONSHIPS ? textStyles.activeTab : null
}
>
{t( "Relationships" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_CONTENT_DISPLAY )}
accessibilityRole="link"
>
<Text
style={
>
{t( "Relationships" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_CONTENT_DISPLAY )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_CONTENT_DISPLAY
? textStyles.activeTab
: null
}
>
{t( "Content-Display" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_APPLICATIONS )}
accessibilityRole="link"
>
<Text
style={
>
{t( "Content-Display" )}
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_APPLICATIONS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_APPLICATIONS ? textStyles.activeTab : null
}
>
{t( "Applications" )}
</Text>
</Pressable>
</View>
</>
);
};
>
{t( "Applications" )}
</Text>
</Pressable>
</View>
);
const Settings = ( { children }: Props ): Node => {
const Settings = ( { children: _children }: Props ): Node => {
const [activeTab, setActiveTab] = useState( TAB_TYPE_PROFILE );
const [settings, setSettings] = useState( {} );
const [accessToken, setAccessToken] = useState( null );
@@ -223,12 +221,13 @@ const Settings = ( { children }: Props ): Node => {
useFocusEffect(
React.useCallback( () => {
// Reload the settings
getAPIToken( true ).then( ( token ) => {
getAPIToken( true ).then( token => {
setAccessToken( token );
} );
return () => {
// De-focused - clean up the access token (this will force a refresh later when we're re-focused)
// De-focused - clean up the access token (this will force a refresh
// later when we're re-focused)
setAccessToken( null );
};
}, [] )

View File

@@ -1,82 +1,92 @@
// @flow
import {Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React from "react";
import {Picker} from "@react-native-picker/picker";
import {colors} from "../../styles/global";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import CheckBox from "@react-native-community/checkbox";
import {inatLanguages} from "../../dictionaries/languages";
import {inatNetworks} from "../../dictionaries/networks";
import PlaceSearchInput from "./PlaceSearchInput";
import type { Node } from "react";
import type { SettingsProps } from "./types";
import { Picker } from "@react-native-picker/picker";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Text, View } from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
const SettingsAccount = ( { settings, onSettingsModified }: SettingsProps ): Node => {
import inatLanguages from "../../dictionaries/languages";
import inatNetworks from "../../dictionaries/networks";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import PlaceSearchInput from "./PlaceSearchInput";
import type { SettingsProps } from "./types";
return (
<>
<Text style={textStyles.title}>{t( "Account" )}</Text>
const SettingsAccount = ( { settings, onSettingsModified }: SettingsProps ): Node => (
<>
<Text style={textStyles.title}>{t( "Account" )}</Text>
<Text style={[textStyles.subTitle]}>{t( "Language-Locale" )}</Text>
<Text>{t( "This-sets-your-language-and-date-formatting-preferences-across-iNaturalist" )}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.locale}
onValueChange={( itemValue, itemIndex ) =>
onSettingsModified( { ...settings, locale: itemValue } )
}>
{Object.keys( inatLanguages ).map( ( k ) => (
<Picker.Item
key={k}
label={inatLanguages[k]}
value={k} />
) )}
</Picker>
</View>
<Text style={[textStyles.subTitle]}>{t( "Language-Locale" )}</Text>
<Text>{t( "This-sets-your-language-and-date-formatting-preferences-across-iNaturalist" )}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.locale}
onValueChange={
( itemValue, _itemIndex ) => onSettingsModified( { ...settings, locale: itemValue } )
}
>
{Object.keys( inatLanguages ).map( k => (
<Picker.Item
key={k}
label={inatLanguages[k]}
value={k}
/>
) )}
</Picker>
</View>
<Text style={[textStyles.subTitle]}>{t( "Default-Search-Place" )}</Text>
<Text>{t( "This-will-be-your-default-place-for-all-searches-in-Explore-and-Identify" )}</Text>
<PlaceSearchInput placeId={settings.search_place_id} onPlaceChanged={( p ) => onSettingsModified( { ...settings, search_place_id: p} )} />
<Text style={[textStyles.subTitle]}>{t( "Default-Search-Place" )}</Text>
<Text>{t( "This-will-be-your-default-place-for-all-searches-in-Explore-and-Identify" )}</Text>
<PlaceSearchInput
placeId={settings.search_place_id}
onPlaceChanged={p => onSettingsModified( { ...settings, search_place_id: p } )}
/>
<Text style={[textStyles.subTitle]}>{t( "Privacy" )}</Text>
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => onSettingsModified( { ...settings, prefers_no_tracking: !settings.prefers_no_tracking} )}>
<CheckBox
value={settings.prefers_no_tracking}
onValueChange={( v ) => onSettingsModified( { ...settings, prefers_no_tracking: v} )}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text style={[textStyles.checkbox, viewStyles.column]}>{t( "Do-not-collect-stability-and-usage-data-using-third-party-services" )}</Text>
</Pressable>
<Text style={[textStyles.subTitle]}>{t( "Privacy" )}</Text>
<Pressable
style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => onSettingsModified( {
...settings,
prefers_no_tracking: !settings.prefers_no_tracking
} )}
>
<CheckBox
value={settings.prefers_no_tracking}
onValueChange={v => onSettingsModified( { ...settings, prefers_no_tracking: v } )}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text style={[textStyles.checkbox, viewStyles.column]}>
{t( "Do-not-collect-stability-and-usage-data-using-third-party-services" )}
</Text>
</Pressable>
<Text style={[textStyles.subTitle]}>{t( "iNaturalist-Network-Affiliation" )}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.site_id}
onValueChange={
( itemValue, _itemIndex ) => onSettingsModified( { ...settings, site_id: itemValue } )
}
>
{Object.keys( inatNetworks ).map( k => (
<Picker.Item
key={k}
label={inatNetworks[k].name}
value={k}
/>
) )}
</Picker>
</View>
<Text>{t( "The-iNaturalist-Network-is-a-collection-of-localized-websites" )}</Text>
<Text style={[textStyles.subTitle]}>{t( "iNaturalist-Network-Affiliation" )}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.site_id}
onValueChange={( itemValue, itemIndex ) =>
onSettingsModified( { ...settings, site_id: itemValue } )
}>
{Object.keys( inatNetworks ).map( ( k ) => (
<Picker.Item
key={k}
label={inatNetworks[k].name}
value={k} />
) )}
</Picker>
</View>
<Text>{t( "The-iNaturalist-Network-is-a-collection-of-localized-websites" )}</Text>
</>
);
};
</>
);
export default SettingsAccount;

View File

@@ -1,15 +1,16 @@
// @flow
import {Alert, Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React, {useEffect, useState} from "react";
import type { Node } from "react";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import {inatProviders} from "../../dictionaries/providers";
import { t } from "i18next";
import inatjs from "inaturalistjs";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { Alert, Text, View } from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import inatProviders from "../../dictionaries/providers";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import useAuthorizedApplications from "./hooks/useAuthorizedApplications";
import useProviderAuthorizations from "./hooks/useProviderAuthorizations";
import { t } from "i18next";
type Props = {
accessToken: string
@@ -24,17 +25,19 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
setAuthorizedApps( currentAuthorizedApps );
}, [currentAuthorizedApps] );
const revokeApp = async ( appId ) => {
const response = await inatjs.authorized_applications.delete( { id: appId }, {api_token: accessToken} );
const revokeApp = async appId => {
const response = await inatjs.authorized_applications.delete(
{ id: appId },
{ api_token: accessToken }
);
console.log( "Revoked app", response );
// Refresh authorized applications
const apps = await inatjs.authorized_applications.search( {}, {api_token: accessToken} );
const apps = await inatjs.authorized_applications.search( {}, { api_token: accessToken } );
console.log( "Authorized Applications", apps.results );
setAuthorizedApps( apps.results );
};
const askToRevokeApp = ( app ) => {
const askToRevokeApp = app => {
Alert.alert(
`Revoke ${app.application.name}?`,
"This will sign you out of your current session on this application.",
@@ -50,23 +53,37 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
return (
<View style={viewStyles.column}>
<Text style={textStyles.title}>{t( "iNaturalist-Applications" )}</Text>
{authorizedApps.filter( ( app ) => app.application.official ).map( ( app ) => (
<Text key={app.application.id}>{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}</Text>
{authorizedApps.filter( app => app.application.official ).map( app => (
<Text key={app.application.id}>
{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}
</Text>
) )}
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "Connected-Accounts" )}</Text>
{Object.keys( inatProviders ).map( ( providerKey ) => {
const connectedProvider = providerAuthorizations.find( x => x.provider_name === providerKey );
return ( <Text
key={providerKey}>{inatProviders[providerKey]} {connectedProvider && `(authorized on: ${connectedProvider.created_at})`}</Text> );
{Object.keys( inatProviders ).map( providerKey => {
const connectedProvider = providerAuthorizations.find(
x => x.provider_name === providerKey
);
return (
<Text
key={providerKey}
>
{inatProviders[providerKey]}
{" "}
{connectedProvider && `(authorized on: ${connectedProvider.created_at})`}
</Text>
);
} )}
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "External-Applications" )}</Text>
{authorizedApps.filter( ( app ) => !app.application.official ).map( ( app ) => (
{authorizedApps.filter( app => !app.application.official ).map( app => (
<View key={app.application.id} style={[viewStyles.row, viewStyles.applicationRow]}>
<Text style={textStyles.applicationName}>{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}</Text>
<Pressable style={viewStyles.revokeAccess} onPress={() => askToRevokeApp( app )}><Text>{t( "Revoke" )}</Text></Pressable>
<Text style={textStyles.applicationName}>
{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}
</Text>
<Pressable style={viewStyles.revokeAccess} onPress={() => askToRevokeApp( app )}>
<Text>{t( "Revoke" )}</Text>
</Pressable>
</View>
) )}
</View>

View File

@@ -1,16 +1,17 @@
// @flow
import {Pressable, Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React from "react";
import {Picker} from "@react-native-picker/picker";
import {colors} from "../../styles/global";
import CheckBox from "@react-native-community/checkbox";
import PlaceSearchInput from "./PlaceSearchInput";
import {inatLicenses} from "../../dictionaries/licenses";
import type { Node } from "react";
import type { SettingsProps } from "./types";
import { Picker } from "@react-native-picker/picker";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import inatLicenses from "../../dictionaries/licenses";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import PlaceSearchInput from "./PlaceSearchInput";
import type { SettingsProps } from "./types";
const PROJECT_SETTINGS = {
any: "Any",
@@ -30,7 +31,6 @@ const ADD_OBSERVATION_FIELDS = {
observer: "Only you"
};
const LicenseSelector = ( {
value,
onValueChanged,
@@ -38,73 +38,96 @@ const LicenseSelector = ( {
updateExistingTitle,
onUpdateExisting,
updateExisting
} ): Node => {
return <>
} ): Node => (
<>
<Text style={textStyles.subTitle}>{title}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={value}
onValueChange={onValueChanged}>
{inatLicenses.map( ( l ) => (
onValueChange={onValueChanged}
>
{inatLicenses.map( l => (
<Picker.Item
key={l.value}
label={l.title}
value={l.value} />
value={l.value}
/>
) )}
</Picker>
</View>
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => {
onUpdateExisting( !updateExisting );
}}>
<Pressable
style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => {
onUpdateExisting( !updateExisting );
}}
>
<CheckBox
value={updateExisting}
onValueChange={onUpdateExisting}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text style={textStyles.notificationTitle}>{updateExistingTitle}</Text>
</Pressable>
</>;
};
</>
);
const SettingsContentDisplay = ( { settings, onSettingsModified }: SettingsProps ): Node => {
let taxonNamePreference = "prefers_common_names";
if ( settings.prefers_scientific_name_first ) {
taxonNamePreference = "prefers_scientific_name_first";
} else if ( settings.prefers_scientific_names ) {
taxonNamePreference = "prefers_scientific_names";
}
return (
<>
<Text style={textStyles.title}>{t( "Project-Settings" )}</Text>
<Text style={textStyles.subTitle}>{t( "Which-traditional-projects-can-add-your-observations" )}</Text>
<Text style={textStyles.subTitle}>
{t( "Which-traditional-projects-can-add-your-observations" )}
</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.preferred_project_addition_by}
onValueChange={( itemValue, itemIndex ) =>
onSettingsModified( { ...settings, preferred_project_addition_by: itemValue } )
}>
{Object.keys( PROJECT_SETTINGS ).map( ( k ) => (
onValueChange={( itemValue, _itemIndex ) => onSettingsModified( {
...settings,
preferred_project_addition_by: itemValue
} )}
>
{Object.keys( PROJECT_SETTINGS ).map( k => (
<Picker.Item
key={k}
label={PROJECT_SETTINGS[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "Taxonomy-Settings" )}</Text>
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => {
onSettingsModified( { ...settings, prefers_automatic_taxonomic_changes: !settings.prefers_automatic_taxonomic_changes } );
}}>
<Pressable
style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => {
onSettingsModified( {
...settings,
prefers_automatic_taxonomic_changes: !settings.prefers_automatic_taxonomic_changes
} );
}}
>
<CheckBox
value={settings.prefers_automatic_taxonomic_changes}
onValueChange={( v ) => {
onValueChange={v => {
onSettingsModified( { ...settings, prefers_automatic_taxonomic_changes: v } );
}}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text style={textStyles.notificationTitle}>{t( "Automatically-update-my-content-for-taxon-changes" )}</Text>
<Text style={textStyles.notificationTitle}>
{t( "Automatically-update-my-content-for-taxon-changes" )}
</Text>
</Pressable>
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "Names" )}</Text>
@@ -114,68 +137,84 @@ const SettingsContentDisplay = ( { settings, onSettingsModified }: SettingsProps
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={
settings.prefers_common_names && !settings.prefers_scientific_name_first ? "prefers_common_names" :
( settings.prefers_common_names && settings.prefers_scientific_name_first ?
"prefers_scientific_name_first" : "prefers_scientific_names"
)
}
onValueChange={( value, itemIndex ) => {
selectedValue={taxonNamePreference}
onValueChange={( value, _itemIndex ) => {
if ( value === "prefers_common_names" ) {
onSettingsModified( { ...settings,
onSettingsModified( {
...settings,
prefers_common_names: true,
prefers_scientific_name_first: false
} );
} else if ( value === "prefers_scientific_name_first" ) {
onSettingsModified( { ...settings,
onSettingsModified( {
...settings,
prefers_common_names: true,
prefers_scientific_name_first: true
} );
} else if ( value === "prefers_scientific_names" ) {
onSettingsModified( { ...settings,
onSettingsModified( {
...settings,
prefers_common_names: false,
prefers_scientific_name_first: false
} );
}
}}>
{Object.keys( TAXON_DISPLAY ).map( ( k ) => (
}}
>
{Object.keys( TAXON_DISPLAY ).map( k => (
<Picker.Item
key={k}
label={TAXON_DISPLAY[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
<Text style={textStyles.subTitle}>{t( "Prioritize-common-names-used-in-this-place" )}</Text>
<PlaceSearchInput placeId={settings.place_id} onPlaceChanged={( p ) => onSettingsModified( { ...settings, place_id: p} )} />
<PlaceSearchInput
placeId={settings.place_id}
onPlaceChanged={p => onSettingsModified( { ...settings, place_id: p } )}
/>
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "Community-Moderation-Settings" )}</Text>
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => {
onSettingsModified( { ...settings, prefers_community_taxa: !settings.prefers_community_taxa } );
}}>
<Text style={[textStyles.title, textStyles.marginTop]}>
{t( "Community-Moderation-Settings" )}
</Text>
<Pressable
style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => {
onSettingsModified( {
...settings,
prefers_community_taxa: !settings.prefers_community_taxa
} );
}}
>
<CheckBox
value={settings.prefers_community_taxa}
onValueChange={( v ) => {
onValueChange={v => {
onSettingsModified( { ...settings, prefers_community_taxa: v } );
}}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text style={textStyles.notificationTitle}>{t( "Accept-community-identifications" )}</Text>
</Pressable>
<Text style={textStyles.subTitle}>{t( "Who-can-add-observation-fields-to-my-observations" )}</Text>
<Text style={textStyles.subTitle}>
{t( "Who-can-add-observation-fields-to-my-observations" )}
</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={settings.preferred_observation_fields_by}
onValueChange={( itemValue, itemIndex ) =>
onSettingsModified( { ...settings, preferred_observation_fields_by: itemValue } )
}>
{Object.keys( ADD_OBSERVATION_FIELDS ).map( ( k ) => (
onValueChange={( itemValue, _itemIndex ) => onSettingsModified( {
...settings,
preferred_observation_fields_by: itemValue
} )}
>
{Object.keys( ADD_OBSERVATION_FIELDS ).map( k => (
<Picker.Item
key={k}
label={ADD_OBSERVATION_FIELDS[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
@@ -184,26 +223,32 @@ const SettingsContentDisplay = ( { settings, onSettingsModified }: SettingsProps
<LicenseSelector
title="Default observation license"
value={settings.preferred_observation_license}
onValueChanged={( v ) => onSettingsModified( { ...settings, preferred_observation_license: v} )}
onValueChanged={v => onSettingsModified( {
...settings,
preferred_observation_license: v
} )}
updateExistingTitle="Update existing observations with new license choices"
updateExisting={settings.make_observation_licenses_same}
onUpdateExisting={( v ) => onSettingsModified( { ...settings, make_observation_licenses_same: v} )}
onUpdateExisting={v => onSettingsModified( {
...settings,
make_observation_licenses_same: v
} )}
/>
<LicenseSelector
title="Default photo license"
value={settings.preferred_photo_license}
onValueChanged={( v ) => onSettingsModified( { ...settings, preferred_photo_license: v} )}
onValueChanged={v => onSettingsModified( { ...settings, preferred_photo_license: v } )}
updateExistingTitle="Update existing photos with new license choices"
updateExisting={settings.make_photo_licenses_same}
onUpdateExisting={( v ) => onSettingsModified( { ...settings, make_photo_licenses_same: v} )}
onUpdateExisting={v => onSettingsModified( { ...settings, make_photo_licenses_same: v } )}
/>
<LicenseSelector
title="Default sound license"
value={settings.preferred_sound_license}
onValueChanged={( v ) => onSettingsModified( { ...settings, preferred_sound_license: v} )}
onValueChanged={v => onSettingsModified( { ...settings, preferred_sound_license: v } )}
updateExistingTitle="Update existing sounds with new license choices"
updateExisting={settings.make_sound_licenses_same}
onUpdateExisting={( v ) => onSettingsModified( { ...settings, make_sound_licenses_same: v} )}
onUpdateExisting={v => onSettingsModified( { ...settings, make_sound_licenses_same: v } )}
/>
</>
);

View File

@@ -1,43 +1,48 @@
// @flow
import {Pressable, Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React from "react";
import type { Node } from "react";
import Switch from "react-native/Libraries/Components/Switch/Switch";
import CheckBox from "@react-native-community/checkbox";
import { colors } from "../../styles/global";
import type { SettingsProps } from "./types";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import Switch from "react-native/Libraries/Components/Switch/Switch";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import type { SettingsProps } from "./types";
const EMAIL_NOTIFICATIONS = {
"Comments": "prefers_comment_email_notification",
"Identifications": "prefers_identification_email_notification",
"Mentions": "prefers_mention_email_notification",
"Messages": "prefers_message_email_notification",
Comments: "prefers_comment_email_notification",
Identifications: "prefers_identification_email_notification",
Mentions: "prefers_mention_email_notification",
Messages: "prefers_message_email_notification",
"Project journal posts": "prefers_project_journal_post_email_notification",
// eslint-disable-next-line max-len
"When a project adds your observations": "prefers_project_added_your_observation_email_notification",
"Project curator changes": "prefers_project_curator_change_email_notification",
"Taxonomy changes": "prefers_taxon_change_email_notification",
"Observations by people I follow": "prefers_user_observation_email_notification",
// eslint-disable-next-line max-len
"Observations of taxa or from places that I subscribe to": "prefers_taxon_or_place_observation_email_notification"
};
const EmailNotification = ( { title, value, onValueChange } ): Node => (
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => onValueChange( !value )}>
<Pressable
style={[viewStyles.row, viewStyles.notificationCheckbox]}
onPress={() => onValueChange( !value )}
>
<CheckBox
value={value}
onValueChange={onValueChange}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
tintColors={{ false: colors.inatGreen, true: colors.inatGreen }}
/>
<Text style={textStyles.notificationTitle}>{title}</Text>
<Text style={textStyles.notificationTitle}>{title}</Text>
</Pressable>
);
const Notification = ( { title, description, value, onValueChange } ): Node => (
const Notification = ( {
title, description, value, onValueChange
} ): Node => (
<View style={[viewStyles.row, viewStyles.notificationContainer]}>
<View style={[viewStyles.column, viewStyles.notificationLeftSide]}>
<Text style={textStyles.notificationTitle}>{title}</Text>
@@ -50,41 +55,49 @@ const Notification = ( { title, description, value, onValueChange } ): Node => (
);
const SettingsNotifications = ( { settings, onSettingsModified }: SettingsProps ): Node => (
<>
<Text style={textStyles.title}>{t( "iNaturalist-Activity-Notifications" )}</Text>
<Notification
title="Notify me of mentions (e.g. @username)"
description="If you turn this off, you will not get any notifications when someone mentions you on iNaturalist."
value={settings.prefers_receive_mentions}
onValueChange={( v ) => onSettingsModified( { ...settings, prefers_receive_mentions: v} )}
/>
<Notification
title="Confirming ID's"
description="If you turn this off, you will no longer be notified about IDs that agree with yours."
value={settings.prefers_redundant_identification_notifications}
onValueChange={( v ) => onSettingsModified( { ...settings, prefers_redundant_identification_notifications: v} )}
/>
<Text style={textStyles.title}>{t( "Email-Notifications" )}</Text>
<Notification
title="Receive Email Notifications"
description="If you turn this off, you will no longer receive any emails from iNaturalist regarding notifications."
value={!settings.prefers_no_email}
onValueChange={( v ) => onSettingsModified( { ...settings, prefers_no_email: !v} )}
/>
<>
<Text style={textStyles.title}>{t( "iNaturalist-Activity-Notifications" )}</Text>
<Notification
title="Notify me of mentions (e.g. @username)"
// eslint-disable-next-line max-len
description="If you turn this off, you will not get any notifications when someone mentions you on iNaturalist."
value={settings.prefers_receive_mentions}
onValueChange={v => onSettingsModified( { ...settings, prefers_receive_mentions: v } )}
/>
<Notification
title="Confirming ID's"
// eslint-disable-next-line max-len
description="If you turn this off, you will no longer be notified about IDs that agree with yours."
value={settings.prefers_redundant_identification_notifications}
onValueChange={v => onSettingsModified( {
...settings,
prefers_redundant_identification_notifications: v
} )}
/>
<Text style={textStyles.title}>{t( "Email-Notifications" )}</Text>
<Notification
title="Receive Email Notifications"
// eslint-disable-next-line max-len
description="If you turn this off, you will no longer receive any emails from iNaturalist regarding notifications."
value={!settings.prefers_no_email}
onValueChange={v => onSettingsModified( { ...settings, prefers_no_email: !v } )}
/>
{!settings.prefers_no_email &&
{!settings.prefers_no_email
&& (
<>
{Object.keys( EMAIL_NOTIFICATIONS ).map( ( k ) => (
{Object.keys( EMAIL_NOTIFICATIONS ).map( k => (
<EmailNotification
key={k}
title={k}
value={settings[EMAIL_NOTIFICATIONS[k]]}
// $FlowIgnore
onValueChange={( v ) => onSettingsModified( { ...settings, [EMAIL_NOTIFICATIONS[k]]: v } )}
onValueChange={v => onSettingsModified( { ...settings, [EMAIL_NOTIFICATIONS[k]]: v } )}
/>
) )}
</>}
</>
</>
)}
</>
);
export { SettingsNotifications, EMAIL_NOTIFICATIONS };
export { EMAIL_NOTIFICATIONS, SettingsNotifications };

View File

@@ -1,28 +1,35 @@
// @flow
import {Button, Image, Text, TextInput, View} from "react-native";
import {viewStyles} from "../../styles/settings/settings";
import { t } from "i18next";
// $FlowIgnore
import {launchImageLibrary} from "react-native-image-picker";
import React from "react";
import type { Node } from "react";
import React from "react";
import {
Button, Image, Text, TextInput, View
} from "react-native";
// $FlowIgnore
import { launchImageLibrary } from "react-native-image-picker";
import { viewStyles } from "../../styles/settings/settings";
import type { SettingsProps } from "./types";
const SettingsProfile = ( { settings, onSettingsModified }: SettingsProps ): Node => {
let profileSource;
if ( settings.newProfilePhoto && !settings.removeProfilePhoto ) {
profileSource = { uri: settings.newProfilePhoto.uri };
} else if (
settings.icon && !settings.removeProfilePhoto ) { profileSource = { uri: settings.icon };
settings.icon && !settings.removeProfilePhoto ) {
profileSource = { uri: settings.icon };
} else {
profileSource = require( "./../../images/profile.png" );
profileSource = require( "../../images/profile.png" );
}
const onImageSelected = ( response ) => {
if ( response.didCancel ) {return;}
onSettingsModified( { ...settings, newProfilePhoto: response.assets[0], removeProfilePhoto: false } );
const onImageSelected = response => {
if ( response.didCancel ) { return; }
onSettingsModified( {
...settings,
newProfilePhoto: response.assets[0],
removeProfilePhoto: false
} );
};
return (
@@ -34,15 +41,21 @@ const SettingsProfile = ( { settings, onSettingsModified }: SettingsProps ): Nod
source={profileSource}
/>
<View style={viewStyles.column}>
<Button title="Upload New Photo" onPress={() => launchImageLibrary( {}, onImageSelected )} />
<Button title="Remove Photo" onPress={() => onSettingsModified( { ...settings, removeProfilePhoto: true } )} />
<Button
title="Upload New Photo"
onPress={() => launchImageLibrary( {}, onImageSelected )}
/>
<Button
title="Remove Photo"
onPress={() => onSettingsModified( { ...settings, removeProfilePhoto: true } )}
/>
</View>
</View>
<View style={viewStyles.column}>
<Text>{t( "Username" )}</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, login: x} )}
onChangeText={x => onSettingsModified( { ...settings, login: x } )}
value={settings.login}
/>
</View>
@@ -50,7 +63,7 @@ const SettingsProfile = ( { settings, onSettingsModified }: SettingsProps ): Nod
<Text>{t( "Email" )}</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, email: x} )}
onChangeText={x => onSettingsModified( { ...settings, email: x } )}
value={settings.email}
/>
</View>
@@ -58,7 +71,7 @@ const SettingsProfile = ( { settings, onSettingsModified }: SettingsProps ): Nod
<Text>{t( "Display-Name" )}</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, name: x} )}
onChangeText={x => onSettingsModified( { ...settings, name: x } )}
value={settings.name}
/>
</View>
@@ -68,7 +81,7 @@ const SettingsProfile = ( { settings, onSettingsModified }: SettingsProps ): Nod
style={viewStyles.textInput}
multiline
numberOfLines={4}
onChangeText={( x ) => onSettingsModified( { ...settings, description: x} )}
onChangeText={x => onSettingsModified( { ...settings, description: x } )}
value={settings.description}
/>
</View>

View File

@@ -1,18 +1,23 @@
// @flow
import { Alert, Image, Text, TextInput, View } from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React, { useEffect, useMemo, useCallback } from "react";
import type { Node } from "react";
import { Picker } from "@react-native-picker/picker";
import { t } from "i18next";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import {useDebounce} from "use-debounce";
import inatjs from "inaturalistjs";
import {Picker} from "@react-native-picker/picker";
import {colors} from "../../styles/global";
import CheckBox from "@react-native-community/checkbox";
import UserSearchInput from "./UserSearchInput";
import type { Node } from "react";
import React, { useCallback, useEffect } from "react";
import {
Alert, Image, Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import { useDebounce } from "use-debounce";
import colors from "../../styles/colors";
import { textStyles, viewStyles } from "../../styles/settings/settings";
import BlockedUser from "./BlockedUser";
import useRelationships from "./hooks/useRelationships";
import MutedUser from "./MutedUser";
import Relationship from "./Relationship";
import UserSearchInput from "./UserSearchInput";
const FOLLOWING = {
any: "All",
@@ -66,15 +71,32 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
orderBy = "user";
order = "desc";
}
const relationshipParams = {q: finalUserSearch, following, trusted, order_by: orderBy, order: order, per_page: 10, page, random: refreshRelationships };
const [relationshipResults, perPage, totalResults] = useRelationships( accessToken, relationshipParams );
const relationshipParams = {
q: finalUserSearch,
following,
trusted,
order_by: orderBy,
order,
per_page: 10,
page,
random: refreshRelationships
};
const [
relationshipResults,
perPage,
totalResults
] = useRelationships( accessToken, relationshipParams );
const totalPages = totalResults > 0 && perPage > 0 ? Math.ceil( totalResults / perPage ) : 1;
useEffect( () => {
const getBlockedUsers = async () => {
try {
const responses = await Promise.all( settings.blocked_user_ids.map( ( userId ) => inatjs.users.fetch( userId, { fields: "icon,login,name"} ) ) );
setBlockedUsers( responses.map( ( r ) => r.results[0] ) );
const responses = await Promise.all(
settings.blocked_user_ids.map(
userId => inatjs.users.fetch( userId, { fields: "icon,login,name" } )
)
);
setBlockedUsers( responses.map( r => r.results[0] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
@@ -85,7 +107,6 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
cancelable: true
}
);
return;
}
};
if ( settings.blocked_user_ids.length > 0 ) {
@@ -96,8 +117,12 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
const getMutedUsers = async () => {
try {
const responses = await Promise.all( settings.muted_user_ids.map( ( userId ) => inatjs.users.fetch( userId, { fields: "icon,login,name" } ) ) );
setMutedUsers( responses.map( ( r ) => r.results[0] ) );
const responses = await Promise.all(
settings.muted_user_ids.map(
userId => inatjs.users.fetch( userId, { fields: "icon,login,name" } )
)
);
setMutedUsers( responses.map( r => r.results[0] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
@@ -108,7 +133,6 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
cancelable: true
}
);
return;
}
};
if ( settings.muted_user_ids.length > 0 ) {
@@ -118,13 +142,12 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
}
}, [settings] );
const updateRelationship = useCallback( async ( relationship, update ) => {
let response;
try {
response = await inatjs.relationships.update(
{ id: relationship.id, relationship: update },
{ api_token: accessToken}
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
@@ -142,17 +165,19 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
setRefreshRelationships( Math.random() );
}, [accessToken] );
const askToRemoveRelationship = useCallback( ( relationship ) => {
const askToRemoveRelationship = useCallback( relationship => {
Alert.alert(
"Remove Relationship?",
`You will no longer be following or trusting ${relationship.friendUser.login}.`,
[
{ text: "Remove Relationship", onPress: async () => {
{
text: "Remove Relationship",
onPress: async () => {
let response;
try {
response = await inatjs.relationships.delete(
{ id: relationship.id },
{ api_token: accessToken}
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
@@ -168,7 +193,8 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
}
console.log( response );
setRefreshRelationships( Math.random() );
} }
}
}
],
{
cancelable: true
@@ -176,13 +202,12 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
);
}, [accessToken] );
const unblockUser = useCallback( async ( user ) => {
const unblockUser = useCallback( async user => {
let response;
try {
response = await inatjs.users.unblock(
{ id: user.id },
{ api_token: accessToken}
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
@@ -200,15 +225,15 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
onRefreshUser();
}, [accessToken, onRefreshUser] );
const blockUser = async ( user ) => {
if ( !user ) {return;}
const blockUser = async user => {
if ( !user ) { return; }
let response;
try {
response = await inatjs.users.block(
{ id: user.id },
{ api_token: accessToken}
);
response = await inatjs.users.block(
{ id: user.id },
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
Alert.alert(
@@ -225,27 +250,12 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
onRefreshUser();
};
// $FlowFixMe
const BlockedUser = useMemo( ( { user } ): Node => {
return <View style={[viewStyles.row, viewStyles.relationshipRow]}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: user.icon}}
/>
<View style={viewStyles.column}>
<Text>{user.login}</Text>
<Text>{user.name}</Text>
</View>
<Pressable style={viewStyles.removeRelationship} onPress={() => unblockUser( user )}><Text>{t( "Unblock" )}</Text></Pressable>
</View>;
}, [unblockUser] );
const unmuteUser = useCallback( async ( user ) => {
const unmuteUser = useCallback( async user => {
let response;
try {
response = await inatjs.users.unmute(
{ id: user.id },
{ api_token: accessToken}
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
@@ -263,15 +273,14 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
onRefreshUser();
}, [accessToken, onRefreshUser] );
const muteUser = async ( user ) => {
if ( !user ) {return;}
const muteUser = async user => {
if ( !user ) { return; }
let response;
try {
response = await inatjs.users.mute(
{ id: user.id },
{ api_token: accessToken}
{ api_token: accessToken }
);
} catch ( e ) {
console.error( e );
@@ -289,58 +298,6 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
onRefreshUser();
};
// $FlowFixMe
const MutedUser = useMemo( ( {user} ): Node => {
return <View style={[viewStyles.row, viewStyles.relationshipRow]}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: user.icon}}
/>
<View style={viewStyles.column}>
<Text>{user.login}</Text>
<Text>{user.name}</Text>
</View>
<Pressable style={viewStyles.removeRelationship} onPress={() => unmuteUser( user )}><Text>{t( "Unmute" )}</Text></Pressable>
</View>;
}, [unmuteUser] );
// $FlowFixMe
const Relationship = useMemo( ( {relationship} ): Node => {
return <View style={[viewStyles.column, viewStyles.relationshipRow]}>
<View style={viewStyles.row}>
<Image
style={viewStyles.relationshipImage}
source={{ uri: relationship.friendUser.icon_url}}
/>
<View style={viewStyles.column}>
<Text>{relationship.friendUser.login}</Text>
<Text>{relationship.friendUser.name}</Text>
</View>
<View style={viewStyles.column}>
<View style={[viewStyles.row, viewStyles.notificationCheckbox]}>
<CheckBox
value={relationship.following}
onValueChange={( x ) => { updateRelationship( relationship, { following: !relationship.following } ); }}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text>{t( "Following" )}</Text>
</View>
<View style={[viewStyles.row, viewStyles.notificationCheckbox]}>
<CheckBox
value={relationship.trust}
onValueChange={( x ) => { updateRelationship( relationship, { trust: !relationship.trust } ); }}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text>{t( "Trust-with-hidden-coordinates" )}</Text>
</View>
</View>
</View>
<Text>{t( "Added-on-date", { date: relationship.created_at } )}</Text>
<Pressable style={viewStyles.removeRelationship} onPress={() => askToRemoveRelationship( relationship )}><Text>{t( "Remove-Relationship" )}</Text></Pressable>
</View>;
}, [askToRemoveRelationship, updateRelationship] );
return (
// $FlowFixMe
<View style={viewStyles.column}>
@@ -348,14 +305,17 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
onChangeText={v => {
setUserSearch( v );
}}
value={userSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setUserSearch( "" );
}}>
<Pressable
style={viewStyles.clearSearch}
onPress={() => {
setUserSearch( "" );
}}
>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
@@ -370,14 +330,14 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={following}
onValueChange={( itemValue, itemIndex ) =>
setFollowing( itemValue )
}>
{Object.keys( FOLLOWING ).map( ( k ) => (
onValueChange={( itemValue, _itemIndex ) => setFollowing( itemValue )}
>
{Object.keys( FOLLOWING ).map( k => (
<Picker.Item
key={k}
label={FOLLOWING[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
@@ -390,14 +350,14 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={trusted}
onValueChange={( itemValue, itemIndex ) =>
setTrusted( itemValue )
}>
{Object.keys( TRUSTED ).map( ( k ) => (
onValueChange={( itemValue, _itemIndex ) => setTrusted( itemValue )}
>
{Object.keys( TRUSTED ).map( k => (
<Picker.Item
key={k}
label={TRUSTED[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
@@ -410,43 +370,65 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={sortBy}
onValueChange={( itemValue, itemIndex ) =>
setSortBy( itemValue )
}>
{Object.keys( SORT_BY ).map( ( k ) => (
onValueChange={( itemValue, _itemIndex ) => setSortBy( itemValue )}
>
{Object.keys( SORT_BY ).map( k => (
<Picker.Item
key={k}
label={SORT_BY[k]}
value={k} />
value={k}
/>
) )}
</Picker>
</View>
</View>
{relationshipResults.map( ( relationship ) => (
// $FlowFixMe
<Relationship key={relationship.id} relationship={relationship} />
{relationshipResults.map( relationship => (
<Relationship
key={relationship.id}
relationship={relationship}
updateRelationship={updateRelationship}
askToRemoveRelationship={askToRemoveRelationship}
/>
) )}
{ totalPages > 1 && <View style={[viewStyles.row, viewStyles.paginationContainer]}>
<Pressable disabled={page === 1} style={viewStyles.pageButton} onPress={() => setPage( page - 1 )}><Text>&lt;</Text></Pressable>
{[...Array( totalPages ).keys()].map( ( x ) => (
<Pressable key={x} style={viewStyles.pageButton} onPress={() => setPage( x + 1 )}><Text style={x + 1 === page ? textStyles.currentPage : null}>{x + 1}</Text></Pressable>
{ totalPages > 1 && (
<View style={[viewStyles.row, viewStyles.paginationContainer]}>
<Pressable
disabled={page === 1}
style={viewStyles.pageButton}
onPress={() => setPage( page - 1 )}
>
<Text>&lt;</Text>
</Pressable>
{[...Array( totalPages ).keys()].map( x => (
<Pressable
key={x}
style={viewStyles.pageButton}
onPress={() => setPage( x + 1 )}
>
<Text style={x + 1 === page ? textStyles.currentPage : null}>{x + 1}</Text>
</Pressable>
) )}
<Pressable disabled={page === totalPages} style={viewStyles.pageButton} onPress={() => setPage( page + 1 )}><Text>&gt;</Text></Pressable>
</View>}
<Pressable
disabled={page === totalPages}
style={viewStyles.pageButton}
onPress={() => setPage( page + 1 )}
>
<Text>&gt;</Text>
</Pressable>
</View>
)}
<Text style={textStyles.title}>{t( "Blocked-Users" )}</Text>
<UserSearchInput userId={0} onUserChanged={( u ) => blockUser( u )} />
{blockedUsers.map( ( user ) => (
// $FlowFixMe
<BlockedUser key={user.id} user={user} />
<UserSearchInput userId={0} onUserChanged={u => blockUser( u )} />
{blockedUsers.map( user => (
<BlockedUser key={user.id} user={user} unblockUser={unblockUser} />
) )}
<Text style={textStyles.title}>{t( "Muted-Users" )}</Text>
<UserSearchInput userId={0} onUserChanged={( u ) => muteUser( u )} />
{mutedUsers.map( ( user ) => (
// $FlowFixMe
<MutedUser key={user.id} user={user} />
<UserSearchInput userId={0} onUserChanged={u => muteUser( u )} />
{mutedUsers.map( user => (
<MutedUser key={user.id} user={user} unmuteUser={unmuteUser} />
) )}
</View>
);

View File

@@ -1,16 +1,23 @@
import React, {useEffect} from "react";
import {useDebounce} from "use-debounce";
import {Image, Text, TextInput, View} from "react-native";
import {textStyles, viewStyles} from "../../styles/settings/settings";
import React, { useEffect } from "react";
import {
Image, Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { useDebounce } from "use-debounce";
const UserSearchInput = ( { onUserChanged} ): React.Node => {
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { textStyles, viewStyles } from "../../styles/settings/settings";
const UserSearchInput = ( { onUserChanged } ): React.Node => {
const [hideResults, setHideResults] = React.useState( true );
const [userSearch, setUserSearch] = React.useState( "" );
// So we'll start searching only once the user finished typing
const [finalUserSearch] = useDebounce( userSearch, 500 );
const userResults = useRemoteSearchResults( finalUserSearch, "users", "user.login,user.name,user.icon" ).map( r => r.user );
const userResults = useRemoteSearchResults(
finalUserSearch,
"users",
"user.login,user.name,user.icon"
).map( r => r.user );
useEffect( () => {
if ( finalUserSearch.length === 0 ) {
@@ -18,22 +25,25 @@ const UserSearchInput = ( { onUserChanged} ): React.Node => {
}
}, [finalUserSearch] );
return (
return (
<View style={viewStyles.column}>
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
onChangeText={v => {
setHideResults( false );
setUserSearch( v );
}}
value={userSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setHideResults( true );
onUserChanged( null );
setUserSearch( "" );
}}>
<Pressable
style={viewStyles.clearSearch}
onPress={() => {
setHideResults( true );
onUserChanged( null );
setUserSearch( "" );
}}
>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
@@ -41,13 +51,16 @@ const UserSearchInput = ( { onUserChanged} ): React.Node => {
/>
</Pressable>
</View>
{!hideResults && finalUserSearch.length > 0 && userResults.map( ( result ) => (
<Pressable key={result.id} style={[viewStyles.row, viewStyles.placeResultContainer]}
onPress={() => {
setHideResults( true );
onUserChanged( result );
setUserSearch( result.login );
}}>
{!hideResults && finalUserSearch.length > 0 && userResults.map( result => (
<Pressable
key={result.id}
style={[viewStyles.row, viewStyles.placeResultContainer]}
onPress={() => {
setHideResults( true );
onUserChanged( result );
setUserSearch( result.login );
}}
>
<Image
style={viewStyles.userPic}
resizeMode="contain"
@@ -59,7 +72,6 @@ const UserSearchInput = ( { onUserChanged} ): React.Node => {
) )}
</View>
);
};
export default UserSearchInput;

Some files were not shown because too many files have changed in this diff Show More