Skip to content

Commit a4c76cb

Browse files
authored
fix: Show pending revocation on click (#38184)
## **Description** This PR improves the user experience when revoking Gator permissions by adding optimistic UI feedback. **What is the reason for the change?** When users clicked the "Revoke" button for a Gator permission, there was a noticeable delay before the button appeared disabled and showed "Pending..." text. This created a poor user experience where users might click multiple times, unsure if their action was registered. **What is the improvement/solution?** The solution implements optimistic UI feedback by: 1. Immediately setting a pending state when the revoke button is clicked 2. Disabling the button and displaying "Pending..." text right away 3. Managing the pending state with a 800ms timeout after the revoke operation completes to prevent visual flashing before the transaction confirmation window appears 4. Properly cleaning up timeouts on component unmount to prevent memory leaks 5. Immediately clearing the pending state if an error occurs The implementation uses local component state (`pendingRevokeClicks`) combined with the existing global pending revocations selector to provide a smooth, responsive user experience. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/38184?quickstart=1) ## **Changelog** CHANGELOG entry: Improved responsiveness of revoke button in Gator permissions by adding immediate UI feedback ## **Manual testing steps** 1. Go to all permissions 2. Find a permission 3. Click revoke -> the button should turn to pending revocation right away, before the revoke transaction window shows. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/7e111d41-3857-44a0-84ea-7569af0f9e01 ### **After** https://github.com/user-attachments/assets/5c222555-8f0d-4dc5-b70e-247350024cdd <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds optimistic pending state for Gator permission revocations, disabling the button and showing "Pending..." immediately with timeout cleanup. > > - **UI — Gator Permissions** > - `review-permissions/review-gator-permissions-page.tsx`: > - Introduces local pending state via `pendingRevokeClicks` (Set) and `revokeTimeoutsRef` (Map) with unmount cleanup. > - Updates `handleRevokeClick` to optimistically set pending, call `revokeGatorPermission`, then clear after 800ms; clears immediately on error. > - Passes `hasRevokeBeenClicked` to children; simplifies empty-state check to `gatorPermissions.length`. > - `components/review-gator-permission-item.tsx`: > - Adds optional `hasRevokeBeenClicked` prop and integrates it into `isPendingRevocation` alongside global `pendingRevocations`. > - Disables Revoke button and switches label to pending when either local or global pending is true. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f675d88. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 718bbf7 commit a4c76cb

File tree

2 files changed

+98
-31
lines changed

2 files changed

+98
-31
lines changed

ui/components/multichain/pages/gator-permissions/components/review-gator-permission-item.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ type ReviewGatorPermissionItemProps = {
7373
* The function to call when the revoke is clicked
7474
*/
7575
onRevokeClick: () => void;
76+
77+
/**
78+
* Whether this permission has a pending revoke click (temporary UI state)
79+
*/
80+
hasRevokeBeenClicked?: boolean;
7681
};
7782

7883
type PermissionExpandedDetails = Record<
@@ -102,6 +107,7 @@ export const ReviewGatorPermissionItem = ({
102107
networkName,
103108
gatorPermission,
104109
onRevokeClick,
110+
hasRevokeBeenClicked = false,
105111
}: ReviewGatorPermissionItemProps) => {
106112
const t = useI18nContext();
107113
const { permissionResponse, siteOrigin } = gatorPermission;
@@ -151,10 +157,13 @@ export const ReviewGatorPermissionItem = ({
151157
}, [tokensByChain, chainId, tokenAddress, nativeTokenMetadata]);
152158

153159
const isPendingRevocation = useMemo(() => {
154-
return pendingRevocations.some(
155-
(revocation) => revocation.permissionContext === permissionContext,
160+
return (
161+
hasRevokeBeenClicked ||
162+
pendingRevocations.some(
163+
(revocation) => revocation.permissionContext === permissionContext,
164+
)
156165
);
157-
}, [pendingRevocations, permissionContext]);
166+
}, [pendingRevocations, permissionContext, hasRevokeBeenClicked]);
158167

159168
/**
160169
* Handles the click event for the expand/collapse button

ui/components/multichain/pages/gator-permissions/review-permissions/review-gator-permissions-page.tsx

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useEffect, useMemo, useState } from 'react';
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
28
import { useNavigate, useParams } from 'react-router-dom-v5-compat';
39
import { useSelector } from 'react-redux';
410
import { Hex } from '@metamask/utils';
@@ -65,7 +71,38 @@ export const ReviewGatorPermissionsPage = ({
6571
const [, evmNetworks] = useSelector(
6672
getMultichainNetworkConfigurationsByChainId,
6773
);
68-
const [totalGatorPermissions, setTotalGatorPermissions] = useState(0);
74+
const [pendingRevokeClicks, setPendingRevokeClicks] = useState<Set<string>>(
75+
new Set(),
76+
);
77+
const revokeTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
78+
new Map(),
79+
);
80+
81+
// Cleanup all pending timeouts on unmount
82+
useEffect(() => {
83+
const timeouts = revokeTimeoutsRef.current;
84+
return () => {
85+
timeouts.forEach((timeout) => clearTimeout(timeout));
86+
timeouts.clear();
87+
};
88+
}, []);
89+
90+
// Helper functions for managing pending state
91+
const addPendingContext = useCallback((context: string) => {
92+
setPendingRevokeClicks((prev) => {
93+
const next = new Set(prev);
94+
next.add(context);
95+
return next;
96+
});
97+
}, []);
98+
99+
const removePendingContext = useCallback((context: string) => {
100+
setPendingRevokeClicks((prev) => {
101+
const next = new Set(prev);
102+
next.delete(context);
103+
return next;
104+
});
105+
}, []);
69106

70107
const networkName: string = useMemo(() => {
71108
const networkNameKey = extractNetworkName(evmNetworks, chainId as Hex);
@@ -100,39 +137,60 @@ export const ReviewGatorPermissionsPage = ({
100137
chainId: chainId as Hex,
101138
});
102139

103-
useEffect(() => {
104-
setTotalGatorPermissions(gatorPermissions.length);
105-
}, [chainId, gatorPermissions]);
140+
const handleRevokeClick = useCallback(
141+
async (
142+
permission: StoredGatorPermissionSanitized<
143+
Signer,
144+
PermissionTypesWithCustom
145+
>,
146+
) => {
147+
const { context } = permission.permissionResponse;
106148

107-
const handleRevokeClick = async (
108-
permission: StoredGatorPermissionSanitized<
109-
Signer,
110-
PermissionTypesWithCustom
111-
>,
112-
) => {
113-
try {
114-
await revokeGatorPermission(permission);
115-
} catch (error) {
116-
console.error('Error revoking gator permission:', error);
117-
}
118-
};
149+
// Set pending state immediately to disable button and show "Pending..." text
150+
addPendingContext(context);
151+
152+
try {
153+
await revokeGatorPermission(permission);
154+
155+
// Delay clearing to prevent visual flash before transaction window shows
156+
const timeoutId = setTimeout(() => {
157+
removePendingContext(context);
158+
revokeTimeoutsRef.current.delete(context);
159+
}, 800); // 800ms delay to prevent visual flash before transaction window shows
160+
161+
revokeTimeoutsRef.current.set(context, timeoutId);
162+
} catch (error) {
163+
console.error('Error revoking gator permission:', error);
164+
165+
// Clean up any pending timeout
166+
const existingTimeout = revokeTimeoutsRef.current.get(context);
167+
clearTimeout(existingTimeout);
168+
revokeTimeoutsRef.current.delete(context);
169+
170+
// Clear pending state immediately on error
171+
removePendingContext(context);
172+
}
173+
},
174+
[revokeGatorPermission, addPendingContext, removePendingContext],
175+
);
119176

120177
const renderGatorPermissions = (
121178
permissions: StoredGatorPermissionSanitized<
122179
Signer,
123180
PermissionTypesWithCustom
124181
>[],
125182
) =>
126-
permissions.map((permission) => {
127-
return (
128-
<ReviewGatorPermissionItem
129-
key={`${permission.siteOrigin}-${permission.permissionResponse.context}`}
130-
networkName={networkName}
131-
gatorPermission={permission}
132-
onRevokeClick={() => handleRevokeClick(permission)}
133-
/>
134-
);
135-
});
183+
permissions.map((permission) => (
184+
<ReviewGatorPermissionItem
185+
key={`${permission.siteOrigin}-${permission.permissionResponse.context}`}
186+
networkName={networkName}
187+
gatorPermission={permission}
188+
onRevokeClick={() => handleRevokeClick(permission)}
189+
hasRevokeBeenClicked={pendingRevokeClicks.has(
190+
permission.permissionResponse.context,
191+
)}
192+
/>
193+
));
136194

137195
return (
138196
<Page
@@ -162,7 +220,7 @@ export const ReviewGatorPermissionsPage = ({
162220
</Text>
163221
</Header>
164222
<Content padding={0}>
165-
{totalGatorPermissions > 0 ? (
223+
{gatorPermissions.length > 0 ? (
166224
renderGatorPermissions(gatorPermissions)
167225
) : (
168226
<Box

0 commit comments

Comments
 (0)