Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/runtime": "^7.26.7",
"@ecency/render-helper": "^2.5.23",
"@ecency/sdk": "^2.3.79",
"@ecency/sdk": "^2.3.80",
"@esteemapp/react-native-autocomplete-input": "^4.2.1",
"@esteemapp/react-native-multi-slider": "^1.1.0",
"@native-html/iframe-plugin": "^2.6.1",
Expand Down
2 changes: 2 additions & 0 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,8 @@
"checkin_desc": "Checking in regularly gives you 0.25 points.",
"fill_transfer_from_savings": "Savings Executed",
"activities": "Activities",
"no_activities": "No transactions yet",
"activities_failed": "Could not load transactions. Pull down to retry.",
"tap_update": "Tap to update",
"mining_lottery": "Lottery Won",
"checkin": "Check-in",
Expand Down
117 changes: 100 additions & 17 deletions src/providers/queries/walletQueries/walletQueries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect, useRef } from 'react';
import { useIntl } from 'react-intl';
import { unionBy, get } from 'lodash';
import { RecurrentTransfer } from 'providers/hive/hive.types';
Expand All @@ -11,13 +11,16 @@ import {
getCollateralizedConversionRequestsQueryOptions,
getRecurrentTransfersQueryOptions,
getOpenOrdersQueryOptions,
getTransactionsInfiniteQueryOptions,
getHiveAssetTransactionsQueryOptions,
getHbdAssetTransactionsQueryOptions,
getHivePowerAssetTransactionsQueryOptions,
getPointsQueryOptions,
getPortfolioQueryOptions,
getHiveEngineTokenTransactions,
useBroadcastMutation,
buildRecurrentTransferOp,
} from '@ecency/sdk';
import { getHistoryOpsForSymbol, matchesAssetTicker } from '../../../utils/walletHistory';
import { ASSET_IDS } from '../../../constants/defaultAssets';
import { resolvePointType } from '../../../constants/options/points';
import { useAppDispatch, useAppSelector } from '../../../hooks';
Expand Down Expand Up @@ -58,6 +61,11 @@ interface RecurrentTransferPayload {

const ACTIVITIES_FETCH_LIMIT = 50;

// A page whose rows are all filtered out client-side leaves the list empty, and an
// empty list is never scrolled, so `onEndReached` never fires to pull the next page.
// Auto-advance at most this many pages while nothing renders.
const MAX_AUTO_ADVANCE_PAGES = 5;

/** hook used to return user drafts */
export const useAssetsQuery = ({ onlyEnabled = true }: { onlyEnabled?: boolean } = {}) => {
const currentAccount = useAppSelector(selectCurrentAccount);
Expand Down Expand Up @@ -360,9 +368,41 @@ export const useActivitiesQuery = (symbol: string, layer: PortfolioLayer) => {
});

// Only fetch Hive transactions for native Hive tokens (HIVE, HBD, HP)
// External chain tokens (BNB, ETH, etc.) have no transaction history API
// External chain tokens (BNB, ETH, etc.) have no transaction history API.
//
// History comes from `condenser_api.get_account_history` with a server-side
// operation bitmask. The previous `getTransactionsInfiniteQueryOptions` path called
// hafah's REST `/accounts/{name}/operations` with a 30-op-type filter, which on an
// account with a large history takes 2-28s server-side (and answers HTTP 500
// "canceling statement due to statement timeout" on some nodes) against the 10s
// client ceiling set in `sdk-config.ts`, so the list simply never loaded. The same
// filter over RPC answers in well under a second.
//
// Pagination is the SDK's: 2.3.80 fixed the cursor to walk back from the oldest row
// on the page (a page arrives in ascending `num`), so no local override is needed.
const chainQueryOptions = useMemo(() => {
const historyOps = getHistoryOpsForSymbol(symbol);
const name = username ?? '';

switch (symbol) {
case 'HBD':
return getHbdAssetTransactionsQueryOptions(name, ACTIVITIES_FETCH_LIMIT, historyOps);
case 'HP':
return getHivePowerAssetTransactionsQueryOptions(name, ACTIVITIES_FETCH_LIMIT, historyOps);
default:
return getHiveAssetTransactionsQueryOptions(name, ACTIVITIES_FETCH_LIMIT, historyOps);
}
}, [symbol, username]);

const chainQuery = useInfiniteQuery({
...getTransactionsInfiniteQueryOptions(username ?? '', ACTIVITIES_FETCH_LIMIT),
...chainQueryOptions,
// Guard, not a workaround. These options used to seed
// `initialData: { pages: [], pageParams: [] }` for the web's server-prefetched
// pages; against this client's staleTime of 60s that empty seed reads as fresh
// data and suppresses the very first fetch, so the history renders empty and
// nothing reports an error. @ecency/sdk 2.3.80 dropped it, and no SDK test pins
// its absence, so keep this until one does.
initialData: undefined,
enabled: !!username && isHive,
});

Expand All @@ -384,8 +424,12 @@ export const useActivitiesQuery = (symbol: string, layer: PortfolioLayer) => {
getNextPageParam: (lastPage, pages) => (lastPage?.length ? pages.length : undefined),
});

// Bounded auto-advance counter, declared here so `_refresh` can clear it.
const autoAdvancedRef = useRef(0);

const _refresh = async () => {
setIsRefreshing(true);
autoAdvancedRef.current = 0;
if (isPoints) {
await pointsQuery.refetch();
} else if (isEngine) {
Expand Down Expand Up @@ -434,10 +478,10 @@ export const useActivitiesQuery = (symbol: string, layer: PortfolioLayer) => {
);
}

// SDK pages have shape { entries: Transaction[], currentPage }; flatten the
// entries arrays so each `tx` is the normalized operation object that
// groomingTransactionData understands (it also tolerates the legacy array form).
// Defensively handle both shapes in case a cached/legacy page is an array.
// Each SDK page is a Transaction[] whose items are flat operation objects
// ({ num, type, timestamp, ...opValue }) that groomingTransactionData understands.
// Older cached pages may still hold the { entries } wrapper or the legacy
// [trxIndex, { op }] tuple form, so tolerate all three.
const _chainPages = (chainQuery.data as any)?.pages as
| Array<unknown[] | { entries?: unknown[] }>
| undefined;
Expand All @@ -454,7 +498,7 @@ export const useActivitiesQuery = (symbol: string, layer: PortfolioLayer) => {
groomingTransactionData(item, globalProps.hivePerMVests),
);

return activities.filter((item) => item && item.value && item.value.includes(symbol));
return activities.filter((item) => matchesAssetTicker(item, symbol));
}, [
pointsQuery.data?.transactions,
(chainQuery.data as any)?.pages,
Expand All @@ -465,16 +509,38 @@ export const useActivitiesQuery = (symbol: string, layer: PortfolioLayer) => {
symbol,
]);

// A page can be filtered down to nothing (an HBD-only page on the HIVE tab, a run of
// curation rewards on HBD), leaving an empty list that is never scrolled and so never
// fires `onEndReached` to pull the next page. Walk forward a bounded number of pages
// while nothing renders instead of showing the user an empty history.
useEffect(() => {
autoAdvancedRef.current = 0;
}, [symbol, username]);

useEffect(() => {
if (!isHive || _data.length > 0) {
return;
}
if (!chainQuery.hasNextPage || chainQuery.isFetching) {
return;
}
if (autoAdvancedRef.current >= MAX_AUTO_ADVANCE_PAGES) {
return;
}

autoAdvancedRef.current += 1;
chainQuery.fetchNextPage();
}, [isHive, _data.length, chainQuery.hasNextPage, chainQuery.isFetching]);

const activeQuery = isPoints ? pointsQuery : isEngine ? engineQuery : chainQuery;

return {
data: _data,
isRefreshing,
isLoading: isPoints
? pointsQuery.isLoading || pointsQuery.isFetching
: isEngine
? engineQuery.isLoading || engineQuery.isFetching
: isHive
? chainQuery.isLoading || chainQuery.isFetching
: false,
isLoading:
isPoints || isEngine || isHive ? activeQuery.isLoading || activeQuery.isFetching : false,
isError: isPoints || isEngine || isHive ? activeQuery.isError : false,
error: isPoints || isEngine || isHive ? activeQuery.error : null,
fetchNextPage: _fetchNextPage,
refresh: _refresh,
};
Expand All @@ -485,10 +551,27 @@ export const useRecurringActivitesQuery = (coinId: string) => {
const currentAccount = useAppSelector(selectCurrentAccount);
const username = currentAccount?.name;

// Every caller now passes the portfolio symbol ('HIVE'), not the legacy asset id
// ('hive'), so gating on ASSET_IDS.HIVE alone left this query permanently disabled
// and the coin summary stuck on "0" recurrent transfers. Accept both spellings.
const isHiveAsset = coinId === ASSET_IDS.HIVE || coinId === 'HIVE';

// Always call useQuery (Rules of Hooks) - use enabled to control execution
const query = useQuery({
...getRecurrentTransfersQueryOptions(username || ''),
enabled: coinId === ASSET_IDS.HIVE && !!username, // Only fetch for HIVE and when username exists
enabled: isHiveAsset && !!username, // Only fetch for HIVE and when username exists

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter recurrent totals to the displayed asset

When an account has recurrent transfers in both HIVE and HBD, enabling this query for the HIVE symbol fetches every recurrent transfer because the SDK query is scoped only by username. The reducer below then sums parseFloat(item.amount) without checking its symbol, while CoinSummary labels the result as HIVE; for example, 1 HIVE plus 10 HBD is displayed as 11 HIVE, and the HIVE modal also exposes both assets. Filter the returned transfers by coinId before summing and exposing them.

Useful? React with 👍 / 👎.

// The SDK query is scoped to the account, not to an asset, so it returns every
// schedule the account has. The total below sums bare `parseFloat` values and the
// summary labels them HIVE, so an account with 1 HIVE and 10 HBD scheduled read as
// "11 HIVE" and the modal listed the HBD schedules under HIVE. Latent until the
// gate above started matching.
select: (data) =>
data.filter(
(item) =>
String(item.amount || '')
.trim()
.split(/\s+/)[1] === 'HIVE',
),
});

const totalAmount = useMemo(() => {
Expand Down
46 changes: 37 additions & 9 deletions src/screens/assetDetails/children/activitiesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface ActivitiesListProps {
completedActivities: CoinActivity[];
refreshing: boolean;
loading: boolean;
failed: boolean;
activitiesEnabled: boolean;
onEndReached: () => void;
onRefresh: () => void;
Expand All @@ -26,6 +27,7 @@ export const ActivitiesList = ({
header,
loading,
refreshing,
failed,
completedActivities,
pendingActivities,
activitiesEnabled,
Expand Down Expand Up @@ -74,15 +76,19 @@ export const ActivitiesList = ({

const sections = [];

// Explicit keys: without them the pending section appearing shifts the completed
// section's identity and re-mounts every visible row.
if (pendingActivities && pendingActivities.length) {
sections.push({
key: 'pending',
title: intl.formatMessage({ id: 'wallet.pending_requests' }),
data: pendingActivities,
});
}

if (activitiesEnabled) {
sections.push({
key: 'completed',
title: intl.formatMessage({ id: 'wallet.activities' }),
data: completedActivities || [],
});
Expand All @@ -99,26 +105,48 @@ export const ActivitiesList = ({
/>
);

// A failed fetch used to be indistinguishable from an empty history: both rendered a
// bare header with nothing under it. Only claim "no transactions" once a request has
// actually settled without error.
const _renderFooter = () => {
if (loading) {
return (
<ActivityIndicator
color={EStyleSheet.value('$primaryBlue')}
style={styles.activitiesFooterIndicator}
/>
);
}

if (!activitiesEnabled || completedActivities?.length) {
return null;
}

return (
<Text style={styles.activitiesPlaceholder}>
{intl.formatMessage({ id: failed ? 'wallet.activities_failed' : 'wallet.no_activities' })}
</Text>
);
};

return (
<SectionList
style={styles.list}
contentContainerStyle={styles.listContent}
sections={sections}
renderItem={_renderActivityItem}
keyExtractor={(item, index) => `activity_item_${index}_${item.created}`}
// `created` alone repeats across ops mined in the same block, and the index alone
// shifts as pages prepend, so key on the on-chain identity when there is one.
keyExtractor={(item, index) =>
`activity_item_${item.engineTrxId ?? item.trxIndex ?? index}_${item.created}`
}
renderSectionHeader={({ section: { title } }) => (
<Text style={styles.textActivities}>{title}</Text>
)}
ListFooterComponent={
loading ? (
<ActivityIndicator
color={EStyleSheet.value('$primaryBlue')}
style={styles.activitiesFooterIndicator}
/>
) : null
}
ListFooterComponent={_renderFooter()}
ListHeaderComponent={header}
refreshControl={_refreshControl}
onEndReachedThreshold={0.5}
onEndReached={() => {
onEndReached();
}}
Expand Down
7 changes: 7 additions & 0 deletions src/screens/assetDetails/children/children.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ export default EStyleSheet.create({
marginVertical: 16,
} as ViewStyle,

activitiesPlaceholder: {
color: '$primaryDarkText',
fontSize: 14,
textAlign: 'center',
paddingVertical: 24,
} as TextStyle,

delegationsModal: {
flex: 1,
backgroundColor: '$primaryBackgroundColor',
Expand Down
13 changes: 12 additions & 1 deletion src/screens/assetDetails/screen/assetDetailsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,17 @@ const AssetDetailsScreen = ({ navigation, route }: AssetDetailsScreenProps) => {
}
}, [assetsQuery.data]);

// The AppState listener is registered once, so it would capture the first render's
// `_fetchDetails` and with it a permanently-false `isRefreshing`/`isLoading`. Route
// it through a ref so foregrounding refreshes against current query state instead of
// restarting an in-flight fetch.
const fetchDetailsRef = useRef<(refresh?: boolean) => void>(() => {});

// side-effects
useEffect(() => {
fetchDetailsRef.current = _fetchDetails;
});

useEffect(() => {
_fetchDetails();
const appStateSub = AppState.addEventListener('change', _handleAppStateChange);
Expand All @@ -95,7 +105,7 @@ const AssetDetailsScreen = ({ navigation, route }: AssetDetailsScreenProps) => {

const _handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
_fetchDetails(true);
fetchDetailsRef.current(true);
}

appState.current = nextAppState;
Expand Down Expand Up @@ -263,6 +273,7 @@ const AssetDetailsScreen = ({ navigation, route }: AssetDetailsScreenProps) => {
pendingActivities={pendingRequestsQuery.data || []}
refreshing={activitiesQuery.isRefreshing}
loading={activitiesQuery.isLoading}
failed={activitiesQuery.isError}
activitiesEnabled={asset.layer !== 'chain'}
onEndReached={_fetchDetails}
onRefresh={_onRefresh}
Expand Down
Loading
Loading