mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-24 08:22:26 -04:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
23825
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
src/components/Settings/PlaceSearchInput.js
Normal file
66
src/components/Settings/PlaceSearchInput.js
Normal 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;
|
||||
295
src/components/Settings/Settings.js
Normal file
295
src/components/Settings/Settings.js
Normal 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;
|
||||
80
src/components/Settings/SettingsAccount.js
Normal file
80
src/components/Settings/SettingsAccount.js
Normal 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;
|
||||
68
src/components/Settings/SettingsApplications.js
Normal file
68
src/components/Settings/SettingsApplications.js
Normal 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;
|
||||
200
src/components/Settings/SettingsContentDisplay.js
Normal file
200
src/components/Settings/SettingsContentDisplay.js
Normal 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;
|
||||
84
src/components/Settings/SettingsNotifications.js
Normal file
84
src/components/Settings/SettingsNotifications.js
Normal 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 };
|
||||
73
src/components/Settings/SettingsProfile.js
Normal file
73
src/components/Settings/SettingsProfile.js
Normal 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;
|
||||
442
src/components/Settings/SettingsRelationships.js
Normal file
442
src/components/Settings/SettingsRelationships.js
Normal 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><</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>></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;
|
||||
65
src/components/Settings/UserSearchInput.js
Normal file
65
src/components/Settings/UserSearchInput.js
Normal 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;
|
||||
50
src/components/Settings/hooks/useAuthorizedApplications.js
Normal file
50
src/components/Settings/hooks/useAuthorizedApplications.js
Normal 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;
|
||||
41
src/components/Settings/hooks/usePlaceDetails.js
Normal file
41
src/components/Settings/hooks/usePlaceDetails.js
Normal 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;
|
||||
47
src/components/Settings/hooks/usePlaces.js
Normal file
47
src/components/Settings/hooks/usePlaces.js
Normal 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;
|
||||
50
src/components/Settings/hooks/useProviderAuthorizations.js
Normal file
50
src/components/Settings/hooks/useProviderAuthorizations.js
Normal 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;
|
||||
63
src/components/Settings/hooks/useRelationships.js
Normal file
63
src/components/Settings/hooks/useRelationships.js
Normal 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;
|
||||
42
src/components/Settings/hooks/useUserMe.js
Normal file
42
src/components/Settings/hooks/useUserMe.js
Normal 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;
|
||||
55
src/dictionaries/languages.js
Normal file
55
src/dictionaries/languages.js
Normal 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
|
||||
};
|
||||
38
src/dictionaries/licenses.js
Normal file
38
src/dictionaries/licenses.js
Normal 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
|
||||
};
|
||||
27
src/dictionaries/networks.js
Normal file
27
src/dictionaries/networks.js
Normal 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
|
||||
};
|
||||
57
src/dictionaries/places.js
Normal file
57
src/dictionaries/places.js
Normal 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
|
||||
};
|
||||
9
src/dictionaries/providers.js
Normal file
9
src/dictionaries/providers.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const inatProviders = {
|
||||
facebook: "Facebook",
|
||||
google_oauth2: "Google",
|
||||
apple: "Apple"
|
||||
};
|
||||
|
||||
export {
|
||||
inatProviders
|
||||
};
|
||||
@@ -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
BIN
src/images/clear.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 461 B |
BIN
src/images/profile.png
Normal file
BIN
src/images/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
187
src/styles/settings/settings.js
Normal file
187
src/styles/settings/settings.js
Normal 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
|
||||
};
|
||||
44
src/styles/settings/settingsTabs.js
Normal file
44
src/styles/settings/settingsTabs.js
Normal 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
|
||||
};
|
||||
@@ -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" )
|
||||
|
||||
Reference in New Issue
Block a user