From 82c438cd91b1ac43aa1090c4d46989648350017b Mon Sep 17 00:00:00 2001 From: Matt Reetz Date: Mon, 20 Sep 2021 13:52:04 -0500 Subject: [PATCH] Feature/934 validators filters (#946) * Move hotspot picker logic from slice to component. * Add validators and followed validators filter. * Load validator rewards. Update validator border left color. * Remove validator type checking in HotspotListItem. * Fix hotspot lint error. * Rename ElectedValidatorItem to ValidatorListItem. * Convert validator list item hnt to currency. --- package.json | 2 + src/components/HotspotListItem.tsx | 30 +- .../hotspots/details/HotspotDetails.tsx | 2 +- src/features/hotspots/root/HotspotsList.tsx | 274 +++++++++++++++--- src/features/hotspots/root/HotspotsPicker.tsx | 161 +++++----- src/features/hotspots/root/HotspotsView.tsx | 10 +- .../hotspots/root/WelcomeOverview.tsx | 4 +- ...alidatorItem.tsx => ValidatorListItem.tsx} | 63 +++- .../validators/explorer/ValidatorExplorer.tsx | 4 +- src/locales/en.ts | 2 + src/store/hotspots/hotspotsSlice.ts | 84 +----- src/store/validators/validatorsSlice.ts | 3 + yarn.lock | 7 +- 13 files changed, 391 insertions(+), 255 deletions(-) rename src/features/validators/{explorer/ElectedValidatorItem.tsx => ValidatorListItem.tsx} (58%) diff --git a/package.json b/package.json index 29244c89d..8fbc12e38 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "react-qr-code": "^1.0.3", "react-redux": "^7.2.2", "redux-thunk": "^2.3.0", + "tinycolor2": "^1.4.2", "use-debounce": "^5.2.0", "use-state-with-callback": "^2.0.3" }, @@ -137,6 +138,7 @@ "@types/react-native": "^0.63.2", "@types/react-redux": "^7.1.11", "@types/react-test-renderer": "^16.9.2", + "@types/tinycolor2": "^1.4.3", "@types/webpack-env": "^1.15.3", "@typescript-eslint/eslint-plugin": "^4.1.0", "@typescript-eslint/parser": "^4.1.0", diff --git a/src/components/HotspotListItem.tsx b/src/components/HotspotListItem.tsx index 93667b248..00f08c717 100644 --- a/src/components/HotspotListItem.tsx +++ b/src/components/HotspotListItem.tsx @@ -18,7 +18,7 @@ import VisibilityOff from '../assets/images/visibility_off.svg' type HotspotListItemProps = { onPress?: (hotspot: Hotspot) => void - hotspot: Hotspot + gateway: Hotspot totalReward?: Balance showCarot?: boolean loading?: boolean @@ -33,7 +33,7 @@ type HotspotListItemProps = { const HotspotListItem = ({ onPress, - hotspot, + gateway, totalReward, loading = false, showCarot = false, @@ -48,7 +48,7 @@ const HotspotListItem = ({ const { t } = useTranslation() const colors = useColors() const { toggleConvertHntToCurrency, hntBalanceToDisplayVal } = useCurrency() - const handlePress = useCallback(() => onPress?.(hotspot), [hotspot, onPress]) + const handlePress = useCallback(() => onPress?.(gateway), [gateway, onPress]) const [reward, setReward] = useState('') const updateReward = useCallback(async () => { @@ -63,21 +63,21 @@ const HotspotListItem = ({ }, [updateReward]) const locationText = useMemo(() => { - const { geocode: geo } = hotspot + const { geocode: geo } = gateway as Hotspot if (!geo || (!geo.longStreet && !geo.longCity && !geo.shortCountry)) { return t('hotspot_details.no_location_title') } return `${geo.longStreet}, ${geo.longCity}, ${geo.shortCountry}` - }, [hotspot, t]) + }, [gateway, t]) - const isRelayed = useMemo(() => isRelay(hotspot?.status?.listenAddrs), [ - hotspot?.status, + const isRelayed = useMemo(() => isRelay(gateway?.status?.listenAddrs), [ + gateway?.status, ]) const statusBackgroundColor = useMemo(() => { if (hidden) return 'grayLightText' - return hotspot.status?.online === 'online' ? 'greenOnline' : 'yellow' - }, [hidden, hotspot.status?.online]) + return gateway.status?.online === 'online' ? 'greenOnline' : 'yellow' + }, [hidden, gateway.status?.online]) return ( @@ -108,7 +108,7 @@ const HotspotListItem = ({ numberOfLines={2} maxWidth={220} > - {animalName(hotspot.address)} + {animalName(gateway.address)} {hidden && } @@ -164,8 +164,8 @@ const HotspotListItem = ({ )} {showRewardScale && ( {t('generic.meters', { - distance: hotspot?.elevation || 0, + distance: (gateway as Hotspot)?.elevation || 0, })} - {hotspot?.gain !== undefined && ( + {(gateway as Hotspot)?.gain !== undefined && ( - {(hotspot.gain / 10).toFixed(1) + + {(((gateway as Hotspot).gain || 0) / 10).toFixed(1) + t('antennas.onboarding.dbi')} )} diff --git a/src/features/hotspots/details/HotspotDetails.tsx b/src/features/hotspots/details/HotspotDetails.tsx index 6e6ac5285..3cf520b4d 100644 --- a/src/features/hotspots/details/HotspotDetails.tsx +++ b/src/features/hotspots/details/HotspotDetails.tsx @@ -275,7 +275,7 @@ const HotspotDetails = ({ pressable={false} key={witness.address} onPress={onSelectHotspot} - hotspot={witness as Hotspot} + gateway={witness as Hotspot} showAddress={false} distanceAway={getDistance(witness)} showRewardScale diff --git a/src/features/hotspots/root/HotspotsList.tsx b/src/features/hotspots/root/HotspotsList.tsx index eb778b151..42b0d6c60 100644 --- a/src/features/hotspots/root/HotspotsList.tsx +++ b/src/features/hotspots/root/HotspotsList.tsx @@ -1,23 +1,34 @@ -import React, { memo, useCallback, useMemo } from 'react' +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import { SectionList } from 'react-native' -import { Hotspot, Sum } from '@helium/http' +import { Hotspot, Sum, Validator } from '@helium/http' import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import Search from '@assets/images/search.svg' import Add from '@assets/images/add.svg' import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { orderBy, sortBy, uniq } from 'lodash' +import { useAsync } from 'react-async-hook' import { useColors } from '../../../theme/themeHooks' import Box from '../../../components/Box' import Text from '../../../components/Text' import HotspotListItem from '../../../components/HotspotListItem' import { RootState } from '../../../store/rootReducer' import WelcomeOverview from './WelcomeOverview' -import HotspotsPicker from './HotspotsPicker' -import { HotspotSort } from '../../../store/hotspots/hotspotsSlice' +import HotspotsPicker, { GatewaySort } from './HotspotsPicker' import TouchableOpacityBox from '../../../components/TouchableOpacityBox' import { wh } from '../../../utils/layout' import FocusAwareStatusBar from '../../../components/FocusAwareStatusBar' import { CacheRecord } from '../../../utils/cacheUtils' +import { distance, hotspotHasValidLocation } from '../../../utils/location' +import useGetLocation from '../../../utils/useGetLocation' +import usePrevious from '../../../utils/usePrevious' +import useMount from '../../../utils/useMount' +import useVisible from '../../../utils/useVisible' +import { isHotspot } from '../../../utils/hotspotUtils' +import ValidatorListItem from '../../validators/ValidatorListItem' +import { fetchValidatorRewards } from '../../../store/validators/validatorsSlice' +import { useAppDispatch } from '../../../store/store' +import { isValidator } from '../../../utils/validatorUtils' const HotspotsList = ({ onSelectHotspot, @@ -26,19 +37,38 @@ const HotspotsList = ({ addHotspotPressed, accountRewards, }: { - onSelectHotspot: (hotspot: Hotspot, showNav: boolean) => void + onSelectHotspot: (hotspot: Hotspot | Validator, showNav: boolean) => void visible: boolean searchPressed?: () => void addHotspotPressed?: () => void accountRewards: CacheRecord }) => { + const { t } = useTranslation() const colors = useColors() const { top } = useSafeAreaInsets() - const loadingRewards = useSelector( + const [gatewaySortOrder, setGatewaySortOrder] = useState( + GatewaySort.FollowedHotspots, + ) + const dispatch = useAppDispatch() + const loadingHotspotRewards = useSelector( (state: RootState) => state.hotspots.loadingRewards, ) - const orderedHotspots = useSelector( - (state: RootState) => state.hotspots.orderedHotspots, + const { + loadingRewards: loadingValidatorRewards, + myValidatorsLoaded, + followedValidatorsLoaded, + rewards: validatorRewards, + } = useSelector((state: RootState) => state.validators) + + const hotspots = useSelector((state: RootState) => state.hotspots.hotspots) + const followedHotspots = useSelector( + (state: RootState) => state.hotspots.followedHotspots, + ) + const validators = useSelector( + (state: RootState) => state.validators.validators.data, + ) + const followedValidators = useSelector( + (state: RootState) => state.validators.followedValidators.data, ) const hiddenAddresses = useSelector( (state: RootState) => state.account.settings.hiddenAddresses, @@ -46,45 +76,176 @@ const HotspotsList = ({ const showHiddenHotspots = useSelector( (state: RootState) => state.account.settings.showHiddenHotspots, ) - const rewards = useSelector( + const hotspotRewards = useSelector( (state: RootState) => state.hotspots.rewards || {}, ) - const order = useSelector((state: RootState) => state.hotspots.order) + const maybeGetLocation = useGetLocation() + const fleetModeEnabled = useSelector( + (state: RootState) => state.account.settings.isFleetModeEnabled, + ) - const { t } = useTranslation() + const { currentLocation, locationBlocked } = useSelector( + (state: RootState) => state.location, + ) + const prevOrder = usePrevious(gatewaySortOrder) + + const locationDeniedHandler = useCallback(() => { + setGatewaySortOrder(GatewaySort.New) + }, []) + + useMount(() => { + if (!fleetModeEnabled) { + // On mount if fleet mode is off, default to New filter + setGatewaySortOrder(GatewaySort.New) + } + }) + + useVisible({ + onAppear: () => { + // if fleet mode is on and they're on the new filter, bring them to followed when this view appears + if (fleetModeEnabled && gatewaySortOrder === GatewaySort.New) { + setGatewaySortOrder(GatewaySort.FollowedHotspots) + } + }, + }) + + useVisible({ + onAppear: () => { + maybeGetLocation(false, locationDeniedHandler) + }, + }) + + useAsync(async () => { + if ( + !myValidatorsLoaded || + !followedValidatorsLoaded || + loadingValidatorRewards + ) { + return + } + + const allValidatorAddresses = uniq( + [...followedValidators, ...validators].map(({ address }) => address), + ) + const rewardsToFetch = allValidatorAddresses.flatMap((address) => { + const reward = validatorRewards[address] + if (!reward) return [address] + return [] + }) + if (rewardsToFetch.length === 0) return + await dispatch(fetchValidatorRewards(rewardsToFetch)) + }, [ + myValidatorsLoaded, + followedValidatorsLoaded, + validators, + followedValidators, + loadingValidatorRewards, + ]) + + useEffect(() => { + if ( + currentLocation || + gatewaySortOrder !== GatewaySort.Near || + prevOrder === GatewaySort.Near + ) + return + + // They've switched to Nearest filter and we don't have a location + maybeGetLocation(true, locationDeniedHandler) + }, [ + currentLocation, + gatewaySortOrder, + locationDeniedHandler, + maybeGetLocation, + prevOrder, + ]) + + const orderedGateways = useMemo((): (Hotspot | Validator)[] => { + switch (gatewaySortOrder) { + case GatewaySort.New: + return orderBy(hotspots, 'blockAdded', 'desc') + case GatewaySort.Near: { + if (!currentLocation) { + return hotspots + } + return sortBy(hotspots, [ + (h) => + distance(currentLocation || { latitude: 0, longitude: 0 }, { + latitude: h.lat || 0, + longitude: h.lng || 0, + }), + ]) + } + case GatewaySort.Earn: { + if (!hotspotRewards) { + return hotspots + } + return sortBy(hotspots, [ + (h) => + hotspotRewards ? -hotspotRewards[h.address]?.integerBalance : 0, + ]) + } + case GatewaySort.Offline: + return orderBy(hotspots, ['status.online', 'offline']) + case GatewaySort.FollowedHotspots: + return followedHotspots + case GatewaySort.Unasserted: + return hotspots.filter((h) => !hotspotHasValidLocation(h)) + case GatewaySort.FollowedValidators: + return followedValidators + case GatewaySort.Validators: + return validators + } + }, [ + currentLocation, + followedHotspots, + followedValidators, + gatewaySortOrder, + hotspots, + hotspotRewards, + validators, + ]) const visibleHotspots = useMemo(() => { - if (showHiddenHotspots) { - return orderedHotspots + if (showHiddenHotspots || GatewaySort.FollowedValidators) { + return orderedGateways } return ( - orderedHotspots.filter((h) => !hiddenAddresses?.includes(h.address)) || [] + (orderedGateways as Hotspot[]).filter( + (h) => !hiddenAddresses?.includes(h.address), + ) || [] ) - }, [hiddenAddresses, orderedHotspots, showHiddenHotspots]) + }, [hiddenAddresses, orderedGateways, showHiddenHotspots]) const handlePress = useCallback( - (hotspot: Hotspot) => { + (hotspot: Hotspot | Validator) => { onSelectHotspot(hotspot, visibleHotspots.length > 1) }, [onSelectHotspot, visibleHotspots.length], ) - const hasOfflineHotspot = useMemo( - () => visibleHotspots.some((h: Hotspot) => h.status?.online !== 'online'), - [visibleHotspots], - ) + const hasOfflineHotspot = useMemo(() => { + if (GatewaySort.FollowedValidators) { + return false + } + return (visibleHotspots as Hotspot[]).some( + (h: Hotspot) => h.status?.online !== 'online', + ) + }, [visibleHotspots]) const sections = useMemo(() => { let data = visibleHotspots - if (order === HotspotSort.Offline && hasOfflineHotspot) { - data = visibleHotspots.filter((h) => h.status?.online !== 'online') + if (gatewaySortOrder === GatewaySort.Offline && hasOfflineHotspot) { + data = (visibleHotspots as Hotspot[]).filter( + (h) => h.status?.online !== 'online', + ) } return [ { data, }, ] - }, [hasOfflineHotspot, order, visibleHotspots]) + }, [gatewaySortOrder, hasOfflineHotspot, visibleHotspots]) const renderHeader = useCallback(() => { const filterHasHotspots = visibleHotspots && visibleHotspots.length > 0 @@ -95,8 +256,15 @@ const HotspotsList = ({ borderTopLeftRadius="m" backgroundColor="white" > - - {order === HotspotSort.Offline && + + {gatewaySortOrder === GatewaySort.Offline && !hasOfflineHotspot && filterHasHotspots && ( @@ -123,22 +291,49 @@ const HotspotsList = ({ )} ) - }, [visibleHotspots, visible, order, hasOfflineHotspot, t]) + }, [ + visibleHotspots, + followedValidators.length, + locationBlocked, + fleetModeEnabled, + gatewaySortOrder, + validators.length, + hasOfflineHotspot, + t, + ]) const renderItem = useCallback( ({ item }) => { - return ( -