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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions pages/popup/src/Popup.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import { withErrorBoundary, withSuspense } from '@extension/shared';
import { Button, Flex, Spinner, Text } from '@chakra-ui/react';
import EventsViewer from './components/Events';

const Popup = () => {
return (
<div>
<EventsViewer></EventsViewer>
<EventsViewer />
</div>
);
};

export default withErrorBoundary(withSuspense(Popup, <div> Loading ... </div>), <div> Error Occur </div>);
const LoadingFallback = (
<Flex direction="column" align="center" justify="center" minH="200px" gap={3}>
<Spinner />
<Text fontSize="sm" opacity={0.7}>
Loading...
</Text>
</Flex>
);

const ErrorFallback = (
<Flex direction="column" align="center" justify="center" minH="200px" gap={3} p={4}>
<Text fontWeight="bold">Something went wrong</Text>
<Text fontSize="sm" opacity={0.7} textAlign="center">
The approval window hit an unexpected error. You can close this window and retry the request in your dapp.
</Text>
<Button size="sm" onClick={() => window.close()} mt={2}>
Close
</Button>
</Flex>
);

export default withErrorBoundary(withSuspense(Popup, LoadingFallback), ErrorFallback);
164 changes: 95 additions & 69 deletions pages/popup/src/components/Events.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,122 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Box, Spinner } from '@chakra-ui/react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Box, Spinner, Flex, Text } from '@chakra-ui/react';
import { requestStorage } from '@extension/storage';
import Transaction from './Transaction';

// Events older than this are treated as abandoned and dropped on load.
const MAX_EVENT_AGE_MINUTES = 10;
// How long to show the empty state before auto-closing the popup. Long enough
// for the post-sign "signature_complete" → cleanup → Transaction.tsx window.close()
// to settle, short enough that a stuck/no-event popup doesn't linger.
const EMPTY_STATE_AUTO_CLOSE_MS = 3000;

const EventsViewer = () => {
const [events, setEvents] = useState<any[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState<boolean>(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const autoCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Function to calculate the age of the event in minutes
const getEventAgeInMinutes = (timestamp: string) => {
const eventTime = new Date(timestamp).getTime();
const currentTime = Date.now();
const ageInMinutes = (currentTime - eventTime) / 60000; // Convert milliseconds to minutes
return ageInMinutes;
};

// Optimized event fetching to prevent endless loops
const fetchEvents = useCallback(async () => {
setLoading(true); // Show spinner while fetching events
const storedEvents = await requestStorage.getEvents();
const validEvents = [];
try {
const storedEvents = (await requestStorage.getEvents()) || [];
const now = Date.now();
const valid: any[] = [];

for (const event of storedEvents) {
const ageInMinutes = getEventAgeInMinutes(event.timestamp);
if (ageInMinutes <= 10) {
validEvents.push(event); // Keep events that are within 10 minutes
} else {
await requestStorage.removeEventById(event.id); // Remove events older than 10 minutes
for (const event of storedEvents) {
const ageMs = now - new Date(event.timestamp).getTime();
if (ageMs <= MAX_EVENT_AGE_MINUTES * 60_000) {
valid.push(event);
} else {
// Fire-and-forget; don't block the fetch on cleanup.
void requestStorage.removeEventById(event.id);
}
}
}

// Set the valid events and reverse them to show latest first
setEvents(validEvents.reverse());
setLoading(false); // Stop spinner after events are loaded

// If no events are found, close the window
// if (validEvents.length === 0) {
// window.close();
// }
setEvents(valid.reverse());
setFetchError(null);
} catch (e: any) {
console.error('EventsViewer: fetchEvents failed', e);
setFetchError(e?.message || 'Failed to load pending requests');
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchEvents();
// Live-refresh when events are added/removed by the background.
const unsubscribe = requestStorage.subscribe?.(() => {
fetchEvents();
});
return () => {
if (typeof unsubscribe === 'function') unsubscribe();
};
}, [fetchEvents]);

const nextEvent = () => {
if (currentIndex < events.length - 1) {
setCurrentIndex(currentIndex + 1);
resetTransactionState();
}
};

const previousEvent = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
resetTransactionState();
}
};

const clearRequestEvents = async () => {
await requestStorage.clearEvents();
fetchEvents();
setCurrentIndex(0);
};
// Auto-close the popup whenever there's nothing the user can act on —
// empty state (dapp cancelled, cleanup ran, no pending request) OR a fetch
// failure (storage corruption/null). Both UIs tell the user the window
// will close itself, so the timer needs to cover both.
useEffect(() => {
if (loading) return;
const shouldAutoClose = fetchError !== null || events.length === 0;
if (!shouldAutoClose) return;
autoCloseTimerRef.current = setTimeout(() => {
console.log('EventsViewer: dead-end state timeout, closing popup');
window.close();
}, EMPTY_STATE_AUTO_CLOSE_MS);
return () => {
if (autoCloseTimerRef.current) {
clearTimeout(autoCloseTimerRef.current);
autoCloseTimerRef.current = null;
}
};
}, [events.length, loading, fetchError]);

// Reset transaction state when switching between events
const resetTransactionState = () => {
// Here you can reset any transaction-related state
setLoading(false);
};
// Clamp currentIndex inline so a mid-render shrink of the events list never
// hands `undefined` to <Transaction />. Doing this in a useEffect leaves a
// one-render gap where events[currentIndex] is undefined and crashes the
// child before the effect can snap the index back.
const safeIndex = events.length > 0 ? Math.min(currentIndex, events.length - 1) : 0;

return (
<Box maxW="100vw" overflowX="hidden" p={4}>
{/* Show spinner if events are being fetched */}
{loading && <Spinner />}
{loading && (
<Flex direction="column" align="center" justify="center" minH="200px" gap={3}>
<Spinner />
<Text fontSize="sm" opacity={0.7}>
Loading pending requests...
</Text>
</Flex>
)}

{!loading && fetchError && (
<Flex direction="column" align="center" justify="center" minH="200px" gap={3} p={4}>
<Text fontWeight="bold">Couldn't load pending requests</Text>
<Text fontSize="sm" opacity={0.7}>
{fetchError}
</Text>
<Text fontSize="xs" opacity={0.5} mt={2}>
This window will close automatically.
</Text>
</Flex>
)}

{/* Only show event details if events are loaded */}
{events.length > 0 && !loading ? (
<Box>
{/* Show the age of the current event */}
{/*<Text fontSize="md" fontWeight="medium">*/}
{/* Chain: {events[currentIndex].chain}*/}
{/* <br />*/}
{/* Event Age: {Math.floor(getEventAgeInMinutes(events[currentIndex].timestamp))} minutes*/}
{/*</Text>*/}
{!loading && !fetchError && events.length > 0 && (
<Transaction event={events[safeIndex]} reloadEvents={fetchEvents} />
)}

{/* Pass the current event to the Transaction component */}
<Transaction event={events[currentIndex]} reloadEvents={fetchEvents} />
</Box>
) : (
<div>No events</div>
{!loading && !fetchError && events.length === 0 && (
<Flex direction="column" align="center" justify="center" minH="200px" gap={3}>
<Text fontWeight="bold">No pending requests</Text>
<Text fontSize="sm" opacity={0.7}>
Nothing to approve right now.
</Text>
<Text fontSize="xs" opacity={0.5} mt={2}>
This window will close automatically.
</Text>
</Flex>
)}
</Box>
);
Expand Down
Loading