Settings Screen (#77)

Gets most of the way toward #27. Blocks, mutes, and some other functionality require API v2 updates. UI needs refinement.
This commit is contained in:
budowski
2022-06-10 00:49:22 +02:00
committed by GitHub
parent 1f2becdef6
commit 0144200e9c
34 changed files with 5382 additions and 20575 deletions

View File

@@ -3,6 +3,7 @@
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:textColor">#000000</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>

View File

@@ -475,7 +475,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -540,7 +540,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;

23825
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"date-fns": "^2.28.0",
"date-fns-tz": "^1.3.3",
"fbjs": "^3.0.4",
"i18next": "^21.6.14",
"i18next-fluent": "^2.0.0",
"i18next-resources-to-backend": "^1.0.0",
@@ -47,6 +48,7 @@
"react-native-geolocation-service": "^5.3.0-beta.4",
"react-native-gesture-handler": "^2.4.1",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^4.7.3",
"react-native-image-resizer": "^1.4.5",
"react-native-jwt-io": "^1.0.3",
"react-native-localize": "^2.2.1",
@@ -67,7 +69,8 @@
"react-native-vision-camera": "^2.13.0",
"react-spring": "^8.0.27",
"react-tinder-card": "^1.4.5",
"realm": "^10.20.0-beta.1"
"realm": "^10.20.0-beta.1",
"use-debounce": "^7.0.1"
},
"devDependencies": {
"@babel/core": "^7.12.9",

View File

@@ -27,7 +27,7 @@ const CustomDrawerContent = ( { ...props }: Props ): Node => {
/>
<DrawerItem
label="settings"
onPress={( ) => console.log( "nav to settings" )}
onPress={( ) => navigation.navigate( "settings" )}
/>
<DrawerItem
label="following"

View File

@@ -10,7 +10,7 @@ import {getBuildNumber, getDeviceType, getSystemName, getSystemVersion, getVersi
// 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 HOST = Config.OAUTH_API_URL || process.env.OAUTH_API_URL || "https://www.inaturalist.org";
const API_HOST = Config.OAUTH_API_URL || process.env.OAUTH_API_URL || "https://www.inaturalist.org";
// User agent being used, when calling the iNat APIs
const USER_AGENT = `iNaturalistRN/${getVersion()} ${getDeviceType()} (Build ${getBuildNumber()}) ${getSystemName()}/${getSystemVersion()}`;
@@ -23,7 +23,7 @@ const JWT_TOKEN_EXPIRATION_MINS = 25; // JWT Tokens expire after 30 mins - consi
*/
const createAPI = ( additionalHeaders: any ) => {
return create( {
baseURL: HOST,
baseURL: API_HOST,
headers: { "User-Agent": USER_AGENT, ...additionalHeaders }
} );
};
@@ -305,6 +305,7 @@ const signOut = async () => {
};
export {
API_HOST,
authenticateUser,
registerUser,
getAPIToken,

View File

@@ -0,0 +1,66 @@
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 Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import {inatPlaceTypes} from "../../dictionaries/places";
import usePlaces from "./hooks/usePlaces";
import usePlaceDetails from "./hooks/usePlaceDetails";
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
const [finalPlaceSearch] = useDebounce( placeSearch, 500 );
const placeResults = usePlaces( finalPlaceSearch );
const placeDetails = usePlaceDetails( placeId );
useEffect( () => {
if ( placeDetails ) {
console.log( "Place details", placeDetails );
setPlaceSearch( placeDetails.display_name );
} else {
setPlaceSearch( "" );
}
}, [placeDetails] );
return (
<View style={viewStyles.column}>
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
setHideResults( false );
setPlaceSearch( v );
}}
value={placeSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setHideResults( true );
setPlaceSearch( "" );
onPlaceChanged( 0 );
}}>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
source={require( "../../images/clear.png" )}
/>
</Pressable>
</View>
{!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,295 @@
// @flow strict-local
import React, { useCallback, useEffect, useState } from "react";
import { useFocusEffect } from "@react-navigation/native";
import {
ActivityIndicator, Alert,
Button,
Pressable,
SafeAreaView,
ScrollView,
StatusBar,
Text,
View
} from "react-native";
import type { Node } from "react";
import inatjs from "inaturalistjs";
import { viewStyles, textStyles } 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";
const TAB_TYPE_PROFILE = "profile";
const TAB_TYPE_ACCOUNT = "account";
const TAB_TYPE_NOTIFICATIONS = "notifications";
const TAB_TYPE_RELATIONSHIPS = "relationships";
const TAB_TYPE_CONTENT_DISPLAY = "content_display";
const TAB_TYPE_APPLICATIONS = "applications";
// List of all user settings that will be saved (when calling the API to update the settings).
const SETTINGS_PROPERTIES_LIST = [
"login",
"email",
"name",
"description",
"prefers_receive_mentions",
"prefers_redundant_identification_notifications",
"prefers_no_email",
"time_zone",
"locale",
"search_place_id",
"prefers_no_tracking",
"site_id",
"preferred_project_addition_by",
"prefers_common_names",
"prefers_scientific_name_first",
"place_id",
"prefers_community_taxa",
"preferred_observation_fields_by",
"preferred_observation_license",
"preferred_photo_license",
"preferred_sound_license",
"make_observation_licenses_same",
"make_photo_licenses_same",
"make_sound_licenses_same",
...Object.values( EMAIL_NOTIFICATIONS )
];
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}
>
profile
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_ACCOUNT )}
accessibilityRole="link"
>
<Text
style={activeTab === TAB_TYPE_ACCOUNT ? textStyles.activeTab : null}
>
account
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_NOTIFICATIONS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_NOTIFICATIONS ? textStyles.activeTab : null
}
>
notifications
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_RELATIONSHIPS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_RELATIONSHIPS ? textStyles.activeTab : null
}
>
relationships
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_CONTENT_DISPLAY )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_CONTENT_DISPLAY
? textStyles.activeTab
: null
}
>
content&display
</Text>
</Pressable>
<Pressable
onPress={() => onTabPress( TAB_TYPE_APPLICATIONS )}
accessibilityRole="link"
>
<Text
style={
activeTab === TAB_TYPE_APPLICATIONS ? textStyles.activeTab : null
}
>
applications
</Text>
</Pressable>
</View>
</>
);
};
const Settings = ( { children }: Props ): Node => {
const [activeTab, setActiveTab] = useState( TAB_TYPE_PROFILE );
const [settings, setSettings] = useState();
const [accessToken, setAccessToken] = useState();
const [isLoading, setIsLoading] = useState( true );
const [isSaving, setIsSaving] = useState( false );
const user = useUserMe( accessToken );
const fetchProfile = useCallback( async () => {
if ( user ) {
console.log( "User object", user );
setSettings( user );
setIsLoading( false );
}
}, [user] );
useEffect( () => {
if ( accessToken ) {
fetchProfile();
}
}, [accessToken, fetchProfile] );
const saveSettings = async () => {
setIsSaving( true );
const payload = {
id: settings.id
};
SETTINGS_PROPERTIES_LIST.forEach( ( v ) => {
payload[`user[${v}]`] = settings[v];
} );
if ( settings.removeProfilePhoto ) {
payload.icon_delete = true;
}
if ( settings.newProfilePhoto ) {
payload["user[icon]"] = {
type: "custom",
value: {
uri: settings.newProfilePhoto.uri,
type: settings.newProfilePhoto.type,
name: settings.newProfilePhoto.fileName
}
};
}
console.log( "Payload", payload );
let response;
try {
response = await inatjs.users.update( payload, {
api_token: accessToken
} );
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't save settings!",
[{ text: "OK" }],
{
cancelable: true
}
);
setIsSaving( false );
return;
}
console.log( "Updated user", response );
const userResponse = await inatjs.users.me( { api_token: accessToken, fields: "all" } );
console.log( "User object", userResponse.results[0] );
setSettings( userResponse.results[0] );
setIsSaving( false );
};
useFocusEffect(
React.useCallback( () => {
// Reload the settings
getAPIToken( true ).then( ( t ) => {
setAccessToken( t );
} );
return () => {
// De-focused - clean up the access token (this will force a refresh later when we're re-focused)
setAccessToken( null );
};
}, [] )
);
return (
<ViewWithFooter>
<SafeAreaView style={viewStyles.container}>
<StatusBar barStyle="dark-content" />
<View style={viewStyles.headerRow}>
<Text style={textStyles.header}>Settings</Text>
<Button
style={viewStyles.saveSettings}
title="Save"
onPress={saveSettings}
disabled={isLoading || isSaving}
/>
</View>
<SettingsTabs activeTab={activeTab} onTabPress={setActiveTab} />
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<ScrollView>
{activeTab === TAB_TYPE_PROFILE && (
<SettingsProfile
settings={settings}
onSettingsModified={setSettings}
/>
)}
{activeTab === TAB_TYPE_ACCOUNT && (
<SettingsAccount
settings={settings}
onSettingsModified={setSettings}
/>
)}
{activeTab === TAB_TYPE_NOTIFICATIONS && (
<SettingsNotifications
settings={settings}
onSettingsModified={setSettings}
/>
)}
{activeTab === TAB_TYPE_CONTENT_DISPLAY && (
<SettingsContentDisplay
settings={settings}
onSettingsModified={setSettings}
/>
)}
{activeTab === TAB_TYPE_APPLICATIONS && (
<SettingsApplications accessToken={accessToken} />
)}
{activeTab === TAB_TYPE_RELATIONSHIPS && (
<SettingsRelationships
settings={settings}
accessToken={accessToken}
onRefreshUser={fetchProfile}
/>
)}
</ScrollView>
)}
</SafeAreaView>
</ViewWithFooter>
);
};
export default Settings;

View File

@@ -0,0 +1,80 @@
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";
const SettingsAccount = ( { settings, onSettingsModified } ): React.Node => {
return (
<>
<Text style={textStyles.title}>Account</Text>
<Text style={[textStyles.subTitle]}>Language/Locale</Text>
<Text>This sets your language and date formatting preferences across iNaturalist based on your locale.</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]}>Default Search Place</Text>
<Text>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]}>Privacy</Text>
<Text>This will be your default place for all searches in Explore and Identify.</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]}>Do not collect stability and usage data using third-party services</Text>
</Pressable>
<Text style={[textStyles.subTitle]}>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>The iNaturalist Network is a collection of localized websites that are fully connected to the global iNaturalist community. Network sites are supported by local institutions that have signed an agreement with iNaturalist to promote local use and benefit local biodiversity. They have access to true coordinates from their countries that are automatically obscured from public view in order to protect threatened species.
Your username and password works on all sites that are part of the iNaturalist Network. If you choose to affiliate with a Network site, the local institutions that operate each site will also have access to your email address (only to communicate with you about site activities) and access to the true coordinates for observations that are publicly obscured or private.
Note: Please do not experimentally change your affiliation if you have more than 1000 observations.</Text>
</>
);
};
export default SettingsAccount;

View File

@@ -0,0 +1,68 @@
import {Alert, Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React, {useEffect, useState} from "react";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import {inatProviders} from "../../dictionaries/providers";
import inatjs from "inaturalistjs";
import useAuthorizedApplications from "./hooks/useAuthorizedApplications";
import useProviderAuthorizations from "./hooks/useProviderAuthorizations";
const SettingsApplications = ( { accessToken } ): React.Node => {
const currentAuthorizedApps = useAuthorizedApplications( accessToken );
const [authorizedApps, setAuthorizedApps] = useState( [] );
const providerAuthorizations = useProviderAuthorizations( accessToken );
useEffect( () => {
setAuthorizedApps( currentAuthorizedApps );
}, [currentAuthorizedApps] );
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} );
console.log( "Authorized Applications", apps.results );
setAuthorizedApps( apps.results );
};
const askToRevokeApp = ( app ) => {
Alert.alert(
`Revoke ${app.application.name}?`,
"This will sign you out of your current session on this application.",
[
{ text: "Revoke", onPress: () => revokeApp( app.application.id ) }
],
{
cancelable: true
}
);
};
return (
<View style={viewStyles.column}>
<Text style={textStyles.title}>iNaturalist Applications</Text>
{authorizedApps.filter( ( app ) => app.application.official ).map( ( app ) => (
<Text key={app.application.id}>{app.application.name} (authorized on: {app.created_at})</Text>
) )}
<Text style={[textStyles.title, textStyles.marginTop]}>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> );
} )}
<Text style={[textStyles.title, textStyles.marginTop]}>External Applications</Text>
{authorizedApps.filter( ( app ) => !app.application.official ).map( ( app ) => (
<View key={app.application.id} style={[viewStyles.row, viewStyles.applicationRow]}>
<Text style={textStyles.applicationName}>{app.application.name} (authorized on: {app.created_at})</Text>
<Pressable style={viewStyles.revokeAccess} onPress={() => askToRevokeApp( app )}><Text>Revoke</Text></Pressable>
</View>
) )}
</View>
);
};
export default SettingsApplications;

View File

@@ -0,0 +1,200 @@
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";
const PROJECT_SETTINGS = {
any: "Any",
joined: "Projects you've joined",
none: "None, only you can add your observations to projects"
};
const TAXON_DISPLAY = {
prefers_common_names: "Common Name (Scientific Name)",
prefers_scientific_name_first: "Scientific Name (Common Name)",
prefers_scientific_names: "Scientific Name"
};
const ADD_OBSERVATION_FIELDS = {
anyone: "Anyone",
curators: "Curators",
observer: "Only you"
};
const LicenseSelector = ( { value, onValueChanged, title, updateExistingTitle, onUpdateExisting, updateExisting } ): React.Node => {
return <>
<Text style={textStyles.subTitle}>{title}</Text>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={value}
onValueChange={onValueChanged}>
{inatLicenses.map( ( l ) => (
<Picker.Item
key={l.value}
label={l.title}
value={l.value} />
) )}
</Picker>
</View>
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => {
onUpdateExisting( !updateExisting );
}}>
<CheckBox
value={updateExisting}
onValueChange={onUpdateExisting}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text style={textStyles.notificationTitle}>{updateExistingTitle}</Text>
</Pressable>
</>;
};
const SettingsContentDisplay = ( { settings, onSettingsModified } ): React.Node => {
return (
<>
<Text style={textStyles.title}>Project Settings</Text>
<Text style={textStyles.subTitle}>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 ) => (
<Picker.Item
key={k}
label={PROJECT_SETTINGS[k]}
value={k} />
) )}
</Picker>
</View>
<Text style={[textStyles.title, textStyles.marginTop]}>Taxonomy Settings</Text>
<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 ) => {
onSettingsModified( { ...settings, prefers_automatic_taxonomic_changes: v } );
}}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text style={textStyles.notificationTitle}>Automatically update my content for taxon changes</Text>
</Pressable>
<Text style={[textStyles.title, textStyles.marginTop]}>Names</Text>
<Text style={textStyles.subTitle}>Display</Text>
<Text>This is how all taxon names will be displayed to you across iNaturalist</Text>
<View style={viewStyles.selectorContainer}>
<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 ) => {
if ( value === "prefers_common_names" ) {
onSettingsModified( { ...settings,
prefers_common_names: true,
prefers_scientific_name_first: false
} );
} else if ( value === "prefers_scientific_name_first" ) {
onSettingsModified( { ...settings,
prefers_common_names: true,
prefers_scientific_name_first: true
} );
} else if ( value === "prefers_scientific_names" ) {
onSettingsModified( { ...settings,
prefers_common_names: false,
prefers_scientific_name_first: false
} );
}
}}>
{Object.keys( TAXON_DISPLAY ).map( ( k ) => (
<Picker.Item
key={k}
label={TAXON_DISPLAY[k]}
value={k} />
) )}
</Picker>
</View>
<Text style={textStyles.subTitle}>Prioritize common names used in this place.</Text>
<PlaceSearchInput placeId={settings.place_id} onPlaceChanged={( p ) => onSettingsModified( { ...settings, place_id: p} )} />
<Text style={[textStyles.title, textStyles.marginTop]}>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 ) => {
onSettingsModified( { ...settings, prefers_community_taxa: v } );
}}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text style={textStyles.notificationTitle}>Accept community identifications</Text>
</Pressable>
<Text style={textStyles.subTitle}>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 ) => (
<Picker.Item
key={k}
label={ADD_OBSERVATION_FIELDS[k]}
value={k} />
) )}
</Picker>
</View>
<Text style={[textStyles.title, textStyles.marginTop]}>Licensing</Text>
<LicenseSelector
title="Default observation license"
value={settings.preferred_observation_license}
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} )}
/>
<LicenseSelector
title="Default photo license"
value={settings.preferred_photo_license}
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} )}
/>
<LicenseSelector
title="Default sound license"
value={settings.preferred_sound_license}
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} )}
/>
</>
);
};
export default SettingsContentDisplay;

View File

@@ -0,0 +1,84 @@
import {Pressable, Text, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React from "react";
import Switch from "react-native/Libraries/Components/Switch/Switch";
import CheckBox from "@react-native-community/checkbox";
import { colors } from "../../styles/global";
const EMAIL_NOTIFICATIONS = {
"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",
"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",
"Observations of taxa or from places that I subscribe to": "prefers_taxon_or_place_observation_email_notification"
};
const EmailNotification = ( { title, value, onValueChange } ): React.Node => (
<Pressable style={[viewStyles.row, viewStyles.notificationCheckbox]} onPress={() => onValueChange( !value )}>
<CheckBox
value={value}
onValueChange={onValueChange}
tintColors={{false: colors.inatGreen, true: colors.inatGreen}}
/>
<Text style={textStyles.notificationTitle}>{title}</Text>
</Pressable>
);
const Notification = ( { title, description, value, onValueChange } ): React.Node => (
<View style={[viewStyles.row, viewStyles.notificationContainer]}>
<View style={[viewStyles.column, viewStyles.notificationLeftSide]}>
<Text style={textStyles.notificationTitle}>{title}</Text>
<Text>{description}</Text>
</View>
<View style={[viewStyles.column, viewStyles.switch]}>
<Switch value={value} onValueChange={onValueChange} />
</View>
</View>
);
const SettingsNotifications = ( { settings, onSettingsModified } ): React.Node => (
<>
<Text style={textStyles.title}>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}>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} )}
/>
{!settings.prefers_no_email &&
<>
{Object.keys( EMAIL_NOTIFICATIONS ).map( ( k ) => (
<EmailNotification
key={k}
title={k}
value={settings[EMAIL_NOTIFICATIONS[k]]}
onValueChange={( v ) => onSettingsModified( { ...settings, [EMAIL_NOTIFICATIONS[k]]: v } )}
/>
) )}
</>}
</>
);
export { SettingsNotifications, EMAIL_NOTIFICATIONS };

View File

@@ -0,0 +1,73 @@
import {Button, Image, Text, TextInput, View} from "react-native";
import {viewStyles} from "../../styles/settings/settings";
import {launchImageLibrary} from "react-native-image-picker";
import React from "react";
const SettingsProfile = ( { settings, onSettingsModified } ): React.Node => {
let profileSource;
if ( settings.newProfilePhoto && !settings.removeProfilePhoto ) {
profileSource = { uri: settings.newProfilePhoto.uri };
} else if (
settings.icon && !settings.removeProfilePhoto ) { profileSource = { uri: settings.icon };
} else {
profileSource = require( "./../../images/profile.png" );
}
const onImageSelected = ( response ) => {
if ( response.didCancel ) {return;}
onSettingsModified( { ...settings, newProfilePhoto: response.assets[0], removeProfilePhoto: false } );
};
return (
<>
<Text>Profile Picture</Text>
<View style={viewStyles.row}>
<Image
style={viewStyles.profileImage}
source={profileSource}
/>
<View style={viewStyles.column}>
<Button title="Upload New Photo" onPress={() => launchImageLibrary( {}, onImageSelected )} />
<Button title="Remove Photo" onPress={() => onSettingsModified( { ...settings, removeProfilePhoto: true } )} />
</View>
</View>
<View style={viewStyles.column}>
<Text>Username</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, login: x} )}
value={settings.login}
/>
</View>
<View style={viewStyles.column}>
<Text>Email</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, email: x} )}
value={settings.email}
/>
</View>
<View style={viewStyles.column}>
<Text>Display Name</Text>
<TextInput
style={viewStyles.textInput}
onChangeText={( x ) => onSettingsModified( { ...settings, name: x} )}
value={settings.name}
/>
</View>
<View style={viewStyles.column}>
<Text>Bio</Text>
<TextInput
style={viewStyles.textInput}
multiline
numberOfLines={4}
onChangeText={( x ) => onSettingsModified( { ...settings, description: x} )}
value={settings.description}
/>
</View>
</>
);
};
export default SettingsProfile;

View File

@@ -0,0 +1,442 @@
import {Alert, Image, Text, TextInput, View} from "react-native";
import {viewStyles, textStyles} from "../../styles/settings/settings";
import React, {useEffect} from "react";
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 useRelationships from "./hooks/useRelationships";
const FOLLOWING = {
any: "All",
yes: "Yes",
no: "No"
};
const TRUSTED = {
any: "All",
yes: "Yes",
no: "No"
};
const SORT_BY = {
recently_added: "Recently Added",
earliest_added: "Earliest Added",
a_to_z: "A to Z",
z_to_a: "Z to A"
};
const SettingsRelationships = ( { accessToken, settings, onRefreshUser } ): React.Node => {
const [userSearch, setUserSearch] = React.useState( "" );
// So we'll start searching only once the user finished typing
const [finalUserSearch] = useDebounce( userSearch, 500 );
const [following, setFollowing] = React.useState( "all" );
const [trusted, setTrusted] = React.useState( "all" );
const [sortBy, setSortBy] = React.useState( "desc" );
const [page, setPage] = React.useState( 1 );
const [blockedUsers, setBlockedUsers] = React.useState( [] );
const [mutedUsers, setMutedUsers] = React.useState( [] );
const [refreshRelationships, setRefreshRelationships] = React.useState( Math.random() );
let orderBy;
let order;
if ( sortBy === "recently_added" ) {
orderBy = "date";
order = "desc";
} else if ( sortBy === "earliest_added" ) {
orderBy = "date";
order = "asc";
} else if ( sortBy === "a_to_z" ) {
orderBy = "user";
order = "asc";
} else if ( sortBy === "z_to_a" ) {
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 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] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve blocked users!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
};
if ( settings.blocked_user_ids.length > 0 ) {
getBlockedUsers();
} else {
setBlockedUsers( [] );
}
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] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve muted users!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
};
if ( settings.muted_user_ids.length > 0 ) {
getMutedUsers();
} else {
setMutedUsers( [] );
}
}, [settings] );
const updateRelationship = async ( relationship, update ) => {
let response;
try {
response = await inatjs.relationships.update(
{ id: relationship.id, relationship: update },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't update relationship!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( response );
setRefreshRelationships( Math.random() );
};
const askToRemoveRelationship = ( relationship ) => {
Alert.alert(
"Remove Relationship?",
`You will no longer be following or trusting ${relationship.friendUser.login}.`,
[
{ text: "Remove Relationship", onPress: async () => {
let response;
try {
response = await inatjs.relationships.delete(
{ id: relationship.id },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't delete relationship!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( response );
setRefreshRelationships( Math.random() );
} }
],
{
cancelable: true
}
);
};
const unblockUser = async ( user ) => {
let response;
try {
response = await inatjs.users.unblock(
{ id: user.id },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't unblock user!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( "Unblock", response );
onRefreshUser();
};
const blockUser = async ( user ) => {
if ( !user ) {return;}
let response;
try {
response = await inatjs.users.block(
{ id: user.id },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't block user!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( "Block", response );
onRefreshUser();
};
const BlockedUser = ( {user} ): React.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>Unblock</Text></Pressable>
</View>;
};
const unmuteUser = async ( user ) => {
let response;
try {
response = await inatjs.users.unmute(
{ id: user.id },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't unmute user!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( "Unmute", response );
onRefreshUser();
};
const muteUser = async ( user ) => {
if ( !user ) {return;}
let response;
try {
response = await inatjs.users.mute(
{ id: user.id },
{ api_token: accessToken}
);
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't mute user!",
[{ text: "OK" }],
{
cancelable: true
}
);
return;
}
console.log( "Mute", response );
onRefreshUser();
};
const MutedUser = ( {user} ): React.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>Unmute</Text></Pressable>
</View>;
};
const Relationship = ( {relationship} ): React.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>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>Trust with hidden coordinates</Text>
</View>
</View>
</View>
<Text>Added on {relationship.created_at}</Text>
<Pressable style={viewStyles.removeRelationship} onPress={() => askToRemoveRelationship( relationship )}><Text>Remove Relationship</Text></Pressable>
</View>;
};
return (
<View style={viewStyles.column}>
<Text style={textStyles.title}>Relationships</Text>
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
setUserSearch( v );
}}
value={userSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setUserSearch( "" );
}}>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
source={require( "../../images/clear.png" )}
/>
</Pressable>
</View>
<Text>Following</Text>
<View style={viewStyles.row}>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={following}
onValueChange={( itemValue, itemIndex ) =>
setFollowing( itemValue )
}>
{Object.keys( FOLLOWING ).map( ( k ) => (
<Picker.Item
key={k}
label={FOLLOWING[k]}
value={k} />
) )}
</Picker>
</View>
</View>
<Text>Trusted</Text>
<View style={viewStyles.row}>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={trusted}
onValueChange={( itemValue, itemIndex ) =>
setTrusted( itemValue )
}>
{Object.keys( TRUSTED ).map( ( k ) => (
<Picker.Item
key={k}
label={TRUSTED[k]}
value={k} />
) )}
</Picker>
</View>
</View>
<Text>Sort By</Text>
<View style={viewStyles.row}>
<View style={viewStyles.selectorContainer}>
<Picker
style={viewStyles.selector}
dropdownIconColor={colors.inatGreen}
selectedValue={sortBy}
onValueChange={( itemValue, itemIndex ) =>
setSortBy( itemValue )
}>
{Object.keys( SORT_BY ).map( ( k ) => (
<Picker.Item
key={k}
label={SORT_BY[k]}
value={k} />
) )}
</Picker>
</View>
</View>
{relationshipResults.map( ( relationship ) => (
<Relationship key={relationship.id} relationship={relationship} />
) )}
{ 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>}
<Text style={textStyles.title}>Blocked Users</Text>
<UserSearchInput userId={0} onUserChanged={( u ) => blockUser( u )} />
{blockedUsers.map( ( user ) => (
<BlockedUser key={user.id} user={user} />
) )}
<Text style={textStyles.title}>Muted Users</Text>
<UserSearchInput userId={0} onUserChanged={( u ) => muteUser( u )} />
{mutedUsers.map( ( user ) => (
<MutedUser key={user.id} user={user} />
) )}
</View>
);
};
export default SettingsRelationships;

View File

@@ -0,0 +1,65 @@
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 Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
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 );
useEffect( () => {
if ( finalUserSearch.length === 0 ) {
setHideResults( true );
}
}, [finalUserSearch] );
return (
<View style={viewStyles.column}>
<View style={viewStyles.row}>
<TextInput
style={viewStyles.textInput}
onChangeText={( v ) => {
setHideResults( false );
setUserSearch( v );
}}
value={userSearch}
/>
<Pressable style={viewStyles.clearSearch} onPress={() => {
setHideResults( true );
onUserChanged( null );
setUserSearch( "" );
}}>
<Image
style={viewStyles.clearSearch}
resizeMode="contain"
source={require( "../../images/clear.png" )}
/>
</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 );
}}>
<Image
style={viewStyles.userPic}
resizeMode="contain"
source={{ uri: result.icon }}
/>
<Text style={textStyles.resultPlaceName}>{result.login}</Text>
<Text style={textStyles.resultPlaceType}>{result.name}</Text>
</Pressable>
) )}
</View>
);
};
export default UserSearchInput;

View File

@@ -0,0 +1,50 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import {Alert} from "react-native";
const useAuthorizedApplications = ( accessToken: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( () => {
let isCurrent = true;
const fetchSearchResults = async () => {
try {
const response = await inatjs.authorized_applications.search(
{ fields: "application.official,application.name,created_at" },
{ api_token: accessToken }
);
const results = response.results;
if ( !isCurrent ) {
return;
}
setSearchResults( results );
} catch ( e ) {
if ( !isCurrent ) {
return;
}
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve authorized applications!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) {
return;
}
fetchSearchResults();
return () => {
isCurrent = false;
};
}, [accessToken] );
return searchResults;
};
export default useAuthorizedApplications;

View File

@@ -0,0 +1,41 @@
import inatjs from "inaturalistjs";
import {useEffect, useState} from "react";
import {Alert} from "react-native";
const usePlaceDetails = ( placeId: string ): Array<Object> => {
const [searchResult, setSearchResult] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.places.fetch( placeId, {fields: "display_name"} );
const result = response.results[0];
if ( !isCurrent ) { return; }
setSearchResult( result );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve place details!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !placeId ) { return; }
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [placeId] );
return searchResult;
};
export default usePlaceDetails;

View File

@@ -0,0 +1,47 @@
import inatjs from "inaturalistjs";
import {useEffect, useState} from "react";
import {Alert} from "react-native";
const usePlaces = ( q: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const params = {
per_page: 10,
q,
sources: "places",
fields: "place,place.display_name,place.place_type"
};
const response = await inatjs.search( params );
const results = response.results;
if ( !isCurrent ) { return; }
setSearchResults( results.map( r => r.place ) );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve places!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( q === "" ) { return; }
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [q] );
return searchResults;
};
export default usePlaces;

View File

@@ -0,0 +1,50 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import {Alert} from "react-native";
const useProviderAuthorizations = ( accessToken: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( () => {
let isCurrent = true;
const fetchSearchResults = async () => {
try {
const response = await inatjs.provider_authorizations.search(
{ fields: "provider_name,created_at" },
{ api_token: accessToken }
);
const results = response.results;
if ( !isCurrent ) {
return;
}
setSearchResults( results );
} catch ( e ) {
if ( !isCurrent ) {
return;
}
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve provider authorizations!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) {
return;
}
fetchSearchResults();
return () => {
isCurrent = false;
};
}, [accessToken] );
return searchResults;
};
export default useProviderAuthorizations;

View File

@@ -0,0 +1,63 @@
import inatjs from "inaturalistjs";
import {useEffect, useState} from "react";
import {Alert} from "react-native";
const useRelationships = ( accessToken, {
q,
following,
trusted,
order_by,
order,
per_page,
page,
random
} ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
const [perPage, setPerPage] = useState( 0 );
const [totalResults, setTotalResults] = useState( 0 );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.relationships.search( {
q,
following,
trusted,
order_by,
order,
per_page,
page,
fields: "all"
}, {api_token: accessToken} );
const results = response.results;
if ( !isCurrent ) { return; }
setSearchResults( results );
setPerPage( response.per_page );
setTotalResults( response.total_results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve relationships!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) { return; }
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [accessToken, q, following, trusted, order_by, order, per_page, page, random] );
return [searchResults, perPage, totalResults];
};
export default useRelationships;

View File

@@ -0,0 +1,42 @@
import inatjs from "inaturalistjs";
import {useEffect, useState} from "react";
import {Alert} from "react-native";
const useUserMe = ( accessToken: string ): Array<Object> => {
const [result, setResult] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.users.me( {api_token: accessToken, fields:
"all"
} );
if ( !isCurrent ) { return; }
setResult( response.results[0] );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve user details!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) { return; }
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [accessToken] );
return result;
};
export default useUserMe;

View File

@@ -0,0 +1,55 @@
const inatLanguages = {
"ar": "العربية",
"be": "Беларуская",
"bg": "български",
"br": "Breton",
"ca": "Català",
"cs": "česky",
"da": "Dansk",
"de": "Deutsch",
"el": "Ελληνικά",
"en": "English",
"eo": "Esperanto",
"es": "Español",
"es-AR": "Español (Argentina)",
"es-CO": "Spanish (Colombia)",
"es-MX": "Español (México)",
"et": "Eesti",
"eu": "Euskara",
"fi": "suomi",
"fr": "français",
"fr-CA": "French (Canada)",
"gl": "Galego",
"he": "עברית",
"hu": "magyar",
"id": "Indonesia",
"it": "Italiano",
"ja": "日本語",
"kn": "ಕನ್ನಡ",
"ko": "한국어",
"lb": "Lëtzebuergesch",
"lt": "Lietuvių",
"lv": "Latviešu",
"mk": "македонски",
"mr": "मराठी",
"nb": "Norsk Bokmål",
"nl": "Nederlands",
"oc": "Occitan",
"pl": "Polski",
"pt": "Portuguese",
"pt-BR": "Português (Brasil)",
"ru": "Русский",
"sk": "Slovenský",
"sl": "Slovenian",
"sq": "Shqip",
"sv": "Svenska",
"th": "Thai",
"tr": "Türkçe",
"uk": "Українська",
"zh-CN": "简体中文",
"zh-TW": "繁體中文"
};
export {
inatLanguages
};

View File

@@ -0,0 +1,38 @@
const inatLicenses = [
{
value: "CC0",
title: "No Copyright (CC0)"
},
{
value: "CC-BY",
title: "Attribution"
},
{
value: "CC-BY-NC",
title: "Attribution-NonCommercial"
},
{
value: "CC-BY-NC-SA",
title: "Attribution-NonCommercial-ShareAlike"
},
{
value: "CC-BY-NC-ND",
title: "Attribution-NonCommercial-NoDerivs"
},
{
value: "CC-BY-ND",
title: "Attribution-NonDerivs"
},
{
value: "CC-BY-SA",
title: "Attribution-ShareAlike"
},
{
value: "",
title: "No license (all rights reserved)"
}
];
export {
inatLicenses
};

View File

@@ -0,0 +1,27 @@
// Key = site_id
const inatNetworks = {
1: { name: "iNaturalist" },
2: { name: "NaturaLista" },
3: { name: "iNaturalist NZ - Mātaki Taiao" },
5: { name: "iNaturalist Canada" },
6: { name: "Naturalista Colombia" },
8: { name: "Biodiversity4all" },
13: { name: "iNaturalist Panamá" },
14: { name: "iNaturalist Ecuador" },
9: { name: "iNaturalist Australia" },
16: { name: "ArgentiNat" },
15: { name: "iNaturalist Israel" },
20: { name: "iNaturalist Finland" },
18: { name: "iNaturalist Chile" },
23: { name: "iNaturalist Greece" },
26: { name: "iNaturalist Luxembourg" },
25: { name: "iNaturalist United Kingdom" },
17: { name: "Naturalista Costa Rica" },
24: { name: "iNaturalist Guatemala" },
21: { name: "iNaturalist Sweden" },
28: { name: "Naturalista Uruguay" }
};
export {
inatNetworks
};

View File

@@ -0,0 +1,57 @@
const inatPlaceTypes = {
0: "Undefined",
2: "Street Segment",
5: "Intersection",
6: "Street",
7: "Town",
8: "State",
9: "County",
10: "Local Administrative Area",
12: "Country",
13: "Island",
14: "Airport",
15: "Drainage",
16: "Land Feature",
17: "Miscellaneous",
18: "Nationality",
19: "Supername",
20: "Point Of Interest",
21: "Region",
24: "Colloquial",
25: "Zone",
26: "Historical State",
27: "Historical County",
29: "Continent",
33: "Estate",
35: "Historical Town",
36: "Aggregate",
100: "Open Space",
101: "Territory",
102: "District",
103: "Province",
1000: "Municipality",
1001: "Parish",
1002: "Department Segment",
1003: "City Building",
1004: "Commune",
1005: "Governorate",
1006: "Prefecture",
1007: "Canton",
1008: "Republic",
1009: "Division",
1010: "Subdivision",
1011: "Village Block",
1012: "Sum",
1013: "Unknown",
1014: "Shire",
1015: "Prefecture City",
1016: "Regency",
1017: "Constituency",
1018: "Local Authority",
1019: "Poblacion",
1020: "Delegation"
};
export {
inatPlaceTypes
};

View File

@@ -0,0 +1,9 @@
const inatProviders = {
facebook: "Facebook",
google_oauth2: "Google",
apple: "Apple"
};
export {
inatProviders
};

View File

@@ -30,13 +30,14 @@ i18next
callback( null, loadTranslations( locale ) );
} ) )
.init( {
// TODO should default to the system locale, or a user preference
// Added since otherwise Android would crash - see here: https://stackoverflow.com/a/70521614 and https://www.i18next.com/misc/migration-guide
lng: "en",
// debug: true,
interpolation: {
escapeValue: false // react already safes from xss
},
react: {
// Added since otherwise Android would crash - see here: https://stackoverflow.com/a/70521614 and https://www.i18next.com/misc/migration-guide
useSuspense: false
}
} );

BIN
src/images/clear.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

BIN
src/images/profile.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -23,6 +23,7 @@ import Mortal from "../components/SharedComponents/Mortal";
import PhotoGalleryProvider from "../providers/PhotoGalleryProvider";
import { colors } from "../styles/global";
import { viewStyles } from "../styles/navigation/rootNavigation";
import Settings from "../components/Settings/Settings";
// this removes the default hamburger menu from header
const screenOptions = { headerLeft: ( ) => <></> };
@@ -80,7 +81,7 @@ const App = ( ): React.Node => (
component={ProjectsStackNavigation}
options={hideHeader}
/>
<Drawer.Screen name="settings" component={PlaceholderComponent} />
<Drawer.Screen name="settings" component={Settings} options={hideHeader} />
<Drawer.Screen name="following (dashboard)" component={PlaceholderComponent} />
<Drawer.Screen
name="about"

View File

@@ -9,7 +9,7 @@ import inatjs from "inaturalistjs";
// }
// };
const useRemoteSearchResults = ( q: string, sources: string ): Array<Object> => {
const useRemoteSearchResults = ( q: string, sources: string, fields: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( ( ) => {
@@ -19,11 +19,11 @@ const useRemoteSearchResults = ( q: string, sources: string ): Array<Object> =>
const params = {
per_page: 10,
q,
// TODO: get fields param working
sources
sources,
fields: fields || "all"
};
const response = await inatjs.search( params );
const results = response.results.map( result => result.record );
const results = response.results;
if ( !isCurrent ) { return; }
setSearchResults( results );
} catch ( e ) {
@@ -38,7 +38,7 @@ const useRemoteSearchResults = ( q: string, sources: string ): Array<Object> =>
return ( ) => {
isCurrent = false;
};
}, [q, sources] );
}, [q, sources, fields] );
return searchResults;
};

View File

@@ -0,0 +1,187 @@
// @flow strict-local
import { StyleSheet } from "react-native";
import type {TextStyleProp, ViewStyleProp} from "react-native/Libraries/StyleSheet/StyleSheet";
import { colors } from "../global";
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
container: {
backgroundColor: colors.white,
display: "flex",
flexDirection: "column",
flexWrap: "nowrap",
height: "auto",
paddingBottom: 200
},
tabsRow: {
backgroundColor: colors.white,
flexDirection: "row",
flexWrap: "wrap",
width: "100%",
height: 75,
justifyContent: "space-evenly"
},
headerRow: {
backgroundColor: colors.white,
flexDirection: "row",
width: "100%",
justifyContent: "flex-start",
padding: 10
},
row: {
display: "flex",
flexDirection: "row"
},
column: {
flexDirection: "column",
justifyContent: "space-evenly"
},
saveSettings: {
},
profileImage: {
height: 130,
width: 130
},
relationshipImage: {
height: 60,
width: 60
},
textInput: {
color: "#000000",
borderWidth: 1,
flexGrow: 1
},
notificationContainer: {
marginBottom: 10
},
notificationLeftSide: {
flex: 1
},
switch: {
width: 50
},
notificationCheckbox: {
alignItems: "center"
},
selectorContainer: {
width: "100%",
borderColor: "#000000",
borderWidth: 1,
borderStyle: "solid"
},
selector: {
width: "100%"
},
placeResultContainer: {
padding: 5,
borderColor: "#CCCCCC",
borderWidth: 1,
borderStyle: "solid"
},
clearSearch: {
width: 30,
height: 30,
alignSelf: "center"
},
revokeAccess: {
width: 100,
borderColor: "#CCCCCC",
borderWidth: 1,
borderStyle: "solid",
marginRight: 10,
flexDirection: "column",
marginLeft: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center"
},
relationshipRow: {
marginTop: 10,
borderBottomWidth: 1,
borderBottomColor: "#CCCCCC",
paddingBottom: 10
},
removeRelationship: {
width: 100,
borderColor: "#CCCCCC",
borderWidth: 1,
borderStyle: "solid",
flexDirection: "column",
display: "flex",
justifyContent: "center",
alignItems: "center"
},
paginationContainer: {
marginTop: 20,
marginBottom: 20,
display: "flex",
justifyContent: "center",
alignItems: "center"
},
pageButton: {
width: 30,
borderColor: "#CCCCCC",
borderWidth: 1,
borderStyle: "solid",
flexDirection: "column",
display: "flex",
justifyContent: "center",
alignItems: "center",
marginRight: 10
},
userPic: {
width: 30,
height: 30,
alignSelf: "center"
},
applicationRow: {
marginTop: 20
}
} );
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
header: {
fontSize: 20,
flexGrow: 1
},
activeTab: {
fontWeight: "bold"
},
title: {
fontSize: 20,
marginBottom: 10
},
notificationTitle: {
fontWeight: "bold"
},
subTitle: {
fontWeight: "bold",
marginTop: 10
},
marginTop: {
marginTop: 10
},
resultPlaceName: {
fontWeight: "bold"
},
resultPlaceType: {
marginLeft: 10,
color: "#CCCCCC"
},
checkbox: {
flexWrap: "wrap",
maxWidth: "90%"
},
currentPage: {
fontWeight: "bold"
},
applicationName: {
flex: 1
}
} );
export {
viewStyles,
textStyles
};

View File

@@ -0,0 +1,44 @@
// @flow strict-local
import { StyleSheet } from "react-native";
import type {TextStyleProp, ViewStyleProp} from "react-native/Libraries/StyleSheet/StyleSheet";
import { colors } from "../global";
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
container: {
backgroundColor: colors.white,
display: "flex",
flexDirection: "column",
flexWrap: "nowrap",
height: "100%"
},
tabsRow: {
backgroundColor: colors.white,
flexDirection: "row",
flexWrap: "wrap",
width: "100%",
height: 75,
justifyContent: "space-evenly"
},
shadow: {
shadowColor: colors.black,
shadowOffset: {
width: 0,
height: -3
},
shadowOpacity: 0.20,
shadowRadius: 2
}
} );
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
text: {
fontSize: 20
}
} );
export {
viewStyles,
textStyles
};

View File

@@ -1,5 +1,13 @@
import nock from "nock";
import { isLoggedIn, getAPIToken, getUsername, registerUser, authenticateUser, signOut } from "../../../../src/components/LoginSignUp/AuthenticationService";
import {
isLoggedIn,
getAPIToken,
getUsername,
registerUser,
authenticateUser,
signOut,
API_HOST
} from "../../../../src/components/LoginSignUp/AuthenticationService";
const USERNAME = "some_user";
const PASSWORD = "123456";
@@ -9,13 +17,13 @@ const JWT = "jwt_token";
const USERID = 113113;
test( "authenticates user", async ( ) => {
const scope = nock( "https://www.inaturalist.org" )
const scope = nock( API_HOST )
.post( "/oauth/token" )
.reply( 200, { access_token: ACCESS_TOKEN } )
.get( "/users/edit.json" )
.reply( 200, { login: USERNAME, id: USERID } );
const scope2 = nock( "https://www.inaturalist.org" , {
const scope2 = nock( API_HOST , {
reqheaders: {
authorization: ACCESS_TOKEN_AUTHORIZATION_HEADER
}} )
@@ -45,7 +53,7 @@ test( "authenticates user", async ( ) => {
test( "registers user", async ( ) => {
const scope = nock( "https://www.inaturalist.org" )
const scope = nock( API_HOST )
.post( "/oauth/token" )
.reply( 200, { access_token: ACCESS_TOKEN } )
.get( "/users/edit.json" )