diff --git a/CHANGELOG.md b/CHANGELOG.md index a48e83b779..bdba3f23aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: Fix flickering of offers - Remove three dead rendezvous points ## [4.7.10] - 2026-06-02 diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx index 21e4b956b5..0956e516e1 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx @@ -14,12 +14,12 @@ import SortIcon from "@mui/icons-material/Sort"; import CheckIcon from "@mui/icons-material/Check"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import MakerOfferItem from "./MakerOfferItem"; -import { usePendingSelectMakerApproval } from "store/hooks"; import MakerDiscoveryStatus from "./MakerDiscoveryStatus"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { SatsAmount } from "renderer/components/other/Units"; import { useEffect, useState } from "react"; -import { sortApprovalsAndKnownQuotes, OfferSortMode } from "utils/sortUtils"; +import { OfferSortMode } from "utils/sortUtils"; +import { useCachedMakerOffers } from "./useCachedMakerOffers"; const SORT_OPTIONS: { value: OfferSortMode; label: string }[] = [ { value: "large", label: "Large swaps" }, @@ -32,14 +32,12 @@ export default function DepositAndChooseOfferPage({ max_giveable, known_quotes, }: TauriSwapProgressEventContent<"WaitingForBtcDeposit">) { - const pendingSelectMakerApprovals = usePendingSelectMakerApproval(); const [currentPage, setCurrentPage] = useState(1); const [sortMode, setSortMode] = useState("small"); const [sortAnchorEl, setSortAnchorEl] = useState(null); const offersPerPage = 3; - const makerOffers = sortApprovalsAndKnownQuotes( - pendingSelectMakerApprovals, + const makerOffers = useCachedMakerOffers( known_quotes, sortMode, offersPerPage, diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx index dc6bfbc0e4..4b35b0aa92 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx @@ -30,7 +30,7 @@ import { SatsAmount, } from "renderer/components/other/Units"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { resolveApproval } from "renderer/rpc"; +import { useResolveSelectMakerApproval } from "./useResolveSelectMakerApproval"; import WarningIcon from "@mui/icons-material/Warning"; import FavoriteIcon from "@mui/icons-material/Favorite"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; @@ -68,6 +68,7 @@ export default function MakerOfferItem({ const isOutOfLiquidity = quote.max_quantity == 0; const isTooOld = isMakerVersionTooOld(version); const priorityMaker = showAsPriority ? getPriorityMaker(peer_id) : undefined; + const resolveSelectMakerApproval = useResolveSelectMakerApproval(); return ( { - if (!requestId) { - throw new Error("Request ID is required"); - } - return resolveApproval(requestId, true as unknown as object); - }} + onInvoke={() => resolveSelectMakerApproval(peer_id, requestId)} displayErrorSnackbar disabled={!requestId} tooltipTitle={ diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts new file mode 100644 index 0000000000..a70fbadb67 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { QuoteWithAddress } from "models/tauriModel"; +import { useAppSelector, usePendingSelectMakerApproval } from "store/hooks"; +import _ from "lodash"; +import { + OfferSortMode, + SortedMakerEntry, + sortApprovalsAndKnownQuotes, +} from "utils/sortUtils"; + +const REFRESH_INTERVAL_MS = 5_000; + +// The sorted list re-shuffles whenever the backend streams an approval or +// quote update. We snapshot it and only refresh on a fixed cadence so cards +// don't visibly flicker. The snapshot is also refreshed immediately on +// sort-mode change, on Bitcoin balance change, and whenever a new peer +// appears, so newly-discovered makers and freshly-deposited funds don't get +// stuck behind the cadence. +export function useCachedMakerOffers( + known_quotes: QuoteWithAddress[], + sortMode: OfferSortMode, + offersPerPage: number, +): SortedMakerEntry[] { + const pendingApprovals = usePendingSelectMakerApproval(); + const bitcoinBalance = useAppSelector((state) => state.bitcoinWallet.balance); + + const liveOffers = useMemo( + () => + sortApprovalsAndKnownQuotes( + pendingApprovals, + known_quotes, + sortMode, + offersPerPage, + ), + [pendingApprovals, known_quotes, sortMode, offersPerPage], + ); + + const liveOffersRef = useRef(liveOffers); + liveOffersRef.current = liveOffers; + + const [snapshot, setSnapshot] = useState(liveOffers); + + useEffect(() => { + setSnapshot(liveOffersRef.current); + }, [sortMode, bitcoinBalance]); + + useEffect(() => { + const snapshotPeers = new Set( + snapshot.map((o) => o.quote_with_address.peer_id), + ); + const hasNewPeer = liveOffers.some( + (o) => !snapshotPeers.has(o.quote_with_address.peer_id), + ); + if (hasNewPeer) setSnapshot(liveOffers); + }, [snapshot, liveOffers]); + + useEffect(() => { + const id = window.setInterval(() => { + setSnapshot((prev) => + _.isEqual(prev, liveOffersRef.current) ? prev : liveOffersRef.current, + ); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, []); + + return snapshot; +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts new file mode 100644 index 0000000000..cb8bb7990f --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { store, type RootState } from "renderer/store/storeRenderer"; +import { resolveApproval } from "renderer/rpc"; +import { isPendingSelectMakerApprovalEvent } from "models/tauriModelExt"; + +const WAIT_FOR_APPROVAL_MS = 5_000; + +function findPendingApprovalForPeer( + state: RootState, + peerId: string, +): string | null { + const requests = Object.values(state.rpc.state.approvalRequests); + for (const req of requests) { + if (req.request_status.state !== "Pending") continue; + if (!isPendingSelectMakerApprovalEvent(req)) continue; + if (req.request.content.maker.peer_id === peerId) return req.request_id; + } + return null; +} + +/** + * The snapshot list shown to the user can hold a request id that has already + * expired by the time they click Select. In that case we wait briefly for the + * backend to emit a fresh pending approval for the same peer and resolve that + * one instead, so a stale snapshot doesn't surface as a confusing error. + */ +export function useResolveSelectMakerApproval() { + return useCallback( + async (peerId: string, snapshotRequestId: string | undefined) => { + const tryResolve = (id: string) => + resolveApproval(id, true as unknown as object); + + if (snapshotRequestId) { + const stillPending = + store.getState().rpc.state.approvalRequests[snapshotRequestId] + ?.request_status.state === "Pending"; + if (stillPending) return tryResolve(snapshotRequestId); + } + + const immediate = findPendingApprovalForPeer(store.getState(), peerId); + if (immediate) return tryResolve(immediate); + + const freshId = await new Promise((resolve) => { + const timeout = window.setTimeout(() => { + unsubscribe(); + resolve(null); + }, WAIT_FOR_APPROVAL_MS); + + const unsubscribe = store.subscribe(() => { + const found = findPendingApprovalForPeer(store.getState(), peerId); + if (found) { + window.clearTimeout(timeout); + unsubscribe(); + resolve(found); + } + }); + }); + + if (!freshId) { + throw new Error( + "This offer is no longer available. Please pick another maker.", + ); + } + return tryResolve(freshId); + }, + [], + ); +}