diff --git a/.gitignore b/.gitignore index 66c00f729d..f6406db390 100755 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ yarn-error.log* Freighter.app/ Freighter.pkg Freighter.app.dSYM.zip + +# Agent tools +.vscode/mcp.json +.copliot/ +.github/copilot-skills/ diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index ba512ac3fd..a9e66f6d8e 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2283,3 +2283,43 @@ export const getCollectibles = async ({ return response; }; + +export const changeCollectibleVisibility = async ({ + collectibleKey, + collectibleVisibility, + activePublicKey, +}: { + collectibleKey: string; + collectibleVisibility: AssetVisibility; + activePublicKey: string; +}) => { + const response = await sendMessageToBackground({ + type: SERVICE_TYPES.CHANGE_COLLECTIBLE_VISIBILITY, + collectibleVisibility: { + collectible: collectibleKey, + visibility: collectibleVisibility, + }, + activePublicKey, + }); + + return { + hiddenCollectibles: response?.hiddenCollectibles || {}, + error: response?.error || "", + }; +}; + +export const getHiddenCollectibles = async ({ + activePublicKey, +}: { + activePublicKey: string; +}) => { + const response = await sendMessageToBackground({ + type: SERVICE_TYPES.GET_HIDDEN_COLLECTIBLES, + activePublicKey, + }); + + return { + hiddenCollectibles: response?.hiddenCollectibles || {}, + error: response?.error || "", + }; +}; diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index a73e438be0..e07461df6c 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -4,7 +4,12 @@ import browser from "webextension-polyfill"; import { WalletType } from "@shared/constants/hardwareWallet"; import { SERVICE_TYPES } from "@shared/constants/services"; import { NetworkDetails } from "@shared/constants/stellar"; -import { AssetVisibility, BalanceToMigrate, IssuerKey } from "./types"; +import { + AssetVisibility, + BalanceToMigrate, + IssuerKey, + CollectibleKey, +} from "./types"; import { AssetsListItem } from "@shared/constants/soroban/asset-list"; export interface TokenToAdd { @@ -374,6 +379,18 @@ export interface GetCollectiblesMessage extends BaseMessage { network: string; } +export interface ChangeCollectibleVisibilityMessage extends BaseMessage { + type: SERVICE_TYPES.CHANGE_COLLECTIBLE_VISIBILITY; + collectibleVisibility: { + collectible: CollectibleKey; + visibility: AssetVisibility; + }; +} + +export interface GetHiddenCollectiblesMessage extends BaseMessage { + type: SERVICE_TYPES.GET_HIDDEN_COLLECTIBLES; +} + export type ServiceMessageRequest = | FundAccountMessage | CreateAccountMessage @@ -433,4 +450,6 @@ export type ServiceMessageRequest = | GetMobileAppBannerDismissedMessage | DismissMobileAppBannerMessage | AddCollectibleMessage - | GetCollectiblesMessage; + | GetCollectiblesMessage + | ChangeCollectibleVisibilityMessage + | GetHiddenCollectiblesMessage; diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index d8ccf597fd..b0f754df7e 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -28,6 +28,7 @@ export interface UserInfo { export type MigratableAccount = Account & { keyIdIndex: number }; export type IssuerKey = string; // {assetCode}:{issuer/contract ID} issuer pub key for classic, contract ID for tokens +export type CollectibleKey = string; // {collectionAddress}:{tokenId} export type AssetVisibility = "visible" | "hidden"; export interface AllowList { @@ -116,6 +117,11 @@ export interface Response { visibility: AssetVisibility; }; hiddenAssets: Record; + collectibleVisibility: { + collectible: CollectibleKey; + visibility: AssetVisibility; + }; + hiddenCollectibles: Record; isOverwritingAccount: boolean; isDismissed: boolean; collectiblesList: CollectibleContract[]; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 9516e8fda5..2c5a83c794 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -58,6 +58,8 @@ export enum SERVICE_TYPES { DISMISS_MOBILE_APP_BANNER = "DISMISS_MOBILE_APP_BANNER", ADD_COLLECTIBLE = "ADD_COLLECTIBLE", GET_COLLECTIBLES = "GET_COLLECTIBLES", + CHANGE_COLLECTIBLE_VISIBILITY = "CHANGE_COLLECTIBLE_VISIBILITY", + GET_HIDDEN_COLLECTIBLES = "GET_HIDDEN_COLLECTIBLES", } export enum EXTERNAL_SERVICE_TYPES { diff --git a/extension/e2e-tests/hideCollectible.test.ts b/extension/e2e-tests/hideCollectible.test.ts new file mode 100644 index 0000000000..9c15dc83fb --- /dev/null +++ b/extension/e2e-tests/hideCollectible.test.ts @@ -0,0 +1,321 @@ +import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; +import { loginToTestAccount } from "./helpers/login"; +import { + stubAccountBalances, + stubAccountHistory, + stubScanDapp, + stubTokenDetails, + stubTokenPrices, + stubCollectibles, +} from "./helpers/stubs"; + +test("Hide and unhide a collectible", async ({ + page, + extensionId, + context, +}) => { + await stubTokenDetails(page); + await stubAccountBalances(page); + await stubAccountHistory(page); + await stubTokenPrices(page); + await stubScanDapp(context); + await stubCollectibles(page); + + await page.route("**/collectibles**", async (route) => { + const json = { + data: { + collections: [ + // Stellar Frogs Collection + { + collection: { + address: + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + name: "Stellar Frogs", + symbol: "SFROG", + collectibles: [ + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "1", + token_uri: "https://nftcalendar.io/tokenMetadata/1", + }, + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "2", + token_uri: "https://nftcalendar.io/tokenMetadata/2", + }, + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "3", + token_uri: "https://nftcalendar.io/tokenMetadata/3", + }, + ], + }, + }, + // Soroban Domains Collection + { + collection: { + address: "CCCSorobanDomainsCollection", + name: "Soroban Domains", + symbol: "SDOM", + collectibles: [ + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "102510", + token_uri: "https://nftcalendar.io/tokenMetadata/102510", + }, + ], + }, + }, + ], + }, + }; + await route.fulfill({ json }); + }); + + test.slow(); + await loginToTestAccount({ page, extensionId }); + await page.getByTestId("network-selector-open").click(); + await page.getByText("Main Net").click(); + + // Navigate to collectibles tab + await expect(page.getByTestId("account-view")).toBeVisible(); + await page.getByTestId("account-tab-collectibles").click(); + + // Verify collectibles are showing + await expect(page.getByText("Stellar Frogs")).toBeVisible(); + await expect(page.getByText("Soroban Domains")).toBeVisible(); + + // Take a screenshot of the collectibles view + await expectPageToHaveScreenshot({ + page, + screenshot: "collectibles-view-before-hide.png", + }); + + // Click on a collectible to open detail view + const collectibleGrid = page.getByTestId("account-collection-grid").first(); + await collectibleGrid.locator("div").first().click(); + + // Wait for detail view to open + await expect(page.getByTestId("CollectibleDetail")).toBeVisible(); + + // Take a screenshot of the collectible detail + await expectPageToHaveScreenshot({ + page, + screenshot: "collectible-detail-view.png", + }); + + // Open the three-dot menu + await page.getByTestId("CollectibleDetail__header__right-button").click(); + + // Wait for menu to be visible + await expect(page.getByText("Hide collectible")).toBeVisible(); + + // Take a screenshot of the menu with hide option + await expectPageToHaveScreenshot({ + page, + screenshot: "collectible-detail-hide-menu.png", + }); + + // Click "Hide collectible" + await page.getByText("Hide collectible").click(); + + // Wait for detail sheet to close + await expect(page.getByTestId("CollectibleDetail")).not.toBeVisible(); + + // Open the manage dropdown and go to hidden collectibles + await page.getByTestId("account-tabs-manage-btn-collectibles").click(); + await expect(page.getByText("Hidden collectibles")).toBeVisible(); + + // Take a screenshot of the manage dropdown + await expectPageToHaveScreenshot({ + page, + screenshot: "collectibles-manage-dropdown.png", + }); + + // Click on hidden collectibles + await page.getByTestId("hidden-collectibles-btn").click(); + + // Wait for hidden collectibles view to open by checking for the grid or empty state + await expect( + page + .getByTestId("hidden-collectible-1") + .or(page.getByText("No hidden collectibles")), + ).toBeVisible(); + + // Verify the hidden collectible is shown + await expect(page.getByTestId("hidden-collectible-1")).toBeVisible(); + + // Take a screenshot of the hidden collectibles view + await expectPageToHaveScreenshot({ + page, + screenshot: "hidden-collectibles-view.png", + }); + + // Click on the hidden collectible to open detail + await page.getByTestId("hidden-collectible-1").click(); + + // Wait for collectible detail to open + await expect(page.getByTestId("CollectibleDetail")).toBeVisible(); + + // Open the three-dot menu + await page.getByTestId("CollectibleDetail__header__right-button").click(); + + // Verify "Show collectible" option is visible (not "Hide collectible") + await expect(page.getByText("Show collectible")).toBeVisible(); + + // Take a screenshot of the menu with show option + await expectPageToHaveScreenshot({ + page, + screenshot: "collectible-detail-show-menu.png", + }); + + // Click "Show collectible" + await page.getByText("Show collectible").click(); + + // Wait for detail sheet to close + await expect(page.getByTestId("CollectibleDetail")).not.toBeVisible(); + + // Verify the empty state is now shown in hidden collectibles + await expect(page.getByText("No hidden collectibles")).toBeVisible(); + + // Take a screenshot of empty hidden collectibles + await expectPageToHaveScreenshot({ + page, + screenshot: "hidden-collectibles-empty.png", + }); +}); + +test("Hidden collectibles view shows empty state when no collectibles are hidden", async ({ + page, + extensionId, + context, +}) => { + await stubTokenDetails(page); + await stubAccountBalances(page); + await stubAccountHistory(page); + await stubTokenPrices(page); + await stubScanDapp(context); + await stubCollectibles(page); + + await page.route("**/collectibles**", async (route) => { + const json = { + data: { + collections: [ + { + collection: { + address: + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + name: "Stellar Frogs", + symbol: "SFROG", + collectibles: [ + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "1", + token_uri: "https://nftcalendar.io/tokenMetadata/1", + }, + ], + }, + }, + ], + }, + }; + await route.fulfill({ json }); + }); + + test.slow(); + await loginToTestAccount({ page, extensionId }); + await page.getByTestId("network-selector-open").click(); + await page.getByText("Main Net").click(); + + // Navigate to collectibles tab + await expect(page.getByTestId("account-view")).toBeVisible(); + await page.getByTestId("account-tab-collectibles").click(); + + // Open the manage dropdown + await page.getByTestId("account-tabs-manage-btn-collectibles").click(); + + // Click on hidden collectibles + await page.getByTestId("hidden-collectibles-btn").click(); + + // Verify empty state + await expect(page.getByText("No hidden collectibles")).toBeVisible(); +}); + +test("Hiding a collectible removes it from the main view", async ({ + page, + extensionId, + context, +}) => { + await stubTokenDetails(page); + await stubAccountBalances(page); + await stubAccountHistory(page); + await stubTokenPrices(page); + await stubScanDapp(context); + await stubCollectibles(page); + + await page.route("**/collectibles**", async (route) => { + const json = { + data: { + collections: [ + { + collection: { + address: + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + name: "Stellar Frogs", + symbol: "SFROG", + collectibles: [ + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "1", + token_uri: "https://nftcalendar.io/tokenMetadata/1", + }, + { + owner: + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY", + token_id: "2", + token_uri: "https://nftcalendar.io/tokenMetadata/2", + }, + ], + }, + }, + ], + }, + }; + await route.fulfill({ json }); + }); + + test.slow(); + await loginToTestAccount({ page, extensionId }); + await page.getByTestId("network-selector-open").click(); + await page.getByText("Main Net").click(); + + // Navigate to collectibles tab + await expect(page.getByTestId("account-view")).toBeVisible(); + await page.getByTestId("account-tab-collectibles").click(); + + // Initially, collection count should be 2 + await expect(page.getByTestId("account-collection-count")).toHaveText("2"); + + // Click on first collectible to open detail view + const collectibleGrid = page.getByTestId("account-collection-grid").first(); + await collectibleGrid.locator("div").first().click(); + + // Wait for detail view + await expect(page.getByTestId("CollectibleDetail")).toBeVisible(); + + // Open menu and hide the collectible + await page.getByTestId("CollectibleDetail__header__right-button").click(); + await page.getByText("Hide collectible").click(); + + // Wait for sheet to close + await expect(page.getByTestId("CollectibleDetail")).not.toBeVisible(); + + // Collection count should now be 1 + await expect(page.getByTestId("account-collection-count")).toHaveText("1"); +}); diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-hide-menu-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-hide-menu-chromium-darwin.png new file mode 100644 index 0000000000..d06edbe438 Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-hide-menu-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-show-menu-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-show-menu-chromium-darwin.png new file mode 100644 index 0000000000..cae1d99b4d Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-show-menu-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-view-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-view-chromium-darwin.png new file mode 100644 index 0000000000..a5bd0b3b4d Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectible-detail-view-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-manage-dropdown-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-manage-dropdown-chromium-darwin.png new file mode 100644 index 0000000000..2ba492a1df Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-manage-dropdown-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-view-before-hide-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-view-before-hide-chromium-darwin.png new file mode 100644 index 0000000000..a8a7a8f2a0 Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/collectibles-view-before-hide-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-empty-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-empty-chromium-darwin.png new file mode 100644 index 0000000000..71e0ebd01f Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-empty-chromium-darwin.png differ diff --git a/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-view-chromium-darwin.png b/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-view-chromium-darwin.png new file mode 100644 index 0000000000..2985fb898e Binary files /dev/null and b/extension/e2e-tests/hideCollectible.test.ts-snapshots/hidden-collectibles-view-chromium-darwin.png differ diff --git a/extension/src/background/messageListener/handlers/changeCollectibleVisibility.ts b/extension/src/background/messageListener/handlers/changeCollectibleVisibility.ts new file mode 100644 index 0000000000..494074fdfe --- /dev/null +++ b/extension/src/background/messageListener/handlers/changeCollectibleVisibility.ts @@ -0,0 +1,21 @@ +import { ChangeCollectibleVisibilityMessage } from "@shared/api/types/message-request"; +import { DataStorageAccess } from "../../helpers/dataStorageAccess"; +import { getHiddenCollectibles } from "./getHiddenCollectibles"; +import { HIDDEN_COLLECTIBLES } from "../../../constants/localStorageTypes"; + +export const changeCollectibleVisibility = async ({ + request, + localStore, +}: { + request: ChangeCollectibleVisibilityMessage; + localStore: DataStorageAccess; +}) => { + const { collectibleVisibility } = request; + + const { hiddenCollectibles } = await getHiddenCollectibles({ localStore }); + hiddenCollectibles[collectibleVisibility.collectible] = + collectibleVisibility.visibility; + + await localStore.setItem(HIDDEN_COLLECTIBLES, hiddenCollectibles); + return { hiddenCollectibles }; +}; diff --git a/extension/src/background/messageListener/handlers/getHiddenCollectibles.ts b/extension/src/background/messageListener/handlers/getHiddenCollectibles.ts new file mode 100644 index 0000000000..dcf55b91e7 --- /dev/null +++ b/extension/src/background/messageListener/handlers/getHiddenCollectibles.ts @@ -0,0 +1,12 @@ +import { DataStorageAccess } from "../../helpers/dataStorageAccess"; +import { HIDDEN_COLLECTIBLES } from "../../../constants/localStorageTypes"; + +export const getHiddenCollectibles = async ({ + localStore, +}: { + localStore: DataStorageAccess; +}) => { + const hiddenCollectibles = + (await localStore.getItem(HIDDEN_COLLECTIBLES)) || {}; + return { hiddenCollectibles }; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 15f12adab6..bf315fcefd 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -78,6 +78,8 @@ import { dismissMobileAppBanner } from "./handlers/dismissMobileAppBanner"; import { loadBackendSettings } from "./handlers/loadBackendSettings"; import { addCollectible } from "./handlers/addCollectible"; import { getCollectibles } from "./handlers/getCollectibles"; +import { changeCollectibleVisibility } from "./handlers/changeCollectibleVisibility"; +import { getHiddenCollectibles } from "./handlers/getHiddenCollectibles"; const numOfPublicKeysToCheck = 5; @@ -496,6 +498,17 @@ export const popupMessageListener = ( localStore, }); } + case SERVICE_TYPES.CHANGE_COLLECTIBLE_VISIBILITY: { + return changeCollectibleVisibility({ + request, + localStore, + }); + } + case SERVICE_TYPES.GET_HIDDEN_COLLECTIBLES: { + return getHiddenCollectibles({ + localStore, + }); + } default: return { error: "Message type not supported" }; diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index a3f1d01a1d..db96beef9c 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -24,6 +24,7 @@ export const IS_NON_SSL_ENABLED_ID = "isNonSSLEnabled"; export const IS_BLOCKAID_ANNOUNCED_ID = "isBlockaidAnnounced"; export const IS_HIDE_DUST_ENABLED_ID = "isHideDustEnabled"; export const HIDDEN_ASSETS = "hiddenAssets"; +export const HIDDEN_COLLECTIBLES = "hiddenCollectibles"; export const TEMPORARY_STORE_ID = "temporaryStore"; export const TEMPORARY_STORE_EXTRA_ID = "temporaryStoreExtra"; export const MOBILE_APP_BANNER_DISMISSED = "mobileAppBannerDismissed"; diff --git a/extension/src/popup/components/__tests__/CollectibleDetail.test.tsx b/extension/src/popup/components/__tests__/CollectibleDetail.test.tsx index db54a97d7d..7cd9473f5a 100644 --- a/extension/src/popup/components/__tests__/CollectibleDetail.test.tsx +++ b/extension/src/popup/components/__tests__/CollectibleDetail.test.tsx @@ -571,4 +571,146 @@ describe("CollectibleDetail", () => { url: "https://nftcalendar.io/external/2", }); }); + it("shows 'This collectible is hidden' notification when isHidden is true", async () => { + render( + + {}} + isHidden={true} + /> + , + ); + await waitFor(() => screen.getByTestId("CollectibleDetail")); + + // Should show the hidden notification + expect(screen.getByText("This collectible is hidden")).toBeDefined(); + }); + it("does not show hidden notification when isHidden is false", async () => { + render( + + {}} + isHidden={false} + /> + , + ); + await waitFor(() => screen.getByTestId("CollectibleDetail")); + + // Should not show the hidden notification + expect(screen.queryByText("This collectible is hidden")).toBeNull(); + }); + it("renders menu button for hide/show functionality", async () => { + render( + + {}} + /> + , + ); + await waitFor(() => screen.getByTestId("CollectibleDetail")); + + // Should have the menu button for additional actions + expect( + screen.getByTestId("CollectibleDetail__header__right-button"), + ).toBeDefined(); + }); }); diff --git a/extension/src/popup/components/__tests__/HiddenCollectibles.test.tsx b/extension/src/popup/components/__tests__/HiddenCollectibles.test.tsx new file mode 100644 index 0000000000..00445f755e --- /dev/null +++ b/extension/src/popup/components/__tests__/HiddenCollectibles.test.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { render, waitFor, screen, fireEvent } from "@testing-library/react"; + +import { HiddenCollectibles } from "popup/components/account/HiddenCollectibles"; +import { + TESTNET_NETWORK_DETAILS, + DEFAULT_NETWORKS, +} from "@shared/constants/stellar"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { ROUTES } from "popup/constants/routes"; +import { + Wrapper, + mockAccounts, + TEST_PUBLIC_KEY, + mockCollectibles, +} from "../../__testHelpers__"; + +const mockRefreshHiddenCollectibles = jest.fn().mockResolvedValue(undefined); + +// Helper to create isCollectibleHidden function based on hiddenCollectibles record +const createIsCollectibleHidden = + (hiddenCollectibles: Record) => + (collectionAddress: string, tokenId: string) => { + const key = `${collectionAddress}:${tokenId}`; + return hiddenCollectibles[key] === "hidden"; + }; + +const defaultState = { + auth: { + error: null, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + publicKey: TEST_PUBLIC_KEY, + allAccounts: mockAccounts, + }, + settings: { + networkDetails: TESTNET_NETWORK_DETAILS, + networksList: DEFAULT_NETWORKS, + isSorobanPublicEnabled: true, + isRpcHealthy: true, + userNotification: { + enabled: false, + message: "", + }, + }, + cache: { + collections: { + [TESTNET_NETWORK_DETAILS.network]: { + [TEST_PUBLIC_KEY]: mockCollectibles, + }, + }, + }, +}; + +describe("HiddenCollectibles", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders empty state when no collectibles are hidden", async () => { + const onClose = jest.fn(); + const hiddenCollectibles = {}; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("No hidden collectibles")).toBeInTheDocument(); + }); + }); + + it("renders hidden collectibles when some are hidden", async () => { + const onClose = jest.fn(); + const hiddenCollectibles = { + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA:1": "hidden", + }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("hidden-collectible-1")).toBeInTheDocument(); + }); + }); + + it("renders multiple hidden collectibles from different collections", async () => { + const onClose = jest.fn(); + const hiddenCollectibles = { + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA:1": "hidden", + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA:2": "hidden", + "CCCSorobanDomainsCollection:102510": "hidden", + }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("hidden-collectible-1")).toBeInTheDocument(); + expect(screen.getByTestId("hidden-collectible-2")).toBeInTheDocument(); + expect( + screen.getByTestId("hidden-collectible-102510"), + ).toBeInTheDocument(); + }); + }); + + it("does not render when isOpen is false", async () => { + const onClose = jest.fn(); + const hiddenCollectibles = {}; + + render( + + + , + ); + + // Should not find the hidden collectibles content + expect(screen.queryByText("Hidden Collectibles")).not.toBeInTheDocument(); + expect( + screen.queryByText("No hidden collectibles"), + ).not.toBeInTheDocument(); + }); + + it("opens collectible detail when clicking on a hidden collectible", async () => { + const onClose = jest.fn(); + const hiddenCollectibles = { + "CAS3J7GYLGXMF6TDJBBYYSE3HW6BBSMLNUQ34T6TZMYMW2EVH34XOWMA:2": "hidden", + }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("hidden-collectible-2")).toBeInTheDocument(); + }); + + // Click on the hidden collectible + fireEvent.click(screen.getByTestId("hidden-collectible-2")); + + // Should open the collectible detail + await waitFor(() => { + expect(screen.getByTestId("CollectibleDetail")).toBeInTheDocument(); + }); + }); +}); diff --git a/extension/src/popup/components/__tests__/ManageAssetRows.test.tsx b/extension/src/popup/components/__tests__/ManageAssetRows.test.tsx index 6f3ec22ef0..5674276ced 100644 --- a/extension/src/popup/components/__tests__/ManageAssetRows.test.tsx +++ b/extension/src/popup/components/__tests__/ManageAssetRows.test.tsx @@ -11,6 +11,9 @@ import * as CheckForSuspiciousAsset from "popup/helpers/checkForSuspiciousAsset" import * as BlockaidHelpers from "popup/helpers/blockaid"; import * as GetManageAssetXDR from "popup/helpers/getManageAssetXDR"; import * as TransactionSubmission from "popup/ducks/transactionSubmission"; +import * as AccountServices from "popup/ducks/accountServices"; +import * as SorobanHelpers from "popup/helpers/soroban"; +import { AssetType } from "@shared/api/types/account-balance"; import TransportWebHID from "@ledgerhq/hw-transport-webhid"; import LedgerApi from "@ledgerhq/hw-app-str"; @@ -763,4 +766,108 @@ describe("ManageAssetRows", () => { expect(resetSubmissionSpy).toHaveBeenCalled(); expect(getAccountBalancesSpy).not.toHaveBeenCalled(); }); + + it("renders change trust internal for SAC assets (searched by contract ID)", async () => { + jest.spyOn(SorobanHelpers, "isAssetSac").mockImplementation(() => true); + const addTokenIdSpy = jest.spyOn(AccountServices, "addTokenId"); + + jest.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + envelope_xdr: + "AAAAAgAAAABngBTmbmUycqG2cAMHcomSR80dRzGtKzxM6gb3yySD5AAPQkAAAYjdAAAA9gAAAAEAAAAAAAAAAAAAAABmXjffAAAAAAAAAAEAAAAAAAAABgAAAAFVU0RDAAAAACYFzNOyHT8GgwiyzcOOhwLtCctwM/RiSnrFp7JOe8xeAAAAAAAAAAAAAAAAAAAAAcskg+QAAABAA/rRMU+KKsxCX1pDBuCvYDz+eQTCsY9bzgPU4J+Xe3vOWUa8YOzWlL3N3zlxHVx9hsB7a8dpSXMSAINjjsY4Dg==", + hash: "hash", + successful: true, + }), + { status: 200 }, + ), + ), + ); + + render( + + + , + ); + + await waitFor(() => screen.getByTestId("ManageAssetRowButton")); + fireEvent.click(screen.getByTestId("ManageAssetRowButton")); + + // Verify ChangeTrustInternal is shown (not addTokenId for SEP-41 tokens) + await waitFor(() => screen.getByTestId("ChangeTrustInternal__Body")); + await waitFor(() => { + expect( + screen.getByTestId("ChangeTrustInternal__TitleRow"), + ).toHaveTextContent("Confirm Transaction"); + expect( + screen.getByTestId("SignTransaction__TrustlineRow__Asset"), + ).toHaveTextContent("USDC"); + }); + + fireEvent.click(screen.getByText("Confirm")); + + await waitFor(() => { + expect( + screen.getByTestId("SubmitTransaction__Title"), + ).toBeInTheDocument(); + }); + + // SAC assets should submit trustline transactions, + // not just add token IDs like SEP-41 tokens do + expect(addTokenIdSpy).not.toHaveBeenCalled(); + }); }); diff --git a/extension/src/popup/components/account/AccountCollectibles/styles.scss b/extension/src/popup/components/account/AccountCollectibles/styles.scss index 26dfb02c87..5492928641 100644 --- a/extension/src/popup/components/account/AccountCollectibles/styles.scss +++ b/extension/src/popup/components/account/AccountCollectibles/styles.scss @@ -6,6 +6,26 @@ gap: pxToRem(24px); padding: pxToRem(24px) 0; + &__toggle-hidden { + display: flex; + align-items: center; + gap: pxToRem(8px); + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + cursor: pointer; + padding: pxToRem(8px) 0; + + &:hover { + color: var(--sds-clr-gray-12); + } + + svg { + width: pxToRem(16px); + height: pxToRem(16px); + } + } + &__empty { display: flex; align-items: center; @@ -50,11 +70,13 @@ font-size: 0; &__item { - background-color: var(--sds-clr-gray-03); cursor: pointer; aspect-ratio: 1/1; - border-radius: pxToRem(12px); overflow: hidden; + + &--hidden { + opacity: 0.5; + } } } } diff --git a/extension/src/popup/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index 3526e1b4f6..56e3e5a9a0 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -40,6 +40,8 @@ interface AccountHeaderProps { }) => Promise; publicKey: string; roundedTotalBalanceUsd: string; + refreshHiddenCollectibles: () => Promise; + isCollectibleHidden: (collectionAddress: string, tokenId: string) => boolean; } export const AccountHeader = ({ @@ -50,6 +52,8 @@ export const AccountHeader = ({ onClickRow, publicKey, roundedTotalBalanceUsd, + refreshHiddenCollectibles, + isCollectibleHidden, }: AccountHeaderProps) => { const { t } = useTranslation(); const networkDetails = useSelector(settingsNetworkDetailsSelector); @@ -399,7 +403,10 @@ export const AccountHeader = ({ : null} - + diff --git a/extension/src/popup/components/account/AccountTabs/index.tsx b/extension/src/popup/components/account/AccountTabs/index.tsx index d11ac51e70..124675888c 100644 --- a/extension/src/popup/components/account/AccountTabs/index.tsx +++ b/extension/src/popup/components/account/AccountTabs/index.tsx @@ -14,11 +14,14 @@ import { Link } from "react-router-dom"; import { TabsList } from "popup/views/Account/contexts/activeTabContext"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { publicKeySelector } from "popup/ducks/accountServices"; +import { collectionsSelector } from "popup/ducks/cache"; import { isCustomNetwork } from "@shared/helpers/stellar"; import { ROUTES } from "popup/constants/routes"; import { LoadingBackground } from "popup/basics/LoadingBackground"; import { AccountHeaderModal } from "../AccountHeaderModal"; +import { HiddenCollectibles } from "../HiddenCollectibles"; import { useActiveTab } from "./hooks/useActiveTab"; import "./styles.scss"; @@ -116,9 +119,15 @@ const ManageAssetsModalContent = () => { * AddCollectiblesModalContent component renders the content for the add collectibles modal, * providing a link to manually add collectibles. * + * @param {Object} props - Component props + * @param {() => void} props.onHiddenCollectiblesClick - Callback when hidden collectibles is clicked * @returns {JSX.Element} Modal content with link to add collectibles route */ -const AddCollectiblesModalContent = () => { +const AddCollectiblesModalContent = ({ + onHiddenCollectiblesClick, +}: { + onHiddenCollectiblesClick: () => void; +}) => { const { t } = useTranslation(); return ( @@ -133,14 +142,18 @@ const AddCollectiblesModalContent = () => { - {/*
-
- -
-
- {t("Hidden collectibles")} -
-
*/} +
+
+ +
+
+ {t("Hidden collectibles")} +
+
); }; @@ -152,16 +165,30 @@ const AddCollectiblesModalContent = () => { * * @returns {JSX.Element} Account tabs component with navigation and management modals */ -export const AccountTabs = () => { +export const AccountTabs = ({ + refreshHiddenCollectibles, + isCollectibleHidden, +}: { + refreshHiddenCollectibles: () => Promise; + isCollectibleHidden: (collectionAddress: string, tokenId: string) => boolean; +}) => { const [isManageAssetsOpen, setIsManageAssetsOpen] = useState(false); const [isAddCollectiblesOpen, setIsAddCollectiblesOpen] = useState(false); + const [isHiddenCollectiblesOpen, setIsHiddenCollectiblesOpen] = + useState(false); const isBackgroundActive = isManageAssetsOpen || isAddCollectiblesOpen; const { activeTab } = useActiveTab(); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const publicKey = useSelector(publicKeySelector); + const collections = useSelector(collectionsSelector); const isTokensTab = activeTab === TabsList.TOKENS; const isCollectiblesTab = activeTab === TabsList.COLLECTIBLES; + const currentCollections = + collections[networkDetails?.network || ""]?.[publicKey || ""] || []; + /** * Handles the click event on the manage button, toggling the appropriate modal * based on the currently active tab. @@ -174,6 +201,15 @@ export const AccountTabs = () => { } }; + /** + * Handles the hidden collectibles button click, opening the hidden collectibles sheet + * and closing the dropdown modal. + */ + const handleHiddenCollectiblesClick = () => { + setIsAddCollectiblesOpen(false); + setIsHiddenCollectiblesOpen(true); + }; + return (
@@ -195,10 +231,22 @@ export const AccountTabs = () => { > <> {isTokensTab && } - {isCollectiblesTab && } + {isCollectiblesTab && ( + + )} + setIsHiddenCollectiblesOpen(false)} + /> + {isBackgroundActive ? createPortal( void; + isHidden?: boolean; }) => { const { t } = useTranslation(); const publicKey = useSelector(publicKeySelector); @@ -156,6 +158,11 @@ export const CollectibleDetail = ({ ) : ( + {isHidden && ( + + {t("This collectible is hidden")} + + )}
-
- -
+ {image !== undefined && ( +
+ +
+ )} Promise; + isCollectibleHidden: (collectionAddress: string, tokenId: string) => boolean; + isOpen: boolean; + onClose: () => void; +} + +export const HiddenCollectibles = ({ + collections, + refreshHiddenCollectibles, + isCollectibleHidden, + isOpen, + onClose, +}: HiddenCollectiblesProps) => { + const { t } = useTranslation(); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [detailData, setDetailData] = useState( + null, + ); + + const handleOpenCollectible = (collectible: SelectedCollectible) => { + setDetailData(collectible); + setIsDetailOpen(true); + }; + + // Refresh hidden collectibles when sheet opens + useEffect(() => { + if (isOpen) { + refreshHiddenCollectibles(); + } + }, [isOpen, refreshHiddenCollectibles]); + + const handleCloseCollectible = () => { + setIsDetailOpen(false); + // Refresh the shared state - this will update all components using the hook + refreshHiddenCollectibles(); + }; + + const handleAnimationEnd = () => { + if (!isDetailOpen) { + setDetailData(null); + } + }; + + // Get all hidden collectibles across all collections + const hiddenItems: Array<{ + collection: Collection["collection"]; + tokenId: string; + metadata: { image?: string; name?: string }; + }> = []; + + collections.forEach(({ collection, error }) => { + if (error || !collection) return; + + collection.collectibles.forEach((item) => { + if (isCollectibleHidden(collection.address, item.tokenId)) { + hiddenItems.push({ + collection, + tokenId: item.tokenId, + metadata: item.metadata || {}, + }); + } + }); + }); + + const hasHiddenCollectibles = hiddenItems.length > 0; + + return ( + <> + {/* Main Hidden Collectibles Sheet */} + !open && onClose()}> + e.preventDefault()} + > + + {t("Hidden Collectibles")} + + + } + /> + + {hasHiddenCollectibles ? ( +
+ {hiddenItems.map((item) => ( +
{ + handleOpenCollectible({ + collectionAddress: item.collection?.address || "", + tokenId: item.tokenId, + }); + }} + key={`${item.collection?.address}:${item.tokenId}`} + data-testid={`hidden-collectible-${item.tokenId}`} + > + +
+ +
+
+ ))} +
+ ) : ( +
+ + {t("No hidden collectibles")} +
+ )} +
+
+
+
+ + {/* Collectible Detail Sheet - overlays on top */} + { + if (!open) { + handleCloseCollectible(); + } + }} + > + e.preventDefault()} + onAnimationEnd={handleAnimationEnd} + style={{ zIndex: 100 }} + > + + {detailData?.tokenId || ""} + + {detailData && ( + + )} + + + + ); +}; diff --git a/extension/src/popup/components/account/HiddenCollectibles/styles.scss b/extension/src/popup/components/account/HiddenCollectibles/styles.scss new file mode 100644 index 0000000000..c6ad5d7ca0 --- /dev/null +++ b/extension/src/popup/components/account/HiddenCollectibles/styles.scss @@ -0,0 +1,78 @@ +@use "../../../styles/utils.scss" as *; + +.HiddenCollectibles { + &__sheet { + height: 100%; + max-height: 100vh; + border-radius: 0; + + .View { + background-color: var(--sds-clr-gray-01); + } + } + + &__grid { + display: grid; + gap: pxToRem(12px); + grid-template-columns: repeat(2, 1fr); + padding-bottom: pxToRem(24px); + + &__item { + position: relative; + background-color: var(--sds-clr-gray-03); + cursor: pointer; + aspect-ratio: 1/1; + border-radius: pxToRem(12px); + overflow: hidden; + + &__overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.4); + pointer-events: none; + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + color: var(--sds-clr-gray-12); + stroke: var(--sds-clr-gray-12); + stroke-width: 1.875px; + } + } + } + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: pxToRem(8px); + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + text-align: center; + padding: pxToRem(48px) 0; + + svg { + width: pxToRem(24px); + height: pxToRem(24px); + } + } + + &__collectible-detail__sheet { + // Must override the z-50 from Sheet component to overlay on top of the first sheet + // This class is applied to both the overlay and content by SheetContent + &[data-slot="sheet-overlay"], + &[data-slot="sheet-content"] { + z-index: 100 !important; + } + } +} diff --git a/extension/src/popup/components/account/hooks/useHiddenCollectibles.ts b/extension/src/popup/components/account/hooks/useHiddenCollectibles.ts new file mode 100644 index 0000000000..69ea8eafaa --- /dev/null +++ b/extension/src/popup/components/account/hooks/useHiddenCollectibles.ts @@ -0,0 +1,49 @@ +import { useState, useEffect, useCallback } from "react"; +import { useSelector } from "react-redux"; + +import { CollectibleKey } from "@shared/api/types/types"; +import { getHiddenCollectibles } from "@shared/api/internal"; +import { publicKeySelector } from "popup/ducks/accountServices"; + +export const useHiddenCollectibles = () => { + const publicKey = useSelector(publicKeySelector); + const [hiddenCollectibles, setHiddenCollectibles] = useState< + Record + >({}); + + const refreshHiddenCollectibles = useCallback(async () => { + if (!publicKey) { + setHiddenCollectibles({}); + return; + } + + try { + const { hiddenCollectibles: hidden } = await getHiddenCollectibles({ + activePublicKey: publicKey, + }); + setHiddenCollectibles(hidden || {}); + } catch (error) { + console.error("Failed to fetch hidden collectibles:", error); + setHiddenCollectibles({}); + } + }, [publicKey]); + + // Fetch on mount + useEffect(() => { + refreshHiddenCollectibles(); + }, [refreshHiddenCollectibles]); + + const isCollectibleHidden = useCallback( + (collectionAddress: string, tokenId: string) => { + const key = `${collectionAddress}:${tokenId}`; + return hiddenCollectibles[key] === "hidden"; + }, + [hiddenCollectibles], + ); + + return { + hiddenCollectibles, + refreshHiddenCollectibles, + isCollectibleHidden, + }; +}; diff --git a/extension/src/popup/components/manageAssets/AddAsset/index.tsx b/extension/src/popup/components/manageAssets/AddAsset/index.tsx index aef4b2fc6f..3aec6a65fc 100644 --- a/extension/src/popup/components/manageAssets/AddAsset/index.tsx +++ b/extension/src/popup/components/manageAssets/AddAsset/index.tsx @@ -145,6 +145,7 @@ export const AddAsset = () => { const issuer = isSacContract ? tokenDetailsResponse.name.split(":")[1] || "" : contractId; // get the issuer name, if applicable , + const scannedAsset = await scanAsset( `${tokenDetailsResponse.symbol}-${issuer}`, networkDetails, diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx index f8f1ed09db..bb34f54b2f 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/SubmitTx/index.tsx @@ -28,6 +28,7 @@ import { AssetIcons } from "@shared/api/types"; import { removeTokenId, startHwSign } from "popup/ducks/transactionSubmission"; import { NETWORKS } from "@shared/constants/stellar"; import { useGetChangeTrust } from "../hooks/useChangeTrust"; +import { isAssetSac } from "popup/helpers/soroban"; import "./styles.scss"; import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; @@ -74,7 +75,18 @@ export const SubmitTransaction = ({ useEffect(() => { const getData = async () => { - if (asset.contract) { + const isSac = isAssetSac({ + asset: { + code: asset.code, + issuer: asset.issuer, + contract: asset.contract, + }, + networkDetails, + }); + + // For SEP-41 tokens, just add/remove the token ID + // For SACs and classic assets, we need to submit a trustline transaction + if (asset.contract && !isSac) { if (addTrustline) { await dispatch( addTokenId({ @@ -92,6 +104,7 @@ export const SubmitTransaction = ({ ); } } else { + // Classic asset or SAC - submit trustline transaction const server = stellarSdkServer( networkDetails.networkUrl, networkDetails.networkPassphrase, diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx index fc214fdb84..69f3aa7cad 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrustData.tsx @@ -61,6 +61,7 @@ function useGetChangeTrustData({ }, networkDetails, }); + if (!asset.contract || isSac) { const server = stellarSdkServer( networkDetails.networkUrl, diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index bd4c164627..80c776eb31 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -240,6 +240,8 @@ "Got it": "Got it", "Hash": "Hash", "Help": "Help", + "Hidden collectibles": "Hidden collectibles", + "Hidden Collectibles": "Hidden Collectibles", "Hide collectible": "Hide collectible", "Hide payments smaller than 0.1 XLM": "Hide payments smaller than 0.1 XLM", "Hide small payments": "Hide small payments", @@ -358,6 +360,7 @@ "No connected apps found": "No connected apps found", "No description available": "No description available", "No device detected.": "No device detected.", + "No hidden collectibles": "No hidden collectibles", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", "No transactions to show": "No transactions to show", "None": "None", @@ -459,6 +462,7 @@ "Share feedback": "Share feedback", "Share feedback via form": "Share feedback via form", "Should be benign": "Should be benign", + "Show collectible": "Show collectible", "Show recovery phrase": "Show recovery phrase", "Signed Payload": "Signed Payload", "Signer": "Signer", @@ -533,6 +537,7 @@ "This asset was flagged as spam": "This asset was flagged as spam", "This asset was flagged as suspicious": "This asset was flagged as suspicious", "This can be used to sign arbitrary transaction hashes without having to decode them first.": "This can be used to sign arbitrary transaction hashes without having to decode them first.", + "This collectible is hidden": "This collectible is hidden", "This is not a valid contract id.": "This is not a valid contract id.", "This setting enables access to the Futurenet network and disables access to Pubnet.": "This setting enables access to the Futurenet network and disables access to Pubnet.", "This site was flagged as malicious": "This site was flagged as malicious", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 3bc0d9b170..ec33aefd8a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -240,6 +240,8 @@ "Got it": "Entendi", "Hash": "Hash", "Help": "Ajuda", + "Hidden collectibles": "Hidden collectibles", + "Hidden Collectibles": "Hidden Collectibles", "Hide collectible": "Ocultar colecionável", "Hide payments smaller than 0.1 XLM": "Ocultar pagamentos menores que 0.1 XLM", "Hide small payments": "Ocultar pagamentos pequenos", @@ -360,6 +362,7 @@ "No connected apps found": "Nenhum app conectado encontrado", "No description available": "Nenhuma descrição disponível", "No device detected.": "Nenhum dispositivo detectado.", + "No hidden collectibles": "No hidden collectibles", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", @@ -536,6 +539,7 @@ "This asset was flagged as spam": "Este ativo foi marcado como spam", "This asset was flagged as suspicious": "Este ativo foi marcado como suspeito", "This can be used to sign arbitrary transaction hashes without having to decode them first.": "Isso pode ser usado para assinar hashes de transação arbitrários sem precisar decodificá-los primeiro.", + "This collectible is hidden": "This collectible is hidden", "This is not a valid contract id.": "Este não é um ID de contrato válido.", "This setting enables access to the Futurenet network and disables access to Pubnet.": "Esta configuração permite acesso à rede Futurenet e desabilita o acesso ao Pubnet.", "This site was flagged as malicious": "Este site foi marcado como malicioso", diff --git a/extension/src/popup/styles/global.scss b/extension/src/popup/styles/global.scss index 858b31de84..f34748b3dc 100644 --- a/extension/src/popup/styles/global.scss +++ b/extension/src/popup/styles/global.scss @@ -81,7 +81,7 @@ a { // TODO: fix in SDS .Notification { - border-radius: 0.25rem; + border-radius: 0.5rem; } // TODO: Update in SDS diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 42d3958525..b6d318d0e7 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -18,6 +18,7 @@ import { isMainnet } from "helpers/stellar"; import { AccountAssets } from "popup/components/account/AccountAssets"; import { AccountCollectibles } from "popup/components/account/AccountCollectibles"; import { AccountHeader } from "popup/components/account/AccountHeader"; +import { useHiddenCollectibles } from "popup/components/account/hooks/useHiddenCollectibles"; import { Loading } from "popup/components/Loading"; import { NotFundedMessage } from "popup/components/account/NotFundedMessage"; import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; @@ -62,6 +63,8 @@ export const Account = () => { useGetAccountHistoryData(); const { state: iconsData, fetchData: fetchIconsData } = useGetIcons(); + const { refreshHiddenCollectibles, isCollectibleHidden } = + useHiddenCollectibles(); const previousAccountBalancesRef = useRef(null); @@ -184,6 +187,8 @@ export const Account = () => { }} roundedTotalBalanceUsd={roundedTotalBalanceUsd} isFunded={!!resolvedData?.balances?.isFunded} + refreshHiddenCollectibles={refreshHiddenCollectibles} + isCollectibleHidden={isCollectibleHidden} />
diff --git a/extension/src/popup/views/Account/styles.scss b/extension/src/popup/views/Account/styles.scss index a51ade8822..ac4eecb7b9 100644 --- a/extension/src/popup/views/Account/styles.scss +++ b/extension/src/popup/views/Account/styles.scss @@ -5,6 +5,7 @@ } .AccountView { + height: 100%; &__account-details { display: flex; flex-direction: column; diff --git a/extension/src/popup/views/SignTransaction/hooks/__tests__/useGetSignTxData.test.tsx b/extension/src/popup/views/SignTransaction/hooks/__tests__/useGetSignTxData.test.tsx index a4262ea5c4..c485287f28 100644 --- a/extension/src/popup/views/SignTransaction/hooks/__tests__/useGetSignTxData.test.tsx +++ b/extension/src/popup/views/SignTransaction/hooks/__tests__/useGetSignTxData.test.tsx @@ -299,4 +299,57 @@ describe("useGetSignTxData", () => { }); expect(result.current.state.state).toBe(RequestState.SUCCESS); }); + + it("handles balance fetch failure gracefully and returns success with undefined balances", async () => { + jest.spyOn(GetBalancesHooks, "useGetBalances").mockReturnValue({ + fetchData: () => Promise.reject(new Error("Failed to fetch balances")), + state: { + state: RequestState.IDLE, + data: null, + error: null, + }, + } as ReturnType); + + jest.spyOn(BlockaidHelpers, "useScanTx").mockReturnValue({ + data: null, + error: null, + isLoading: false, + setLoading: jest.fn(), + scanTx: () => + Promise.resolve({ + simulation: null, + validation: null, + request_id: "1", + }), + } as ReturnType); + + const { result } = renderHook( + () => + useGetSignTxData( + { + xdr: setOptionsTx, + url: "https://example.com", + }, + { + showHidden: false, + includeIcons: false, + }, + "G123", + ), + { wrapper: Wrapper(store) }, + ); + + await act(async () => { + await result.current.fetchData(); + }); + + // Should still succeed but with null balances + expect(result.current.state.state).toBe(RequestState.SUCCESS); + expect( + (result.current.state.data as { balances: unknown })?.balances, + ).toBeNull(); + expect((result.current.state.data as { type: string })?.type).toBe( + AppDataType.RESOLVED, + ); + }); }); diff --git a/extension/src/popup/views/SignTransaction/hooks/useGetSignTxData.tsx b/extension/src/popup/views/SignTransaction/hooks/useGetSignTxData.tsx index c19675fcd4..1fe40efd5c 100644 --- a/extension/src/popup/views/SignTransaction/hooks/useGetSignTxData.tsx +++ b/extension/src/popup/views/SignTransaction/hooks/useGetSignTxData.tsx @@ -28,7 +28,7 @@ import { AssetListResponse } from "@shared/constants/soroban/asset-list"; export interface ResolvedData { type: AppDataType.RESOLVED; scanResult: BlockAidScanTxResult | null; - balances: AccountBalances; + balances: AccountBalances | null; publicKey: string; signFlowState: { allAccounts: Account[]; @@ -87,13 +87,24 @@ function useGetSignTxData( const allAccounts = appData.account.allAccounts; const networkDetails = appData.settings.networkDetails; const isMainnetNetwork = isMainnet(networkDetails); - const balancesResult = await fetchBalances( - publicKey, - isMainnetNetwork, - networkDetails, - false, - true, - ); + + // Fetch balances with soft failure handling - if this fails, we continue + // without balance data (balance-related warnings will be skipped) + let balancesResult: AccountBalances | null = null; + try { + const fetchResult = await fetchBalances( + publicKey, + isMainnetNetwork, + networkDetails, + false, + true, + ); + if (!isError(fetchResult)) { + balancesResult = fetchResult; + } + } catch { + // Balance fetch failed - continue without balance data + } // handle auto selecting the right account based on `accountToSign` const currentAccount = signFlowAccountSelector({ @@ -244,10 +255,6 @@ function useGetSignTxData( } } - if (isError(balancesResult)) { - throw new Error(balancesResult.message); - } - const payload = { type: AppDataType.RESOLVED, balances: balancesResult, diff --git a/extension/src/popup/views/SignTransaction/index.tsx b/extension/src/popup/views/SignTransaction/index.tsx index c703aab1b5..b04a6095ed 100644 --- a/extension/src/popup/views/SignTransaction/index.tsx +++ b/extension/src/popup/views/SignTransaction/index.tsx @@ -265,12 +265,16 @@ export const SignTransaction = () => { const { currentAccount } = signTxState.data?.signFlowState!; - const hasEnoughXlm = signTxState.data?.balances.balances.some( - (balance) => - "token" in balance && - balance.token.code === "XLM" && - (balance as NativeAsset).available.gt(stroopToXlm(_fee as string)), - ); + // Check if user has enough XLM for the fee - skip warning if balances unavailable + const balances = signTxState.data?.balances; + const hasEnoughXlm = balances + ? balances.balances.some( + (balance) => + "token" in balance && + balance.token.code === "XLM" && + (balance as NativeAsset).available.gt(stroopToXlm(_fee as string)), + ) + : true; // If balances unavailable, assume user can proceed if ( currentAccount.publicKey && diff --git a/extension/src/popup/views/__tests__/SignTransaction.test.tsx b/extension/src/popup/views/__tests__/SignTransaction.test.tsx index 23f2ebf1ce..80327792b0 100644 --- a/extension/src/popup/views/__tests__/SignTransaction.test.tsx +++ b/extension/src/popup/views/__tests__/SignTransaction.test.tsx @@ -198,7 +198,7 @@ describe("SignTransactions", () => { applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, networkDetails: { ...defaultSettingsState.networkDetails, - networkPassphrase: "Test SDF Future Network ; October 2022", + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, }, }, error: null, @@ -261,7 +261,7 @@ describe("SignTransactions", () => { isExperimentalModeEnabled: true, networkDetails: { ...defaultSettingsState.networkDetails, - networkPassphrase: "Test SDF Future Network ; October 2022", + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, }, hiddenAssets: {}, }, @@ -533,7 +533,7 @@ describe("SignTransactions", () => { applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, networkDetails: { ...defaultSettingsState.networkDetails, - networkPassphrase: "Test SDF Future Network ; October 2022", + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, }, }, error: null, @@ -562,7 +562,7 @@ describe("SignTransactions", () => { Promise.resolve({ networkDetails: { ...defaultSettingsState.networkDetails, - networkPassphrase: "Test SDF Future Network ; October 2022", + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, }, networksList: DEFAULT_NETWORKS, hiddenAssets: {}, @@ -614,7 +614,7 @@ describe("SignTransactions", () => { isExperimentalModeEnabled: true, networkDetails: { ...defaultSettingsState.networkDetails, - networkPassphrase: "Test SDF Future Network ; October 2022", + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, }, }, }} @@ -1089,4 +1089,119 @@ describe("SignTransactions", () => { expect(screen.queryByTestId("blockaid-miss-label")).toBeNull(); expect(screen.queryByTestId("blockaid-malicious-label")).toBeNull(); }); + + it("renders sign transaction view without insufficient balance warning when balances are unavailable", async () => { + let currentSignTxDataMock = { + state: { + state: RequestState.SUCCESS, + data: { + type: AppDataType.RESOLVED, + scanResult: { + simulation: null, + validation: null, + request_id: "1", + }, + icons: {}, + balances: null, // Balances unavailable due to fetch failure + publicKey: mockAccounts[1].publicKey, + signFlowState: { + allAccounts: mockAccounts, + accountNotFound: false, + currentAccount: mockAccounts[0], + }, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + networkDetails: { + ...defaultSettingsState.networkDetails, + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, + }, + }, + error: null, + }, + fetchData: jest.fn(), + } as ReturnType; + jest.spyOn(SigningFlowHooks, "useSetupSigningFlow").mockReturnValue({ + isConfirming: false, + isHardwareWallet: false, + isPasswordRequired: false, + handleApprove: jest.fn(), + hwStatus: ShowOverlayStatus.IDLE, + rejectAndClose: jest.fn(), + setIsPasswordRequired: jest.fn(), + verifyPasswordThenSign: jest.fn(), + hardwareWalletType: WalletType.LEDGER, + }); + jest + .spyOn(SignTxDataHooks, "useGetSignTxData") + .mockReturnValue(currentSignTxDataMock); + + jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => + Promise.resolve({ + networkDetails: { + ...defaultSettingsState.networkDetails, + networkPassphrase: FUTURENET_NETWORK_DETAILS.networkPassphrase, + }, + networksList: DEFAULT_NETWORKS, + hiddenAssets: {}, + allowList: ApiInternal.DEFAULT_ALLOW_LIST, + error: "", + isDataSharingAllowed: false, + isMemoValidationEnabled: false, + isHideDustEnabled: true, + settingsState: SettingsState.SUCCESS, + isSorobanPublicEnabled: false, + isRpcHealthy: true, + userNotification: { + enabled: false, + message: "", + }, + isExperimentalModeEnabled: false, + isHashSigningEnabled: false, + isNonSSLEnabled: false, + experimentalFeaturesState: SettingsState.SUCCESS, + assetsLists: DEFAULT_ASSETS_LISTS, + }), + ); + + const transaction = TransactionBuilder.fromXDR( + transactions.classic, + Networks.PUBLIC, + ) as Transaction, Operation.InvokeHostFunction[]>; + const op = transaction.operations[0]; + jest.spyOn(Stellar, "getTransactionInfo").mockImplementation(() => ({ + ...mockTransactionInfo, + transactionXdr: transactions.classic, + transaction: { + ...mockTransactionInfo.transaction, + _networkPassphrase: Networks.FUTURENET, + _operations: [op], + }, + isHttpsDomain: false, + })); + + render( + + + , + ); + + // Should render the sign transaction view without crashing + await waitFor(() => screen.getByTestId("SignTransaction")); + // Should NOT show the insufficient balance warning when balances are unavailable + expect(screen.queryByTestId("InsufficientBalanceWarning")).toBeNull(); + }); });