diff --git a/android/app/build.gradle b/android/app/build.gradle
index 6b405b2e6c..a2da8e2491 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -102,7 +102,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch
- versionName "3.5.1"
+ versionName "3.5.2"
resValue "string", "build_config_package", "app.esteem.mobile.android"
multiDexEnabled true
// react-native-image-crop-picker
diff --git a/ios/Ecency.xcodeproj/project.pbxproj b/ios/Ecency.xcodeproj/project.pbxproj
index 70ed59beff..1657df4e4a 100644
--- a/ios/Ecency.xcodeproj/project.pbxproj
+++ b/ios/Ecency.xcodeproj/project.pbxproj
@@ -1448,7 +1448,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 27;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 75B6RXTKGT;
@@ -1494,7 +1494,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 27;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 75B6RXTKGT;
@@ -1535,7 +1535,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 75B6RXTKGT;
@@ -1618,7 +1618,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 26;
+ CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 75B6RXTKGT;
diff --git a/ios/Ecency/Info.plist b/ios/Ecency/Info.plist
index 2836b05104..85984c5a3e 100644
--- a/ios/Ecency/Info.plist
+++ b/ios/Ecency/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 3.5.1
+ 3.5.2
CFBundleSignature
????
CFBundleURLTypes
diff --git a/ios/EcencyTests/Info.plist b/ios/EcencyTests/Info.plist
index 9d06e85509..956199f661 100644
--- a/ios/EcencyTests/Info.plist
+++ b/ios/EcencyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 3.5.1
+ 3.5.2
CFBundleSignature
????
CFBundleVersion
- 26
+ 27
diff --git a/ios/eshare/Info.plist b/ios/eshare/Info.plist
index 0bce586b2d..7a1e3abb09 100644
--- a/ios/eshare/Info.plist
+++ b/ios/eshare/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 3.5.1
+ 3.5.2
CFBundleVersion
- 26
+ 27
NSExtension
NSExtensionAttributes
diff --git a/package.json b/package.json
index 55e67ac1d9..839297e49f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ecency",
- "version": "3.5.1",
+ "version": "3.5.2",
"displayName": "Ecency",
"private": true,
"rnpm": {
@@ -35,7 +35,7 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/runtime": "^7.26.7",
"@ecency/render-helper": "^2.4.18",
- "@ecency/sdk": "^2.0.7",
+ "@ecency/sdk": "^2.0.9",
"@esteemapp/dhive": "0.15.0",
"@esteemapp/react-native-autocomplete-input": "^4.2.1",
"@esteemapp/react-native-multi-slider": "^1.1.0",
diff --git a/patches/hive-auth-wrapper+1.0.0.patch b/patches/hive-auth-wrapper+1.0.0.patch
new file mode 100644
index 0000000000..22c57bca00
--- /dev/null
+++ b/patches/hive-auth-wrapper+1.0.0.patch
@@ -0,0 +1,85 @@
+diff --git a/node_modules/hive-auth-wrapper/has-wrapper.js b/node_modules/hive-auth-wrapper/has-wrapper.js
+index ff0b878778..7c7906f4d6 100644
+--- a/node_modules/hive-auth-wrapper/has-wrapper.js
++++ b/node_modules/hive-auth-wrapper/has-wrapper.js
+@@ -40,6 +40,7 @@ let HAS_timeout = 60*1000 // default request expiration timeout (60 seconds)
+
+ let messages = []
+ let wsHAS = undefined
++let wsHAS_creating = false // Guard against concurrent WebSocket creation
+ let trace = false
+
+ function getMessage(type, uuid=undefined) {
+@@ -56,9 +57,25 @@ function getMessage(type, uuid=undefined) {
+
+ // HAS client
+ function startWebsocket() {
+- wsHAS = new WebSocket(HAS_options.host)
++ // Guard against concurrent WebSocket creation (e.g., rapid checkConnection calls
++ // when app returns from background after Keychain interaction)
++ if(wsHAS_creating) {
++ if(trace) console.log("WebSocket creation already in progress, skipping")
++ return
++ }
++ wsHAS_creating = true
++ let ws
++ try {
++ ws = new WebSocket(HAS_options.host)
++ } catch (error) {
++ wsHAS_creating = false
++ if(trace) console.log("WebSocket creation failed", error)
++ return
++ }
++ wsHAS = ws
+ wsHAS.onopen = function() {
+ // Web Socket is connected
++ wsHAS_creating = false
+ HAS_connected = true
+ if(trace) console.log("WebSocket connected")
+ }
+@@ -95,9 +112,14 @@ function startWebsocket() {
+ }
+ }
+ wsHAS.onclose = function(event) {
+- // connection closed, discard old websocket
+- wsHAS = undefined
+- HAS_connected = false
++ wsHAS_creating = false
++ // Only clear global reference if THIS WebSocket is still the current one.
++ // Prevents a stale onclose from overwriting a newer WebSocket reference
++ // (e.g., when app returns from background and a new connection was already created).
++ if(wsHAS === ws) {
++ wsHAS = undefined
++ HAS_connected = false
++ }
+ if(trace) console.log("WebSocket disconnected", event)
+ }
+ }
+@@ -143,19 +165,23 @@ async function attach(uuid) {
+ }
+
+ async function checkConnection(uuid=undefined) {
+- if ("WebSocket" in window) {
+- // The browser support Websocket
++ if (typeof WebSocket !== "undefined") {
+ if(HAS_connected) {
+ return true
+ }
+- if(!wsHAS) {
++ if(!wsHAS && !wsHAS_creating) {
+ startWebsocket()
+ }
+ if(!HAS_connected) {
+ // connection not completed yet, wait till ready
++ // Added null safety: after await sleep(), wsHAS could be undefined
++ // if onclose fired during the sleep. Also limit iterations to
++ // prevent infinite loops when WebSocket fails to connect.
++ let maxWait = Math.ceil(HAS_timeout / Math.max(1, DELAY_CHECK_WEBSOCKET))
+ do {
+ await sleep(DELAY_CHECK_WEBSOCKET)
+- } while(wsHAS && wsHAS.readyState==0) // 0 = Connecting
++ maxWait--
++ } while(wsHAS && wsHAS.readyState === 0 && maxWait > 0)
+ }
+ if(HAS_connected && uuid) {
+ // WebSocket reconnected, try to attach pending request if any
diff --git a/src/components/comment/view/commentView.tsx b/src/components/comment/view/commentView.tsx
index ab164304c9..5563b27eb1 100644
--- a/src/components/comment/view/commentView.tsx
+++ b/src/components/comment/view/commentView.tsx
@@ -41,7 +41,7 @@ const CommentView = ({
handleImagePress,
handleYoutubePress,
handleVideoPress,
- mainAuthor = { mainAuthor },
+ mainAuthor = '',
openReplyThread,
repliesToggle,
handleOnToggleReplies,
@@ -229,7 +229,13 @@ const CommentView = ({
iconStyle={styles.leftIcon}
style={styles.leftButton}
name="delete-forever"
- onPress={() => handleDeleteComment(comment.permlink, comment.parent_permlink)}
+ onPress={() =>
+ handleDeleteComment(
+ comment.permlink,
+ comment.parent_permlink,
+ comment.parent_author,
+ )
+ }
iconType="MaterialIcons"
/>
)}
diff --git a/src/components/comments/container/commentsContainer.tsx b/src/components/comments/container/commentsContainer.tsx
index 9d16377096..6f8843866c 100644
--- a/src/components/comments/container/commentsContainer.tsx
+++ b/src/components/comments/container/commentsContainer.tsx
@@ -7,26 +7,22 @@ import get from 'lodash/get';
import { postBodySummary } from '@ecency/render-helper';
import { useNavigation } from '@react-navigation/native';
import { SheetManager } from 'react-native-actions-sheet';
-import { getDiscussionsQueryOptions } from '@ecency/sdk';
+import { getDiscussionsQueryOptions, useDeleteComment } from '@ecency/sdk';
import { useQueryClient } from '@tanstack/react-query';
-import { deleteComment } from '../../../providers/hive/dhive';
// Services and Actions
import { writeToClipboard } from '../../../utils/clipboard';
import { toastNotification } from '../../../redux/actions/uiAction';
-// Middleware
-
// Constants
import ROUTES from '../../../constants/routeNames';
// Component
import CommentsView from '../view/commentsView';
-import { updateCommentCache } from '../../../redux/actions/cacheActions';
-import { CacheStatus } from '../../../redux/reducers/cacheReducer';
import { postQueries } from '../../../providers/queries';
import { PostTypes } from '../../../constants/postTypes';
import { SheetNames } from '../../../navigation/sheets';
-import { selectCurrentAccount, selectIsLoggedIn, selectPin } from '../../../redux/selectors';
+import { selectCurrentAccount, selectIsLoggedIn } from '../../../redux/selectors';
+import { useAuthContext } from '../../../providers/sdk';
const CommentsContainer = ({
author,
@@ -35,7 +31,6 @@ const CommentsContainer = ({
isOwnProfile,
fetchPost,
currentAccount,
- pinCode,
comments,
dispatch,
intl,
@@ -63,6 +58,8 @@ const CommentsContainer = ({
const navigation = useNavigation();
const postsCachePrimer = postQueries.usePostsCachePrimer();
const queryClient = useQueryClient();
+ const authContext = useAuthContext();
+ const deleteCommentMutation = useDeleteComment(currentAccount?.name, authContext);
const [lcomments, setLComments] = useState([]);
const [propComments, setPropComments] = useState(comments);
@@ -210,8 +207,7 @@ const CommentsContainer = ({
});
};
- const _handleDeleteComment = (_permlink, _parent_permlink) => {
- let filteredComments;
+ const _handleDeleteComment = (_permlink, _parent_permlink, _parent_author) => {
if (postType === PostTypes.WAVE && handleCommentDelete) {
handleCommentDelete({
_permlink,
@@ -219,33 +215,23 @@ const CommentsContainer = ({
});
return;
}
- deleteComment(currentAccount, pinCode, _permlink).then(() => {
- let deletedItem = null;
-
- const _applyFilter = (item) => {
- if (item.permlink === _permlink) {
- deletedItem = item;
- return false;
- }
- return true;
- };
-
- if (lcomments.length > 0) {
- filteredComments = lcomments.filter(_applyFilter);
- setLComments(filteredComments);
- } else {
- filteredComments = propComments.filter(_applyFilter);
- setPropComments(filteredComments);
- }
-
- // remove cached entry based on parent
- if (deletedItem) {
- const cachePath = `${deletedItem.author}/${deletedItem.permlink}`;
- deletedItem.status = CacheStatus.DELETED;
- delete deletedItem.updated;
- dispatch(updateCommentCache(cachePath, deletedItem, { isUpdate: true }));
- }
- });
+ deleteCommentMutation
+ .mutateAsync({
+ author: currentAccount?.name,
+ permlink: _permlink,
+ parentAuthor: _parent_author,
+ parentPermlink: _parent_permlink || permlink,
+ })
+ .then(() => {
+ // Remove from local state for immediate UI update
+ setLComments((prev) => prev.filter((item) => item.permlink !== _permlink));
+ setPropComments((prev) => prev.filter((item) => item.permlink !== _permlink));
+ })
+ .catch((err) => {
+ const errorDetail = err?.message ? String(err.message) : String(err);
+ dispatch(toastNotification(`Failed to delete comment: ${errorDetail}`));
+ console.warn('Failed to delete comment', err);
+ });
};
const _handleOnUserPress = (username) => {
@@ -332,7 +318,6 @@ const CommentsContainer = ({
const mapStateToProps = (state) => ({
isLoggedIn: selectIsLoggedIn(state),
currentAccount: selectCurrentAccount(state),
- pinCode: selectPin(state),
});
export default connect(mapStateToProps)(injectIntl(CommentsContainer));
diff --git a/src/components/hiveAuthModal/hooks/useHiveAuth.ts b/src/components/hiveAuthModal/hooks/useHiveAuth.ts
index 926d11e8f1..f42acffd39 100644
--- a/src/components/hiveAuthModal/hooks/useHiveAuth.ts
+++ b/src/components/hiveAuthModal/hooks/useHiveAuth.ts
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Linking, Keyboard } from 'react-native';
+import { AppState, AppStateStatus, Linking, Keyboard } from 'react-native';
import { useDispatch } from 'react-redux';
import HAS from 'hive-auth-wrapper';
@@ -214,6 +214,31 @@ const ensureHasConnection = (forceReconnect = false) => {
return _hasConnectionPromise;
};
+let _appStateSubscription: { remove: () => void } | null = null;
+let _appStateListenerUsers = 0;
+let _lastAppState: AppStateStatus = AppState.currentState;
+
+const ensureAppStateListener = () => {
+ if (_appStateSubscription) {
+ return;
+ }
+
+ _appStateSubscription = AppState.addEventListener('change', (nextAppState) => {
+ if (/inactive|background/.test(_lastAppState) && nextAppState === 'active') {
+ console.log('[HiveAuth] App returned to foreground, forcing HAS reconnect');
+ ensureHasConnection(true);
+ }
+ _lastAppState = nextAppState;
+ });
+};
+
+const releaseAppStateListener = () => {
+ if (_appStateSubscription) {
+ _appStateSubscription.remove();
+ _appStateSubscription = null;
+ }
+};
+
export const useHiveAuth = () => {
const intl = useIntl();
const postLoginActions = usePostLoginActions();
@@ -230,6 +255,22 @@ export const useHiveAuth = () => {
ensureHasConnection();
}, []);
+ // Force fresh HAS connection when app returns from background.
+ // When the user is redirected to Keychain for signing, the app goes to background
+ // and the WebSocket may disconnect. On return, we need a fresh connection before
+ // the broadcast's internal polling tries to use the stale one.
+ useEffect(() => {
+ if (_appStateListenerUsers++ === 0) {
+ ensureAppStateListener();
+ }
+
+ return () => {
+ if (_appStateListenerUsers > 0 && --_appStateListenerUsers === 0) {
+ releaseAppStateListener();
+ }
+ };
+ }, []);
+
/**
* authenticates user via installed hive auth or keychain app
* compiles and set account data in redux store
diff --git a/src/components/postComments/container/postComments.tsx b/src/components/postComments/container/postComments.tsx
index d07ef5fab6..b7334a64da 100644
--- a/src/components/postComments/container/postComments.tsx
+++ b/src/components/postComments/container/postComments.tsx
@@ -17,16 +17,12 @@ import { RefreshControl } from 'react-native-gesture-handler';
import EStyleSheet from 'react-native-extended-stylesheet';
import { SheetManager } from 'react-native-actions-sheet';
import { FlashList } from '@shopify/flash-list';
-import { useQueryClient } from '@tanstack/react-query';
-import { getDiscussionsQueryOptions } from '@ecency/sdk';
+import { useDeleteComment } from '@ecency/sdk';
import COMMENT_FILTER, { VALUE } from '../../../constants/options/comment';
import { FilterBar } from '../../filterBar';
import { postQueries } from '../../../providers/queries';
import { useAppDispatch, useAppSelector } from '../../../hooks';
import ROUTES from '../../../constants/routeNames';
-import { deleteComment } from '../../../providers/hive/dhive';
-import { updateCommentCache } from '../../../redux/actions/cacheActions';
-import { CacheStatus } from '../../../redux/reducers/cacheReducer';
import { PostTypes } from '../../../constants/postTypes';
import { CommentsSection } from '../children/commentsSection';
import { sortComments } from '../children/sortComments';
@@ -36,7 +32,9 @@ import { PostOptionsModal } from '../../index';
import { BotCommentsPreview } from '../children/botCommentsPreview';
import { SheetNames } from '../../../navigation/sheets';
import { checkViewability } from '../../../hooks/useViewabilityTracker';
-import { selectCurrentAccount, selectPin, selectIsDarkTheme } from '../../../redux/selectors';
+import { selectCurrentAccount, selectIsDarkTheme } from '../../../redux/selectors';
+import { useAuthContext } from '../../../providers/sdk';
+import { toastNotification } from '../../../redux/actions/uiAction';
const PostComments = forwardRef(
(
@@ -56,14 +54,17 @@ const PostComments = forwardRef(
ref,
) => {
const intl = useIntl();
- const dispatch = useAppDispatch();
const navigation = useNavigation();
- const queryClient = useQueryClient();
+ const dispatch = useAppDispatch();
const currentAccount = useAppSelector(selectCurrentAccount);
- const pinHash = useAppSelector(selectPin);
+ const currentAccountName = currentAccount?.name;
const isDarkTheme = useAppSelector(selectIsDarkTheme);
+ const authContext = useAuthContext();
+ const deleteCommentMutation = useDeleteComment(currentAccountName, authContext);
+ const { mutateAsync: deleteComment } = deleteCommentMutation;
+
const discussionQuery = postQueries.useDiscussionQuery(author, permlink);
const postsCachePrimer = postQueries.usePostsCachePrimer();
@@ -158,36 +159,20 @@ const PostComments = forwardRef(
);
const _handleDeleteComment = useCallback(
- (_permlink) => {
+ (_permlink, _parentPermlink?, _parentAuthor?) => {
const _onConfirmDelete = async () => {
try {
- await deleteComment(currentAccount, pinHash, _permlink);
- // remove cached entry based on parent
- const _commentPath = `${currentAccount.name}/${_permlink}`;
- console.log('deleted comment', _commentPath);
-
- const _deletedItem = discussionQuery.data?.[_commentPath];
- if (_deletedItem) {
- // Don't mutate - create new object
- const updatedItem = {
- ..._deletedItem,
- status: CacheStatus.DELETED,
- };
- delete updatedItem.updated;
- dispatch(updateCommentCache(_commentPath, updatedItem, { isUpdate: true }));
- }
-
- // Invalidate discussion query cache to refresh comment list
- queryClient.invalidateQueries({
- queryKey: getDiscussionsQueryOptions(
- { author, permlink } as any,
- 'created' as any,
- true,
- currentAccount.name, // Pass observer to match the query key used in useDiscussionQuery
- ).queryKey,
+ await deleteComment({
+ author: currentAccountName,
+ permlink: _permlink,
+ parentAuthor: _parentAuthor,
+ parentPermlink: _parentPermlink || permlink,
});
+ console.log('deleted comment', `${currentAccountName}/${_permlink}`);
} catch (err) {
- console.warn('Failed to delete comment');
+ const errorDetail = err?.message ? String(err.message) : String(err);
+ dispatch(toastNotification(`Failed to delete comment: ${errorDetail}`));
+ console.warn('Failed to delete comment', err);
}
};
@@ -209,16 +194,7 @@ const PostComments = forwardRef(
},
});
},
- [
- currentAccount,
- pinHash,
- discussionQuery.data,
- dispatch,
- intl,
- author,
- permlink,
- queryClient,
- ],
+ [currentAccountName, deleteComment, dispatch, intl, permlink],
);
const _openReplyThread = useCallback(
diff --git a/src/components/postOptionsModal/container/postOptionsModal.tsx b/src/components/postOptionsModal/container/postOptionsModal.tsx
index 31519cd395..8a4b21cc20 100644
--- a/src/components/postOptionsModal/container/postOptionsModal.tsx
+++ b/src/components/postOptionsModal/container/postOptionsModal.tsx
@@ -10,13 +10,9 @@ import { FlatList } from 'react-native-gesture-handler';
import ActionSheet, { SheetManager } from 'react-native-actions-sheet';
import { useQueryClient } from '@tanstack/react-query';
import { getPostQueryOptions, getAccountFullQueryOptions } from '@ecency/sdk';
-import {
- deleteComment,
- ignoreUser,
- pinCommunityPost,
- profileUpdate,
- reblog,
-} from '../../../providers/hive/dhive';
+import { useDeleteComment } from '@ecency/sdk';
+import { ignoreUser, pinCommunityPost, profileUpdate, reblog } from '../../../providers/hive/dhive';
+import { useAuthContext } from '../../../providers/sdk';
import { addReport } from '../../../providers/ecency/ecency';
import { toastNotification, setRcOffer } from '../../../redux/actions/uiAction';
@@ -76,6 +72,9 @@ const PostOptionsModal = ({ pageType, isWave, isVisibleTranslateModal }: Props,
const isLoggedIn = useAppSelector(selectIsLoggedIn);
const currentAccount = useAppSelector(selectCurrentAccount);
const pinCode = useAppSelector(selectPin);
+
+ const authContext = useAuthContext();
+ const deleteCommentMutation = useDeleteComment(currentAccount?.name, authContext);
const isPinCodeOpen = useAppSelector(selectIsPinCodeOpen);
const subscribedCommunities = useAppSelector((state) => state.communities.subscribedCommunities);
@@ -313,15 +312,31 @@ const PostOptionsModal = ({ pageType, isWave, isVisibleTranslateModal }: Props,
const _deletePost = () => {
const _onConfirm = async () => {
- await deleteComment(currentAccount, pinCode, content.permlink);
- navigation.goBack();
- dispatch(
- toastNotification(
- intl.formatMessage({
- id: 'alert.removed',
- }),
- ),
- );
+ try {
+ await deleteCommentMutation.mutateAsync({
+ author: currentAccount?.name,
+ permlink: content.permlink,
+ parentAuthor: content.parent_author || '',
+ parentPermlink: content.parent_permlink || '',
+ });
+ navigation.goBack();
+ dispatch(
+ toastNotification(
+ intl.formatMessage({
+ id: 'alert.removed',
+ }),
+ ),
+ );
+ } catch (err) {
+ console.warn('Failed to delete post', err);
+ dispatch(
+ toastNotification(
+ intl.formatMessage({
+ id: 'alert.fail',
+ }),
+ ),
+ );
+ }
};
SheetManager.show(SheetNames.ACTION_MODAL, {
diff --git a/src/components/quickPostModal/usePostSubmitter.ts b/src/components/quickPostModal/usePostSubmitter.ts
index 806c919123..3a1515414f 100644
--- a/src/components/quickPostModal/usePostSubmitter.ts
+++ b/src/components/quickPostModal/usePostSubmitter.ts
@@ -1,32 +1,30 @@
import { useDispatch } from 'react-redux';
import { Alert } from 'react-native';
import { useIntl } from 'react-intl';
-import { useQueryClient } from '@tanstack/react-query';
-import { getDiscussionsQueryOptions } from '@ecency/sdk';
+import { useComment } from '@ecency/sdk';
import { SheetManager } from 'react-native-actions-sheet';
import { useAppSelector, useStateWithRef } from '../../hooks';
-import { postComment, shouldPromptPostingAuthority } from '../../providers/hive/dhive';
+import { shouldPromptPostingAuthority } from '../../providers/hive/dhive';
import { extractMetadata, generateUniquePermlink, makeJsonMetadata } from '../../utils/editor';
-import { updateCommentCache, deleteCommentCacheEntry } from '../../redux/actions/cacheActions';
import { toastNotification } from '../../redux/actions/uiAction';
-import { useUserActivityMutation, wavesQueries } from '../../providers/queries';
-import { PointActivityIds, PollDraft } from '../../providers/ecency/ecency.types';
+import { wavesQueries } from '../../providers/queries';
+import { PollDraft } from '../../providers/ecency/ecency.types';
import { usePublishWaveMutation } from '../../providers/queries/postQueries/wavesQueries';
import { PostTypes } from '../../constants/postTypes';
import extractHashTags from '../../utils/extractHashTags';
-import { selectCurrentAccount, selectPin } from '../../redux/selectors';
+import { selectCurrentAccount } from '../../redux/selectors';
import { SheetNames } from '../../navigation/sheets';
+import { useAuthContext } from '../../providers/sdk';
export const usePostSubmitter = () => {
const dispatch = useDispatch();
const intl = useIntl();
- const queryClient = useQueryClient();
const pusblishWaveMutation = usePublishWaveMutation();
const currentAccount = useAppSelector(selectCurrentAccount);
- const pinCode = useAppSelector(selectPin);
- const userActivityMutation = useUserActivityMutation();
+ const authContext = useAuthContext();
+ const commentMutation = useComment(currentAccount?.name, authContext);
const [isSubmitting, setIsSubmitting, getIsSubmittingCurrent] = useStateWithRef(false);
const [
_postingAuthorityPromptShown,
@@ -106,7 +104,6 @@ export const usePostSubmitter = () => {
const author = currentAccount.name;
const parentAuthor = parentPost.author;
const parentPermlink = parentPost.permlink;
- const observer = currentAccount.name || currentAccount.username;
const parentTags = parentPost.json_metadata.tags || ['ecency'];
const category = parentPost.category || '';
const url = `/${category}/@${parentAuthor}/${parentPermlink}#@${author}/${permlink}`;
@@ -123,17 +120,11 @@ export const usePostSubmitter = () => {
});
const jsonMetadata = makeJsonMetadata(meta, tags);
- console.log(
- currentAccount,
- pinCode,
- parentAuthor,
- parentPermlink,
- permlink,
- commentBody,
- jsonMetadata,
- );
+ // Derive root author/permlink for proper cache invalidation
+ const rootAuthor = parentPost.root_author || parentAuthor;
+ const rootPermlink = parentPost.root_permlink || parentPermlink;
- // Build cache entry for optimistic update
+ // Build cache entry for wave optimistic prepend
const _cacheCommentData = {
author,
permlink,
@@ -144,43 +135,19 @@ export const usePostSubmitter = () => {
json_metadata: jsonMetadata,
};
- // Optimistic: dispatch cache BEFORE blockchain call
- // useDiscussionQuery's useEffect depends on cachedComments and will
- // re-run injectPostCache to show the comment immediately via Redux
- dispatch(
- updateCommentCache(`${author}/${permlink}`, _cacheCommentData, {
- parentTags: parentTags || ['ecency'],
- }),
- );
-
try {
- const response = await postComment(
- currentAccount,
- pinCode,
+ await commentMutation.mutateAsync({
+ author,
+ permlink,
parentAuthor,
parentPermlink,
- permlink,
- commentBody,
+ title: '',
+ body: commentBody,
jsonMetadata,
- );
-
- userActivityMutation.mutate({
- pointsTy: PointActivityIds.COMMENT,
- transactionId: response.id,
+ rootAuthor,
+ rootPermlink,
});
- // Invalidate discussion queries to refetch with real blockchain data
- if (postType !== PostTypes.WAVE) {
- queryClient.invalidateQueries({
- queryKey: getDiscussionsQueryOptions(
- { author: parentAuthor, permlink: parentPermlink } as any,
- 'created' as any,
- true,
- observer,
- ).queryKey,
- });
- }
-
dispatch(
toastNotification(
intl.formatMessage({
@@ -193,19 +160,6 @@ export const usePostSubmitter = () => {
} catch (error) {
console.log(error);
- // Rollback: remove optimistic cache entry and invalidate to clean up
- dispatch(deleteCommentCacheEntry(`${author}/${permlink}`));
- if (postType !== PostTypes.WAVE) {
- queryClient.invalidateQueries({
- queryKey: getDiscussionsQueryOptions(
- { author: parentAuthor, permlink: parentPermlink } as any,
- 'created' as any,
- true,
- observer,
- ).queryKey,
- });
- }
-
Alert.alert(
intl.formatMessage({
id: 'alert.something_wrong',
diff --git a/src/providers/hive/dhive.ts b/src/providers/hive/dhive.ts
index 51e6fcc6e1..d31ce9a6cd 100644
--- a/src/providers/hive/dhive.ts
+++ b/src/providers/hive/dhive.ts
@@ -1553,70 +1553,6 @@ export const getPurePost = async (author, permlink) => {
}
};
-export const deleteComment = async (currentAccount, pin, permlink) => {
- const { name: author } = currentAccount;
- const digitPinCode = getDigitPinCode(pin);
- const key = getPostingKey(currentAccount.local, digitPinCode);
-
- // HiveAuth without posting authority: go directly to HiveAuth broadcast
- if (shouldUseDirectHiveAuthBroadcast(currentAccount)) {
- const deleteOp: Operation = ['delete_comment', { author, permlink }];
- return handleHiveAuthFallback(currentAccount, [deleteOp], 'delete_comment');
- }
-
- if (isHsClientSupported(currentAccount.local.authType)) {
- const token = decryptKey(currentAccount.local.accessToken, digitPinCode);
- const api = new hsClient({
- accessToken: token,
- });
-
- const params = {
- author,
- permlink,
- };
-
- const opArray = [['delete_comment', params]];
-
- try {
- return await api.broadcast(opArray).then((resp) => resp.result);
- } catch (err) {
- // Check if this is a HiveAuth user with auth error (missing authority, expired token, etc.)
- const isHiveAuth = currentAccount.local?.authType === AUTH_TYPE.HIVE_AUTH;
-
- if (isHiveAuth && shouldTriggerHiveAuthFallback(err)) {
- // Build delete_comment operation
- const deleteOp: Operation = [
- 'delete_comment',
- {
- author,
- permlink,
- },
- ];
-
- return handleHiveAuthFallback(currentAccount, [deleteOp], 'delete_comment');
- }
-
- throw err;
- }
- }
-
- if (key) {
- const opArray = [
- [
- 'delete_comment',
- {
- author,
- permlink,
- },
- ],
- ];
-
- const privateKey = PrivateKey.fromString(key);
-
- return sendHiveOperations(opArray, privateKey);
- }
-};
-
export const getDiscussionCollection = async (
author: string,
permlink: string,
@@ -2840,54 +2776,7 @@ export const postContent = (
});
/**
- * Broadcasts a comment to post
- * @param account currentAccount object
- * @param pin encrypted pin taken from redux
- * @param {*} parentAuthor author of parent post or in case of reply to comment author of parent comment
- * @param {*} parentPermlink permlink of parent post or in case of reply to comment author of parent comment
- * @param {*} permlink perlink of comment to be make
- * @param {*} body body of comment
- * @param {*} parentTags tags of parent post or parent comment
- * @param {*} isEdit optional to avoid tracking activity in case of comment editing
- * @returns
- */
-export const postComment = (
- account,
- pin,
- parentAuthor,
- parentPermlink,
- permlink,
- body,
- jsonMetadata,
-) =>
- _postContent(
- account,
- pin,
- parentAuthor,
- parentPermlink,
- permlink,
- '',
- body,
- jsonMetadata,
- null,
- null,
- )
- .then((resp) => {
- return resp;
- })
- .catch((err) => {
- console.warn('Failed to post conent', err);
- captureExceptionWithRpcParams(err, {
- account: account?.name,
- parentAuthor,
- parentPermlink,
- permlink,
- });
- throw err;
- });
-
-/**
- * @method postComment post a comment/reply
+ * @method _postContent post content to blockchain (used for post creation/editing)
* @param comment comment object { author, permlink, ... }
*/
const _postContent = async (
diff --git a/src/providers/queries/draftQueries.ts b/src/providers/queries/draftQueries.ts
index 18ebec2546..c67920bccc 100644
--- a/src/providers/queries/draftQueries.ts
+++ b/src/providers/queries/draftQueries.ts
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import {
+ QueryKeys,
getDraftsInfiniteQueryOptions,
getSchedulesInfiniteQueryOptions,
useAddDraft,
@@ -14,6 +15,18 @@ import { useIntl } from 'react-intl';
import { useAppDispatch, useAuth } from '../../hooks';
import { toastNotification } from '../../redux/actions/uiAction';
+const DEFAULT_INFINITE_QUERY_LIMIT = 20;
+
+const draftsInfiniteQueryKey = (
+ username: string | undefined,
+ limit = DEFAULT_INFINITE_QUERY_LIMIT,
+) => QueryKeys.posts.draftsInfinite(username, limit).slice(0, 4);
+
+const schedulesInfiniteQueryKey = (
+ username: string | undefined,
+ limit = DEFAULT_INFINITE_QUERY_LIMIT,
+) => QueryKeys.posts.schedulesInfinite(username, limit).slice(0, 4);
+
/**
* Hook to return user drafts with infinite scroll pagination
* Uses SDK's getDraftsInfiniteQueryOptions for efficient data loading
@@ -80,11 +93,14 @@ export const useAddDraftMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
return useAddDraft(
username,
code,
- undefined, // onSuccess - handled by SDK query invalidation
+ () => {
+ queryClient.invalidateQueries({ queryKey: draftsInfiniteQueryKey(username) });
+ },
() => {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
},
@@ -99,24 +115,46 @@ export const useUpdateDraftMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
- return useUpdateDraft(username, code, undefined, () => {
- dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
- });
+ return useUpdateDraft(
+ username,
+ code,
+ () => {
+ queryClient.invalidateQueries({ queryKey: draftsInfiniteQueryKey(username) });
+ },
+ () => {
+ dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
+ },
+ );
};
/**
* Hook to delete a single draft
* Uses SDK's useDeleteDraft hook with mobile-specific error handling
+ *
+ * NOTE: The SDK's useDeleteDraft only updates the non-infinite drafts cache key
+ * (["posts", "drafts", username]), but mobile uses getDraftsInfiniteQueryOptions
+ * which stores data under ["posts", "drafts", "infinite", username, limit].
+ * We invalidate the infinite query on success so the list updates.
*/
export const useDraftDeleteMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
- return useDeleteDraft(username, code, undefined, () => {
- dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
- });
+ return useDeleteDraft(
+ username,
+ code,
+ () => {
+ // Invalidate infinite drafts query so the list re-fetches
+ queryClient.invalidateQueries({ queryKey: draftsInfiniteQueryKey(username) });
+ },
+ () => {
+ dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
+ },
+ );
};
/**
@@ -126,7 +164,9 @@ export const useDraftDeleteMutation = () => {
export const useDraftsBatchDeleteMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
- const deleteDraftMutation = useDraftDeleteMutation();
+ const { username, code } = useAuth();
+ const queryClient = useQueryClient();
+ const deleteDraftMutation = useDeleteDraft(username, code);
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
return {
@@ -139,11 +179,11 @@ export const useDraftsBatchDeleteMutation = () => {
// eslint-disable-next-line no-await-in-loop
await deleteDraftMutation.mutateAsync({ draftId: id });
}
- options?.onSettled?.();
} catch (error) {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
- options?.onSettled?.();
} finally {
+ await queryClient.invalidateQueries({ queryKey: draftsInfiniteQueryKey(username) });
+ options?.onSettled?.();
setIsBatchDeleting(false);
}
},
@@ -160,12 +200,14 @@ export const useAddScheduleMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
return useAddSchedule(
username,
code,
() => {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.success' })));
+ queryClient.invalidateQueries({ queryKey: schedulesInfiniteQueryKey(username) });
},
() => {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
@@ -176,15 +218,26 @@ export const useAddScheduleMutation = () => {
/**
* Hook to delete a single scheduled post
* Uses SDK's useDeleteSchedule hook with mobile-specific error handling
+ *
+ * NOTE: Same infinite query cache mismatch as drafts - SDK updates non-infinite
+ * key but mobile uses getSchedulesInfiniteQueryOptions.
*/
export const useScheduleDeleteMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
- return useDeleteSchedule(username, code, undefined, () => {
- dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
- });
+ return useDeleteSchedule(
+ username,
+ code,
+ () => {
+ queryClient.invalidateQueries({ queryKey: schedulesInfiniteQueryKey(username) });
+ },
+ () => {
+ dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
+ },
+ );
};
/**
@@ -194,7 +247,9 @@ export const useScheduleDeleteMutation = () => {
export const useSchedulesBatchDeleteMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
- const deleteScheduleMutation = useScheduleDeleteMutation();
+ const { username, code } = useAuth();
+ const queryClient = useQueryClient();
+ const deleteScheduleMutation = useDeleteSchedule(username, code);
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
return {
@@ -207,11 +262,11 @@ export const useSchedulesBatchDeleteMutation = () => {
// eslint-disable-next-line no-await-in-loop
await deleteScheduleMutation.mutateAsync({ id });
}
- options?.onSettled?.();
} catch (error) {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
- options?.onSettled?.();
} finally {
+ await queryClient.invalidateQueries({ queryKey: schedulesInfiniteQueryKey(username) });
+ options?.onSettled?.();
setIsBatchDeleting(false);
}
},
@@ -228,12 +283,16 @@ export const useMoveScheduleToDraftsMutation = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username, code } = useAuth();
+ const queryClient = useQueryClient();
return useMoveSchedule(
username,
code,
() => {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.success_moved' })));
+ // Invalidate both infinite queries since move affects both lists
+ queryClient.invalidateQueries({ queryKey: schedulesInfiniteQueryKey(username) });
+ queryClient.invalidateQueries({ queryKey: draftsInfiniteQueryKey(username) });
},
() => {
dispatch(toastNotification(intl.formatMessage({ id: 'alert.fail' })));
diff --git a/src/providers/queries/postQueries/commentQueries.ts b/src/providers/queries/postQueries/commentQueries.ts
new file mode 100644
index 0000000000..d7bc7a96fe
--- /dev/null
+++ b/src/providers/queries/postQueries/commentQueries.ts
@@ -0,0 +1,16 @@
+import { useComment, useUpdateReply, useDeleteComment } from '@ecency/sdk';
+import { useAuthContext } from '../../sdk';
+import { useAppSelector } from '../../../hooks';
+import { selectCurrentAccount } from '../../../redux/selectors';
+
+export function useCommentMutations() {
+ const currentAccount = useAppSelector(selectCurrentAccount);
+ const authContext = useAuthContext();
+ const username = currentAccount?.name;
+
+ const commentMutation = useComment(username, authContext);
+ const updateReplyMutation = useUpdateReply(username, authContext);
+ const deleteCommentMutation = useDeleteComment(username, authContext);
+
+ return { commentMutation, updateReplyMutation, deleteCommentMutation };
+}
diff --git a/src/providers/queries/postQueries/postQueries.ts b/src/providers/queries/postQueries/postQueries.ts
index 1b41e5d989..b250ef7d08 100644
--- a/src/providers/queries/postQueries/postQueries.ts
+++ b/src/providers/queries/postQueries/postQueries.ts
@@ -5,13 +5,7 @@ import { isArray } from 'lodash';
import { getPostQueryOptions, getDiscussionsQueryOptions, getBotsQueryOptions } from '@ecency/sdk';
import { useAppSelector } from '../../../hooks';
import { selectCurrentAccount } from '../../../redux/selectors';
-import { Comment, LastUpdateMeta } from '../../../redux/reducers/cacheReducer';
-import {
- injectPostCache,
- injectVoteCache,
- parsePost,
- parseComment,
-} from '../../../utils/postParser';
+import { injectVoteCache, parsePost, parseComment } from '../../../utils/postParser';
interface PostQueryProps {
author?: string;
@@ -131,15 +125,11 @@ export const usePostsCachePrimer = () => {
*/
export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
const currentAccount = useAppSelector(selectCurrentAccount);
- const cachedComments: { [key: string]: Comment } = useAppSelector(
- (state) => state.cache.commentsCollection,
- (a, b) => a === b, // Use reference equality to prevent unnecessary rerenders
- );
- const cachedVotes: { [key: string]: Comment } = useAppSelector(
+ const cachedVotes = useAppSelector(
(state) => state.cache.votesCollection,
(a, b) => a === b, // Use reference equality to prevent unnecessary rerenders
);
- const lastCacheUpdate: LastUpdateMeta = useAppSelector(
+ const lastCacheUpdate = useAppSelector(
(state) => state.cache.lastUpdate,
(a, b) => a === b, // Use reference equality to prevent unnecessary rerenders
);
@@ -168,11 +158,7 @@ export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
});
useEffect(() => {
- // Even if query.data is empty, we should try to show cached comments
- // This handles the case where user posts a comment before discussion finishes loading
- const hasCache = cachedComments && Object.keys(cachedComments).length > 0;
-
- if (!query.data && !hasCache) {
+ if (!query.data) {
setData((prev) => {
if (Object.keys(prev).length === 0) {
return prev;
@@ -182,29 +168,6 @@ export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
return;
}
- // If we have cache but no query data yet, we need to create synthetic parent entries
- // so that injectPostCache can properly inject the cached comments
- const initialData = {};
- if (!query.data && hasCache) {
- // Create synthetic parent entries for cached comments
- Object.keys(cachedComments).forEach((path) => {
- const cachedComment = cachedComments[path];
- const _parentPath = `${cachedComment.parent_author}/${cachedComment.parent_permlink}`;
-
- // Create a minimal synthetic parent if it doesn't exist
- if (!initialData[_parentPath]) {
- initialData[_parentPath] = {
- author: cachedComment.parent_author,
- permlink: cachedComment.parent_permlink,
- replies: [],
- children: 0,
- // Mark as synthetic so we know it's temporary
- _synthetic: true,
- };
- }
- });
- }
-
// Normalize SDK response to a map keyed by "author/permlink"
const normalizeReplies = (replies) => {
if (!Array.isArray(replies)) {
@@ -257,15 +220,14 @@ export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
return normalized;
};
- // Use initialData if query.data is not available yet (e.g., still loading)
- const normalizedData = normalizeDiscussionData(query.data || initialData);
+ const normalizedData = normalizeDiscussionData(query.data);
// Parse SDK comments to convert markdown to HTML using render-helper
// IMPORTANT: parseComment mutates its input, so we must create a shallow copy first
const parsedComments = {};
Object.keys(normalizedData).forEach((key) => {
const comment = normalizedData[key];
- if (comment && comment.body && !comment._synthetic) {
+ if (comment && comment.body) {
// Create shallow copy to avoid mutating React Query cache
const commentCopy = { ...comment };
parsedComments[key] = parseComment(commentCopy, currentAccount?.name);
@@ -274,10 +236,24 @@ export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
}
});
- const _data = injectPostCache(parsedComments, cachedComments, cachedVotes, lastCacheUpdate, {
- author,
- permlink,
- });
+ // Inject vote cache into parsed comments
+ let _data = parsedComments;
+ if (cachedVotes) {
+ let shouldClone = false;
+ Object.keys(_data).forEach((path) => {
+ const cachedVote = cachedVotes[path];
+ if (cachedVote) {
+ const updatedComment = injectVoteCache(_data[path], cachedVote);
+ if (updatedComment !== _data[path]) {
+ if (!shouldClone) {
+ _data = { ..._data };
+ shouldClone = true;
+ }
+ _data[path] = updatedComment;
+ }
+ }
+ });
+ }
// Deep check if data actually changed before setting state
setData((prev) => {
@@ -304,16 +280,7 @@ export const useDiscussionQuery = (_author?: string, _permlink?: string) => {
// No changes, return previous reference
return prev;
});
- }, [
- query.data,
- cachedComments,
- cachedVotes,
- lastCacheUpdate,
- observer,
- currentAccount?.name,
- author,
- permlink,
- ]);
+ }, [query.data, cachedVotes, lastCacheUpdate, observer, currentAccount?.name, author, permlink]);
// Cache to store processed comments and avoid recreating objects
const processedCommentsCache = useRef