Merge pull request #6675 from HenrikJannsen/clone_offer_with_shared_maker_fee

Add feature for cloning an offer with shared maker fee
This commit is contained in:
Gabriel Bernard 2023-05-25 11:08:08 +00:00 committed by GitHub
commit f4368b3185
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1325 additions and 139 deletions

View File

@ -447,6 +447,7 @@ class CoreOffersService {
openOfferManager.placeOffer(offer,
buyerSecurityDepositPct,
useSavingsWallet,
false,
triggerPriceAsLong,
resultHandler::accept,
log::error);

View File

@ -114,7 +114,9 @@ public class Balances {
.map(openOffer -> btcWalletService.getAddressEntry(openOffer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE)
.orElse(null))
.filter(Objects::nonNull)
.mapToLong(addressEntry -> btcWalletService.getBalanceForAddress(addressEntry.getAddress()).value)
.map(AddressEntry::getAddress)
.distinct()
.mapToLong(address -> btcWalletService.getBalanceForAddress(address).value)
.sum();
reservedBalance.set(Coin.valueOf(sum));
}
@ -122,8 +124,9 @@ public class Balances {
private void updateLockedBalance() {
Stream<Trade> lockedTrades = Stream.concat(closedTradableManager.getTradesStreamWithFundsLockedIn(), failedTradesManager.getTradesStreamWithFundsLockedIn());
lockedTrades = Stream.concat(lockedTrades, tradeManager.getTradesStreamWithFundsLockedIn());
long sum = lockedTrades.map(trade -> btcWalletService.getAddressEntry(trade.getId(), AddressEntry.Context.MULTI_SIG)
.orElse(null))
long sum = lockedTrades.map(trade -> btcWalletService.getAddressEntry(trade.getId(),
AddressEntry.Context.MULTI_SIG)
.orElse(null))
.filter(Objects::nonNull)
.mapToLong(AddressEntry::getCoinLockedInMultiSig)
.sum();

View File

@ -149,7 +149,7 @@ public final class AddressEntryList implements PersistableEnvelope, PersistedDat
wallet.getIssuedReceiveAddresses().stream()
.filter(this::isAddressNotInEntries)
.forEach(address -> {
DeterministicKey key = (DeterministicKey) wallet.findKeyFromAddress(address);
DeterministicKey key = (DeterministicKey) wallet.findKeyFromAddress(address);
if (key != null) {
// Address will be derived from key in getAddress method
log.info("Create AddressEntry for IssuedReceiveAddress. address={}", address.toString());
@ -209,11 +209,26 @@ public final class AddressEntryList implements PersistableEnvelope, PersistedDat
}
log.info("swapToAvailable addressEntry to swap={}", addressEntry);
boolean setChangedByRemove = entrySet.remove(addressEntry);
boolean setChangedByAdd = entrySet.add(new AddressEntry(addressEntry.getKeyPair(),
AddressEntry.Context.AVAILABLE,
addressEntry.isSegwit()));
if (setChangedByRemove || setChangedByAdd) {
if (entrySet.remove(addressEntry)) {
requestPersistence();
}
// If we have an address entry which shared the address with another one (shared maker fee offers use case)
// then we do not swap to available as we need to protect the address of the remaining entry.
boolean entryWithSameContextStillExists = entrySet.stream().anyMatch(entry -> {
if (addressEntry.getAddressString() != null) {
return addressEntry.getAddressString().equals(entry.getAddressString()) &&
addressEntry.getContext() == entry.getContext();
}
return false;
});
if (entryWithSameContextStillExists) {
return;
}
// no other uses of the address context remain, so make it available
if (entrySet.add(
new AddressEntry(addressEntry.getKeyPair(),
AddressEntry.Context.AVAILABLE,
addressEntry.isSegwit()))) {
requestPersistence();
}
}

View File

@ -630,6 +630,25 @@ public class BtcWalletService extends WalletService {
.findAny();
}
// For cloned offers with shared maker fee we create a new address entry based on the source entry
// and set the new offerId.
public AddressEntry getOrCloneAddressEntryWithOfferId(AddressEntry sourceAddressEntry, String offerId) {
Optional<AddressEntry> addressEntry = getAddressEntryListAsImmutableList().stream()
.filter(entry -> offerId.equals(entry.getOfferId()))
.filter(entry -> sourceAddressEntry.getContext() == entry.getContext())
.findAny();
if (addressEntry.isPresent()) {
return addressEntry.get();
} else {
AddressEntry cloneWithNewOfferId = new AddressEntry(sourceAddressEntry.getKeyPair(),
sourceAddressEntry.getContext(),
offerId,
sourceAddressEntry.isSegwit());
addressEntryList.addAddressEntry(cloneWithNewOfferId);
return cloneWithNewOfferId;
}
}
public AddressEntry getOrCreateAddressEntry(String offerId, AddressEntry.Context context) {
Optional<AddressEntry> addressEntry = getAddressEntryListAsImmutableList().stream()
.filter(e -> offerId.equals(e.getOfferId()))

View File

@ -461,8 +461,8 @@ public abstract class WalletService {
.map(tx -> getTransactionConfidence(tx, address))
.filter(Objects::nonNull)
.filter(con -> con.getConfidenceType() == TransactionConfidence.ConfidenceType.PENDING ||
(con.getConfidenceType() == TransactionConfidence.ConfidenceType.BUILDING &&
con.getAppearedAtChainHeight() > targetHeight))
(con.getConfidenceType() == TransactionConfidence.ConfidenceType.BUILDING &&
con.getAppearedAtChainHeight() > targetHeight))
.collect(Collectors.toList()));
}
return getMostRecentConfidence(transactionConfidenceList);
@ -751,6 +751,10 @@ public abstract class WalletService {
return wallet.isEncrypted();
}
public List<Transaction> getAllRecentTransactions(boolean includeDead) {
return getRecentTransactions(Integer.MAX_VALUE, includeDead);
}
public List<Transaction> getRecentTransactions(int numTransactions, boolean includeDead) {
// Returns a list ordered by tx.getUpdateTime() desc
return wallet.getRecentTransactions(numTransactions, includeDead);

View File

@ -502,6 +502,7 @@ public class Offer implements NetworkPayload, PersistablePayload {
return offerPayloadBase.getMakerPaymentAccountId();
}
@Nullable
public String getOfferFeePaymentTxId() {
return getOfferPayload().map(OfferPayload::getOfferFeePaymentTxId).orElse(null);
}

View File

@ -226,7 +226,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
}
private void cleanUpAddressEntries() {
Set<String> openOffersIdSet = openOffers.getList().stream().map(OpenOffer::getId).collect(Collectors.toSet());
Set<String> openOffersIdSet = openOffers.getList().stream()
.map(OpenOffer::getId)
.collect(Collectors.toSet());
// We reset all AddressEntriesForOpenOffer which do not have a corresponding openOffer
btcWalletService.getAddressEntriesForOpenOffer().stream()
.filter(e -> !openOffersIdSet.contains(e.getOfferId()))
.forEach(e -> {
@ -380,12 +383,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
public void placeOffer(Offer offer,
double buyerSecurityDeposit,
boolean useSavingsWallet,
boolean isSharedMakerFee,
long triggerPrice,
TransactionResultHandler resultHandler,
ErrorMessageHandler errorMessageHandler) {
checkNotNull(offer.getMakerFee(), "makerFee must not be null");
checkArgument(!offer.isBsqSwapOffer());
int numClones = getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).size();
if (numClones >= 10) {
errorMessageHandler.handleErrorMessage("Cannot create offer because maximum number of 10 cloned offers with shared maker fee is reached.");
return;
}
Coin reservedFundsForOffer = createOfferService.getReservedFundsForOffer(offer.getDirection(),
offer.getAmount(),
buyerSecurityDeposit,
@ -394,6 +404,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
PlaceOfferModel model = new PlaceOfferModel(offer,
reservedFundsForOffer,
useSavingsWallet,
isSharedMakerFee,
btcWalletService,
tradeWalletService,
bsqWalletService,
@ -408,6 +419,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
model,
transaction -> {
OpenOffer openOffer = new OpenOffer(offer, triggerPrice);
if (isSharedMakerFee) {
if (cannotActivateOffer(offer)) {
openOffer.setState(OpenOffer.State.DEACTIVATED);
} else {
// We did not use the AddToOfferBook task for publishing because we
// do not have created the openOffer during the protocol and we need that to determine if the offer can be activated.
// So in case we have an activated cloned offer we do the publishing here.
model.getOfferBookService().addOffer(model.getOffer(),
() -> model.setOfferAddedToOfferBook(true),
errorMessage -> model.getOffer().setErrorMessage("Could not add offer to offerbook.\n" +
"Please check your network connection and try again."));
}
}
addOpenOfferToList(openOffer);
if (!stopped) {
startPeriodicRepublishOffersTimer();
@ -451,7 +475,12 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
ResultHandler resultHandler,
ErrorMessageHandler errorMessageHandler) {
if (offersToBeEdited.containsKey(openOffer.getId())) {
errorMessageHandler.handleErrorMessage("You can't activate an offer that is currently edited.");
errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivateEditedOffer.warning"));
return;
}
if (cannotActivateOffer(openOffer.getOffer())) {
errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivate.warning"));
return;
}
@ -575,8 +604,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
} else {
resultHandler.handleResult();
}
} else {
errorMessageHandler.handleErrorMessage("Editing of offer can't be canceled as it is not edited.");
}
}
@ -584,9 +611,20 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
offer.setState(Offer.State.REMOVED);
openOffer.setState(OpenOffer.State.CANCELED);
removeOpenOfferFromList(openOffer);
if (!openOffer.getOffer().isBsqSwapOffer()) {
closedTradableManager.add(openOffer);
btcWalletService.resetAddressEntriesForOpenOffer(offer.getId());
// In case of an offer which has its maker fee shared with other offers, we do not add the openOffer
// to history. Only when the last offer with that maker fee txId got removed we add it.
// Only canceled offers which have lost maker fees are shown in history.
// For that reason we also do not add BSQ offers.
if (getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).isEmpty()) {
closedTradableManager.add(openOffer);
// We only reset if there are no other offers with the shared maker fee as otherwise the
// address in the addressEntry would become available while it's still RESERVED_FOR_TRADE
// for the remaining offers.
btcWalletService.resetAddressEntriesForOpenOffer(offer.getId());
}
}
log.info("onRemoved offerId={}", offer.getId());
resultHandler.handleResult();
@ -594,13 +632,31 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
// Close openOffer after deposit published
public void closeOpenOffer(Offer offer) {
getOpenOfferById(offer.getId()).ifPresent(openOffer -> {
removeOpenOfferFromList(openOffer);
openOffer.setState(OpenOffer.State.CLOSED);
offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(),
() -> log.trace("Successful removed offer"),
log::error);
});
if (offer.isBsqSwapOffer()) {
getOpenOfferById(offer.getId()).ifPresent(openOffer -> {
removeOpenOfferFromList(openOffer);
openOffer.setState(OpenOffer.State.CLOSED);
offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(),
() -> log.trace("Successfully removed offer"),
log::error);
});
} else {
getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).forEach(openOffer -> {
removeOpenOfferFromList(openOffer);
if (offer.getId().equals(openOffer.getId())) {
openOffer.setState(OpenOffer.State.CLOSED);
} else {
// We use CANCELED for the offers which have shared maker fee but have not been taken for the trade.
openOffer.setState(OpenOffer.State.CANCELED);
// We need to reset now those entries as well
btcWalletService.resetAddressEntriesForOpenOffer(openOffer.getId());
}
offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(),
() -> log.trace("Successfully removed offer"),
log::error);
});
}
}
public void reserveOpenOffer(OpenOffer openOffer) {
@ -608,6 +664,25 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
requestPersistence();
}
public boolean cannotActivateOffer(Offer offer) {
return openOffers.stream()
.filter(openOffer -> !openOffer.getOffer().isBsqSwapOffer()) // We only handle non-BSQ offers
.filter(openOffer -> !openOffer.getId().equals(offer.getId())) // our own offer gets skipped
.filter(openOffer -> !openOffer.isDeactivated()) // we only check with activated offers
.anyMatch(openOffer ->
// Offers which share our maker fee will get checked if they have the same payment method
// and currency.
openOffer.getOffer().getOfferFeePaymentTxId() != null &&
openOffer.getOffer().getOfferFeePaymentTxId().equals(offer.getOfferFeePaymentTxId()) &&
openOffer.getOffer().getPaymentMethodId().equalsIgnoreCase(offer.getPaymentMethodId()) &&
openOffer.getOffer().getCounterCurrencyCode().equalsIgnoreCase(offer.getCounterCurrencyCode()) &&
openOffer.getOffer().getBaseCurrencyCode().equalsIgnoreCase(offer.getBaseCurrencyCode()));
}
public boolean hasOfferSharedMakerFee(OpenOffer openOffer) {
return getOpenOffersByMakerFeeTxId(openOffer.getOffer().getOfferFeePaymentTxId()).size() > 1;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Getters
@ -815,7 +890,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
result,
errorMessage);
final NodeAddress takersNodeAddress = sender;
NodeAddress takersNodeAddress = sender;
PubKeyRing takersPubKeyRing = message.getPubKeyRing();
log.info("Send AckMessage for OfferAvailabilityRequest to peer {} with offerId {} and sourceUid {}",
takersNodeAddress, offerId, ackMessage.getSourceUid());
@ -1141,6 +1216,16 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
}
private boolean preventedFromPublishing(OpenOffer openOffer) {
return openOffer.isDeactivated() || openOffer.isBsqSwapOfferHasMissingFunds();
return openOffer.isDeactivated() ||
openOffer.isBsqSwapOfferHasMissingFunds() ||
cannotActivateOffer(openOffer.getOffer());
}
private Set<OpenOffer> getOpenOffersByMakerFeeTxId(String makerFeeTxId) {
return openOffers.stream()
.filter(openOffer -> !openOffer.getOffer().isBsqSwapOffer() &&
makerFeeTxId != null &&
makerFeeTxId.equals(openOffer.getOffer().getOfferFeePaymentTxId()))
.collect(Collectors.toSet());
}
}

View File

@ -45,6 +45,7 @@ public class PlaceOfferModel implements Model {
private final Offer offer;
private final Coin reservedFundsForOffer;
private final boolean useSavingsWallet;
private final boolean isSharedMakerFee;
private final BtcWalletService walletService;
private final TradeWalletService tradeWalletService;
private final BsqWalletService bsqWalletService;
@ -66,6 +67,7 @@ public class PlaceOfferModel implements Model {
public PlaceOfferModel(Offer offer,
Coin reservedFundsForOffer,
boolean useSavingsWallet,
boolean isSharedMakerFee,
BtcWalletService walletService,
TradeWalletService tradeWalletService,
BsqWalletService bsqWalletService,
@ -79,6 +81,7 @@ public class PlaceOfferModel implements Model {
this.offer = offer;
this.reservedFundsForOffer = reservedFundsForOffer;
this.useSavingsWallet = useSavingsWallet;
this.isSharedMakerFee = isSharedMakerFee;
this.walletService = walletService;
this.tradeWalletService = tradeWalletService;
this.bsqWalletService = bsqWalletService;

View File

@ -19,6 +19,7 @@ package bisq.core.offer.placeoffer.bisq_v1;
import bisq.core.offer.placeoffer.bisq_v1.tasks.AddToOfferBook;
import bisq.core.offer.placeoffer.bisq_v1.tasks.CheckNumberOfUnconfirmedTransactions;
import bisq.core.offer.placeoffer.bisq_v1.tasks.CloneAddressEntryForSharedMakerFee;
import bisq.core.offer.placeoffer.bisq_v1.tasks.CreateMakerFeeTx;
import bisq.core.offer.placeoffer.bisq_v1.tasks.ValidateOffer;
import bisq.core.trade.bisq_v1.TransactionResultHandler;
@ -76,12 +77,20 @@ public class PlaceOfferProtocol {
errorMessageHandler.handleErrorMessage(errorMessage);
}
);
taskRunner.addTasks(
ValidateOffer.class,
CheckNumberOfUnconfirmedTransactions.class,
CreateMakerFeeTx.class,
AddToOfferBook.class
);
if (model.isSharedMakerFee()) {
taskRunner.addTasks(
ValidateOffer.class,
CloneAddressEntryForSharedMakerFee.class
);
} else {
taskRunner.addTasks(
ValidateOffer.class,
CheckNumberOfUnconfirmedTransactions.class,
CreateMakerFeeTx.class,
AddToOfferBook.class
);
}
taskRunner.run();
}

View File

@ -0,0 +1,81 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.core.offer.placeoffer.bisq_v1.tasks;
import bisq.core.btc.model.AddressEntry;
import bisq.core.btc.wallet.BtcWalletService;
import bisq.core.btc.wallet.WalletService;
import bisq.core.offer.Offer;
import bisq.core.offer.placeoffer.bisq_v1.PlaceOfferModel;
import bisq.common.taskrunner.Task;
import bisq.common.taskrunner.TaskRunner;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionOutput;
import java.util.List;
import java.util.Optional;
//
public class CloneAddressEntryForSharedMakerFee extends Task<PlaceOfferModel> {
@SuppressWarnings({"unused"})
public CloneAddressEntryForSharedMakerFee(TaskRunner<PlaceOfferModel> taskHandler, PlaceOfferModel model) {
super(taskHandler, model);
}
@Override
protected void run() {
runInterceptHook();
Offer offer = model.getOffer();
String makerFeeTxId = offer.getOfferFeePaymentTxId();
BtcWalletService walletService = model.getWalletService();
for (AddressEntry reservedForTradeEntry : walletService.getAddressEntries(AddressEntry.Context.RESERVED_FOR_TRADE)) {
if (findTxId(reservedForTradeEntry.getAddress())
.map(txId -> txId.equals(makerFeeTxId))
.orElse(false)) {
walletService.getOrCloneAddressEntryWithOfferId(reservedForTradeEntry, offer.getId());
complete();
return;
}
}
failed();
}
// We look up the most recent transaction with unspent outputs associated with the given address and return
// the txId if found.
private Optional<String> findTxId(Address address) {
BtcWalletService walletService = model.getWalletService();
List<Transaction> transactions = walletService.getAllRecentTransactions(false);
for (Transaction transaction : transactions) {
for (TransactionOutput output : transaction.getOutputs()) {
if (walletService.isTransactionOutputMine(output) && WalletService.isOutputScriptConvertibleToAddress(output)) {
String addressString = WalletService.getAddressStringFromOutput(output);
// make sure the output is still unspent
if (addressString != null && addressString.equals(address.toString()) && output.getSpentBy() == null) {
return Optional.of(transaction.getTxId().toString());
}
}
}
}
return Optional.empty();
}
}

View File

@ -365,6 +365,26 @@ offerbook.timeSinceSigning.tooltip.checkmark.wait=wait a minimum of {0} days
offerbook.timeSinceSigning.tooltip.learnMore=Learn more
offerbook.xmrAutoConf=Is auto-confirm enabled
offerbook.cloneOffer=Clone offer (with shared maker fee)
offerbook.clonedOffer.tooltip=This is a cloned offer with shared maker fee transaction ID.\n\Maker fee transaction ID: {0}
offerbook.nonClonedOffer.tooltip=Regular offer without shared maker fee transaction ID.\n\Maker fee transaction ID: {0}
offerbook.cannotActivate.warning=This cloned offer with shared maker fee cannot be activated because it uses \
the same payment method and currency as another active offer.\n\n\
You need to edit the offer and change the \
payment method or currency or deactivate the offer which has the same payment method and currency.
offerbook.cannotActivateEditedOffer.warning=You can't activate an offer that is currently edited.
offerbook.clonedOffer.info=By cloning an offer one creates a copy of the given offer with a new offer ID but using the same \
maker fee transaction ID.\n\n\
This means there is no extra maker fee needed to get paid and the funds reserved for that offer can \
be re-used by the cloned offers. This reduces the liquidity requirements for market makers and allows them to post the \
same offer in different markets or with different payment methods.\n\n\
As a consequence if one of the offers sharing the same maker fee transaction is taken all the other offers \
will get closed as well because the transaction output of that maker fee transaction is spent and would render the \
other offers invalid. \n\n\
This feature requires to use the same trade amount and security deposit and is only permitted for offers with different \
payment methods or currencies.\n\n\
For more information about cloning an offer see: [HYPERLINK:https://bisq.wiki/Cloning_an_offer]
offerbook.timeSinceSigning.help=When you successfully complete a trade with a peer who has a signed payment account, your payment account is signed.\n\
{0} days later, the initial limit of {1} is lifted and your account can sign other peers'' payment accounts.
offerbook.timeSinceSigning.notSigned=Not signed yet
@ -607,6 +627,7 @@ takeOffer.tac=With taking this offer I agree to the trade conditions as defined
####################################################################
openOffer.header.triggerPrice=Trigger price
openOffer.header.makerFeeTxId=Maker fee
openOffer.triggerPrice=Trigger price {0}
openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\
Please edit the offer to define a new trigger price
@ -619,7 +640,21 @@ editOffer.publishOffer=Publishing your offer.
editOffer.failed=Editing of offer failed:\n{0}
editOffer.success=Your offer has been successfully edited.
editOffer.invalidDeposit=The buyer's security deposit is not within the constraints defined by the Bisq DAO and can no longer be edited.
editOffer.openTabWarning=You have already the \"Edit Offer\" tab open.
editOffer.cannotActivateOffer=You have edited an offer which uses a shared maker fee with another offer and your edit \
made the payment method and currency now the same as that of another active cloned offer. Your edited offer will be \
deactivated because it is not permitted to publish 2 offers sharing the same maker fee with the same payment method \
and currency.\n\n\
You can edit the offer again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.
cloneOffer.clone=Clone offer
cloneOffer.publishOffer=Publishing cloned offer.
cloneOffer.success=Your offer has been successfully cloned.
cloneOffer.cannotActivateOffer=You have not changed the payment method or the currency. You still can clone the offer, but it will \
be deactivated and not published.\n\n\
You can edit the offer later again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.\n\n\
Do you still want to clone the offer?
cloneOffer.openTabWarning=You have already the \"Clone Offer\" tab open.
####################################################################
# BSQ Swap offer
@ -654,7 +689,7 @@ portfolio.tab.bsqSwap=Unconfirmed BSQ swaps
portfolio.tab.failed=Failed
portfolio.tab.editOpenOffer=Edit offer
portfolio.tab.duplicateOffer=Duplicate offer
portfolio.context.offerLikeThis=Create new offer like this...
portfolio.tab.cloneOpenOffer=Clone offer
portfolio.context.notYourOffer=You can only duplicate offers where you were the maker.
portfolio.closedTrades.deviation.help=Percentage price deviation from market

View File

@ -832,6 +832,10 @@ tree-table-view:focused {
-fx-text-fill: -bs-rd-error-red;
}
.icon {
-fx-fill: -bs-text-color;
}
.opaque-icon {
-fx-fill: -bs-color-gray-bbb;
-fx-opacity: 1;

View File

@ -336,6 +336,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs
openOfferManager.placeOffer(offer,
buyerSecurityDeposit.get(),
useSavingsWallet,
false,
triggerPrice,
resultHandler,
log::error);

View File

@ -258,8 +258,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
currencyComboBox.getSelectionModel().select(model.getTradeCurrency());
paymentAccountsComboBox.setItems(getPaymentAccounts());
paymentAccountsComboBox.getSelectionModel().select(model.getPaymentAccount());
UserThread.execute(() -> paymentAccountsComboBox.getSelectionModel().select(model.getPaymentAccount()));
onPaymentAccountsComboBoxSelected();
balanceTextField.setTargetAmount(model.getDataModel().totalToPayAsCoinProperty().get());

View File

@ -23,7 +23,9 @@ import bisq.desktop.common.view.CachingViewLoader;
import bisq.desktop.common.view.FxmlView;
import bisq.desktop.common.view.View;
import bisq.desktop.main.MainView;
import bisq.desktop.main.overlays.popups.Popup;
import bisq.desktop.main.portfolio.bsqswaps.UnconfirmedBsqSwapsView;
import bisq.desktop.main.portfolio.cloneoffer.CloneOfferView;
import bisq.desktop.main.portfolio.closedtrades.ClosedTradesView;
import bisq.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
import bisq.desktop.main.portfolio.editoffer.EditOfferView;
@ -58,7 +60,7 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
@FXML
Tab openOffersTab, pendingTradesTab, closedTradesTab, bsqSwapTradesTab;
private Tab editOpenOfferTab, duplicateOfferTab;
private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab;
private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase());
private Tab currentTab;
private Navigation.Listener navigationListener;
@ -71,7 +73,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
private final OpenOfferManager openOfferManager;
private EditOfferView editOfferView;
private DuplicateOfferView duplicateOfferView;
private boolean editOpenOfferViewOpen;
private CloneOfferView cloneOfferView;
private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen;
private OpenOffer openOffer;
private OpenOffersView openOffersView;
private int initialTabCount = 0;
@ -116,13 +119,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
else if (newValue == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
} else if (newValue == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
}
if (oldValue != null && oldValue == editOpenOfferTab)
editOfferView.onTabSelected(false);
if (oldValue != null && oldValue == duplicateOfferTab)
duplicateOfferView.onTabSelected(false);
if (oldValue != null && oldValue == cloneOpenOfferTab)
cloneOfferView.onTabSelected(false);
};
tabListChangeListener = change -> {
@ -132,6 +138,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
onEditOpenOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
onDuplicateOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab))
onCloneOpenOfferRemoved();
};
}
@ -154,6 +162,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
private void onCloneOpenOfferRemoved() {
cloneOpenOfferViewOpen = false;
if (cloneOfferView != null) {
cloneOfferView.onClose();
cloneOfferView = null;
}
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
@Override
protected void activate() {
failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
@ -183,6 +201,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
} else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true);
} else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
if (cloneOfferView != null) cloneOfferView.onTabSelected(true);
}
}
@ -195,10 +216,18 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
}
private void loadView(Class<? extends View> viewClass, @Nullable Object data) {
// we want to get activate/deactivate called, so we remove the old view on tab change
// TODO Don't understand the check for currentTab != editOpenOfferTab
if (currentTab != null && currentTab != editOpenOfferTab)
currentTab.setContent(null);
// We want to get activate/deactivate triggered, so we remove the old view on tab change
// for the tab views which are not-closable by calling `currentTab.setContent(null)`.
// The closable tab views like editOpenOfferTab, duplicateOfferTab and cloneOpenOfferTab
// do not need to be triggered.
if (currentTab != null) {
boolean isClosableTabView = currentTab == editOpenOfferTab ||
currentTab == duplicateOfferTab ||
currentTab == cloneOpenOfferTab;
if (!isClosableTabView) {
currentTab.setContent(null);
}
}
View view = viewLoader.load(viewClass);
@ -254,6 +283,28 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
} else if (view instanceof CloneOfferView) {
if (data instanceof OpenOffer) {
openOffer = (OpenOffer) data;
}
if (openOffer != null) {
if (cloneOfferView == null) {
cloneOfferView = (CloneOfferView) view;
cloneOfferView.applyOpenOffer(openOffer);
cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase());
cloneOfferView.setCloseHandler(() -> {
root.getTabs().remove(cloneOpenOfferTab);
});
root.getTabs().add(cloneOpenOfferTab);
}
if (currentTab != cloneOpenOfferTab)
cloneOfferView.onTabSelected(true);
currentTab = cloneOpenOfferTab;
} else {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
}
currentTab.setContent(view.getRoot());
@ -264,20 +315,35 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
openOffersView = view;
currentTab = openOffersTab;
OpenOfferActionHandler openOfferActionHandler = openOffer -> {
EditOpenOfferHandler editOpenOfferHandler = openOffer -> {
if (!editOpenOfferViewOpen) {
editOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
} else {
log.error("You have already a \"Edit Offer\" tab open.");
new Popup().warning(Res.get("editOffer.openTabWarning")).show();
}
};
openOffersView.setOpenOfferActionHandler(openOfferActionHandler);
openOffersView.setEditOpenOfferHandler(editOpenOfferHandler);
CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> {
if (!cloneOpenOfferViewOpen) {
cloneOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class);
} else {
new Popup().warning(Res.get("cloneOffer.openTabWarning")).show();
}
};
openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler);
}
public interface OpenOfferActionHandler {
public interface EditOpenOfferHandler {
void onEditOpenOffer(OpenOffer openOffer);
}
public interface CloneOpenOfferHandler {
void onCloneOpenOffer(OpenOffer openOffer);
}
}

View File

@ -0,0 +1,248 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.main.portfolio.cloneoffer;
import bisq.desktop.Navigation;
import bisq.desktop.main.offer.bisq_v1.MutableOfferDataModel;
import bisq.core.account.witness.AccountAgeWitnessService;
import bisq.core.btc.wallet.BsqWalletService;
import bisq.core.btc.wallet.BtcWalletService;
import bisq.core.locale.CurrencyUtil;
import bisq.core.locale.TradeCurrency;
import bisq.core.offer.Offer;
import bisq.core.offer.OfferDirection;
import bisq.core.offer.OfferUtil;
import bisq.core.offer.OpenOffer;
import bisq.core.offer.OpenOfferManager;
import bisq.core.offer.bisq_v1.CreateOfferService;
import bisq.core.offer.bisq_v1.OfferPayload;
import bisq.core.payment.PaymentAccount;
import bisq.core.proto.persistable.CorePersistenceProtoResolver;
import bisq.core.provider.fee.FeeService;
import bisq.core.provider.price.PriceFeedService;
import bisq.core.trade.statistics.TradeStatisticsManager;
import bisq.core.user.Preferences;
import bisq.core.user.User;
import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.CoinFormatter;
import bisq.network.p2p.P2PService;
import bisq.common.handlers.ErrorMessageHandler;
import bisq.common.handlers.ResultHandler;
import com.google.inject.Inject;
import javax.inject.Named;
import java.util.Date;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
class CloneOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer sourceOpenOffer;
@Inject
CloneOfferDataModel(CreateOfferService createOfferService,
OpenOfferManager openOfferManager,
OfferUtil offerUtil,
BtcWalletService btcWalletService,
BsqWalletService bsqWalletService,
Preferences preferences,
User user,
P2PService p2PService,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
FeeService feeService,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
CorePersistenceProtoResolver corePersistenceProtoResolver,
TradeStatisticsManager tradeStatisticsManager,
Navigation navigation) {
super(createOfferService,
openOfferManager,
offerUtil,
btcWalletService,
bsqWalletService,
preferences,
user,
p2PService,
priceFeedService,
accountAgeWitnessService,
feeService,
btcFormatter,
tradeStatisticsManager,
navigation);
this.corePersistenceProtoResolver = corePersistenceProtoResolver;
}
public void reset() {
direction = null;
tradeCurrency = null;
tradeCurrencyCode.set(null);
useMarketBasedPrice.set(false);
amount.set(null);
minAmount.set(null);
price.set(null);
volume.set(null);
minVolume.set(null);
buyerSecurityDeposit.set(0);
paymentAccounts.clear();
paymentAccount = null;
marketPriceMargin = 0;
sourceOpenOffer = null;
}
public void applyOpenOffer(OpenOffer openOffer) {
this.sourceOpenOffer = openOffer;
Offer offer = openOffer.getOffer();
direction = offer.getDirection();
CurrencyUtil.getTradeCurrency(offer.getCurrencyCode())
.ifPresent(c -> this.tradeCurrency = c);
tradeCurrencyCode.set(offer.getCurrencyCode());
PaymentAccount tmpPaymentAccount = user.getPaymentAccount(openOffer.getOffer().getMakerPaymentAccountId());
Optional<TradeCurrency> optionalTradeCurrency = CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode());
if (optionalTradeCurrency.isPresent() && tmpPaymentAccount != null) {
TradeCurrency selectedTradeCurrency = optionalTradeCurrency.get();
this.paymentAccount = PaymentAccount.fromProto(tmpPaymentAccount.toProtoMessage(), corePersistenceProtoResolver);
if (paymentAccount.getSingleTradeCurrency() != null)
paymentAccount.setSingleTradeCurrency(selectedTradeCurrency);
else
paymentAccount.setSelectedTradeCurrency(selectedTradeCurrency);
}
allowAmountUpdate = false;
}
@Override
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
try {
return super.initWithData(direction, tradeCurrency);
} catch (NullPointerException e) {
if (e.getMessage().contains("tradeCurrency")) {
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
}
return false;
}
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
return Objects.requireNonNull(user.getPaymentAccounts()).stream()
.filter(account -> !account.getPaymentMethod().isBsqSwap())
.collect(Collectors.toSet());
}
@Override
protected PaymentAccount getPreselectedPaymentAccount() {
return paymentAccount;
}
public void populateData() {
Offer offer = sourceOpenOffer.getOffer();
// Min amount need to be set before amount as if minAmount is null it would be set by amount
setMinAmount(offer.getMinAmount());
setAmount(offer.getAmount());
setPrice(offer.getPrice());
setVolume(offer.getVolume());
setUseMarketBasedPrice(offer.isUseMarketBasedPrice());
setTriggerPrice(sourceOpenOffer.getTriggerPrice());
if (offer.isUseMarketBasedPrice()) {
setMarketPriceMargin(offer.getMarketPriceMargin());
}
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
Offer clonedOffer = createClonedOffer();
openOfferManager.placeOffer(clonedOffer,
sourceOpenOffer.getOffer().getBuyerSecurityDeposit().getValue(),
false,
true,
triggerPrice,
transaction -> resultHandler.handleResult(),
errorMessageHandler);
}
private Offer createClonedOffer() {
Offer sourceOffer = sourceOpenOffer.getOffer();
OfferPayload sourceOfferPayload = sourceOffer.getOfferPayload().orElseThrow();
// We create a new offer based on our source offer and the edited fields in the UI
Offer editedOffer = createAndGetOffer();
OfferPayload editedOfferPayload = editedOffer.getOfferPayload().orElseThrow();
// We clone the edited offer but use the maker tx ID from the source offer as well as a new offerId and
// a fresh date.
String sharedMakerTxId = sourceOfferPayload.getOfferFeePaymentTxId();
String newOfferId = OfferUtil.getRandomOfferId();
long date = new Date().getTime();
OfferPayload clonedOfferPayload = new OfferPayload(newOfferId,
date,
sourceOfferPayload.getOwnerNodeAddress(),
sourceOfferPayload.getPubKeyRing(),
sourceOfferPayload.getDirection(),
editedOfferPayload.getPrice(),
editedOfferPayload.getMarketPriceMargin(),
editedOfferPayload.isUseMarketBasedPrice(),
sourceOfferPayload.getAmount(),
sourceOfferPayload.getMinAmount(),
editedOfferPayload.getBaseCurrencyCode(),
editedOfferPayload.getCounterCurrencyCode(),
sourceOfferPayload.getArbitratorNodeAddresses(),
sourceOfferPayload.getMediatorNodeAddresses(),
editedOfferPayload.getPaymentMethodId(),
editedOfferPayload.getMakerPaymentAccountId(),
sharedMakerTxId,
editedOfferPayload.getCountryCode(),
editedOfferPayload.getAcceptedCountryCodes(),
editedOfferPayload.getBankId(),
editedOfferPayload.getAcceptedBankIds(),
editedOfferPayload.getVersionNr(),
sourceOfferPayload.getBlockHeightAtOfferCreation(),
sourceOfferPayload.getTxFee(),
sourceOfferPayload.getMakerFee(),
sourceOfferPayload.isCurrencyForMakerFeeBtc(),
sourceOfferPayload.getBuyerSecurityDeposit(),
sourceOfferPayload.getSellerSecurityDeposit(),
editedOfferPayload.getMaxTradeLimit(),
editedOfferPayload.getMaxTradePeriod(),
sourceOfferPayload.isUseAutoClose(),
sourceOfferPayload.isUseReOpenAfterAutoClose(),
sourceOfferPayload.getLowerClosePrice(),
sourceOfferPayload.getUpperClosePrice(),
sourceOfferPayload.isPrivateOffer(),
sourceOfferPayload.getHashOfChallenge(),
editedOfferPayload.getExtraDataMap(),
sourceOfferPayload.getProtocolVersion());
Offer clonedOffer = new Offer(clonedOfferPayload);
clonedOffer.setPriceFeedService(priceFeedService);
clonedOffer.setState(Offer.State.OFFER_FEE_PAID);
return clonedOffer;
}
public boolean cannotActivateOffer() {
Offer clonedOffer = createClonedOffer();
return openOfferManager.cannotActivateOffer(clonedOffer);
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ This file is part of Bisq.
~
~ Bisq is free software: you can redistribute it and/or modify it
~ under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or (at
~ your option) any later version.
~
~ Bisq is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
~ License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with Bisq. If not, see <http://www.gnu.org/licenses/>.
-->
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane fx:id="root" fx:controller="bisq.desktop.main.portfolio.cloneoffer.CloneOfferView"
xmlns:fx="http://javafx.com/fxml">
</AnchorPane>

View File

@ -0,0 +1,268 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.main.portfolio.cloneoffer;
import bisq.desktop.Navigation;
import bisq.desktop.common.view.FxmlView;
import bisq.desktop.components.AutoTooltipButton;
import bisq.desktop.components.BusyAnimation;
import bisq.desktop.main.offer.bisq_v1.MutableOfferView;
import bisq.desktop.main.overlays.popups.Popup;
import bisq.desktop.main.overlays.windows.OfferDetailsWindow;
import bisq.desktop.util.GUIUtil;
import bisq.core.locale.CurrencyUtil;
import bisq.core.locale.Res;
import bisq.core.offer.OpenOffer;
import bisq.core.payment.PaymentAccount;
import bisq.core.user.DontShowAgainLookup;
import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.BsqFormatter;
import bisq.core.util.coin.CoinFormatter;
import bisq.common.util.Tuple4;
import com.google.inject.Inject;
import javax.inject.Named;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.collections.ObservableList;
import java.util.List;
import java.util.stream.Collectors;
import static bisq.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup;
@FxmlView
public class CloneOfferView extends MutableOfferView<CloneOfferViewModel> {
private BusyAnimation busyAnimation;
private Button cloneButton;
private Button cancelButton;
private Label spinnerInfoLabel;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
private CloneOfferView(CloneOfferViewModel model,
Navigation navigation,
Preferences preferences,
OfferDetailsWindow offerDetailsWindow,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
BsqFormatter bsqFormatter) {
super(model, navigation, preferences, offerDetailsWindow, btcFormatter, bsqFormatter);
}
@Override
protected void initialize() {
super.initialize();
addCloneGroup();
renameAmountGroup();
}
private void renameAmountGroup() {
amountTitledGroupBg.setText(Res.get("editOffer.setPrice"));
}
@Override
protected void doSetFocus() {
// Don't focus in any field before data was set
}
@Override
protected void doActivate() {
super.doActivate();
addBindings();
hideOptionsGroup();
// Lock amount field as it would require bigger changes to support increased amount values.
amountTextField.setDisable(true);
amountBtcLabel.setDisable(true);
minAmountTextField.setDisable(true);
minAmountBtcLabel.setDisable(true);
volumeTextField.setDisable(true);
volumeCurrencyLabel.setDisable(true);
// Workaround to fix margin on top of amount group
gridPane.setPadding(new Insets(-20, 25, -1, 25));
updatePriceToggle();
updateElementsWithDirection();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
model.onInvalidateMarketPriceMargin();
model.onInvalidatePrice();
// To force re-validation of payment account validation
onPaymentAccountsComboBoxSelected();
}
@Override
protected void deactivate() {
super.deactivate();
removeBindings();
}
@Override
public void onClose() {
}
///////////////////////////////////////////////////////////////////////////////////////////
// API
///////////////////////////////////////////////////////////////////////////////////////////
public void applyOpenOffer(OpenOffer openOffer) {
model.applyOpenOffer(openOffer);
initWithData(openOffer.getOffer().getDirection(),
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
null);
if (!model.isSecurityDepositValid()) {
new Popup().warning(Res.get("editOffer.invalidDeposit"))
.onClose(this::close)
.show();
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Bindings, Listeners
///////////////////////////////////////////////////////////////////////////////////////////
private void addBindings() {
cloneButton.disableProperty().bind(model.isNextButtonDisabled);
}
private void removeBindings() {
cloneButton.disableProperty().unbind();
}
@Override
protected ObservableList<PaymentAccount> filterPaymentAccounts(ObservableList<PaymentAccount> paymentAccounts) {
// We do not allow cloning or BSQ as there is no maker fee and requirement for reserved funds.
// Do not create a new ObservableList as that would cause bugs with the selected account.
List<PaymentAccount> toRemove = paymentAccounts.stream()
.filter(paymentAccount -> GUIUtil.BSQ.equals(paymentAccount.getSingleTradeCurrency()))
.collect(Collectors.toList());
toRemove.forEach(paymentAccounts::remove);
return paymentAccounts;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Build UI elements
///////////////////////////////////////////////////////////////////////////////////////////
private void addCloneGroup() {
Tuple4<Button, BusyAnimation, Label, HBox> tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 4, Res.get("cloneOffer.clone"));
HBox hBox = tuple4.fourth;
hBox.setAlignment(Pos.CENTER_LEFT);
GridPane.setHalignment(hBox, HPos.LEFT);
cloneButton = tuple4.first;
cloneButton.setMinHeight(40);
cloneButton.setPadding(new Insets(0, 20, 0, 20));
cloneButton.setGraphicTextGap(10);
busyAnimation = tuple4.second;
spinnerInfoLabel = tuple4.third;
cancelButton = new AutoTooltipButton(Res.get("shared.cancel"));
cancelButton.setDefaultButton(false);
cancelButton.setOnAction(event -> close());
hBox.getChildren().add(cancelButton);
cloneButton.setOnAction(e -> {
cloneButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong)
onClone();
});
}
private void onClone() {
if (model.dataModel.cannotActivateOffer()) {
new Popup().warning(Res.get("cloneOffer.cannotActivateOffer"))
.actionButtonText(Res.get("shared.yes"))
.onAction(this::doClone)
.closeButtonText(Res.get("shared.no"))
.show();
} else {
doClone();
}
}
private void doClone() {
if (model.isPriceInRange()) {
model.isNextButtonDisabled.setValue(true);
cancelButton.setDisable(true);
busyAnimation.play();
spinnerInfoLabel.setText(Res.get("cloneOffer.publishOffer"));
model.onCloneOffer(() -> {
String key = "cloneOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("cloneOffer.success"))
.dontShowAgainId(key)
.show();
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
},
errorMessage -> {
log.error(errorMessage);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(errorMessage).show();
});
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////////////////////
private void updateElementsWithDirection() {
ImageView iconView = new ImageView();
iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white");
cloneButton.setGraphic(iconView);
cloneButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
}
}

View File

@ -0,0 +1,128 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.desktop.main.portfolio.cloneoffer;
import bisq.desktop.Navigation;
import bisq.desktop.main.offer.OfferViewUtil;
import bisq.desktop.main.offer.bisq_v1.MutableOfferViewModel;
import bisq.desktop.util.validation.BsqValidator;
import bisq.desktop.util.validation.BtcValidator;
import bisq.desktop.util.validation.FiatVolumeValidator;
import bisq.desktop.util.validation.SecurityDepositValidator;
import bisq.core.account.witness.AccountAgeWitnessService;
import bisq.core.offer.OfferUtil;
import bisq.core.offer.OpenOffer;
import bisq.core.provider.price.PriceFeedService;
import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils;
import bisq.core.util.PriceUtil;
import bisq.core.util.coin.BsqFormatter;
import bisq.core.util.coin.CoinFormatter;
import bisq.core.util.validation.AltcoinValidator;
import bisq.core.util.validation.FiatPriceValidator;
import bisq.common.handlers.ErrorMessageHandler;
import bisq.common.handlers.ResultHandler;
import com.google.inject.Inject;
import javax.inject.Named;
class CloneOfferViewModel extends MutableOfferViewModel<CloneOfferDataModel> {
@Inject
public CloneOfferViewModel(CloneOfferDataModel dataModel,
FiatVolumeValidator fiatVolumeValidator,
FiatPriceValidator fiatPriceValidator,
AltcoinValidator altcoinValidator,
BtcValidator btcValidator,
BsqValidator bsqValidator,
SecurityDepositValidator securityDepositValidator,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
Navigation navigation,
Preferences preferences,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
BsqFormatter bsqFormatter,
OfferUtil offerUtil) {
super(dataModel,
fiatVolumeValidator,
fiatPriceValidator,
altcoinValidator,
btcValidator,
bsqValidator,
securityDepositValidator,
priceFeedService,
accountAgeWitnessService,
navigation,
preferences,
btcFormatter,
bsqFormatter,
offerUtil);
syncMinAmountWithAmount = false;
}
@Override
public void activate() {
super.activate();
dataModel.populateData();
long triggerPriceAsLong = dataModel.getTriggerPrice();
dataModel.setTriggerPrice(triggerPriceAsLong);
if (triggerPriceAsLong > 0) {
triggerPrice.set(PriceUtil.formatMarketPrice(triggerPriceAsLong, dataModel.getCurrencyCode()));
} else {
triggerPrice.set("");
}
onTriggerPriceTextFieldChanged();
}
public void applyOpenOffer(OpenOffer openOffer) {
dataModel.reset();
dataModel.applyOpenOffer(openOffer);
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
dataModel.onCloneOffer(resultHandler, errorMessageHandler);
}
public void onInvalidateMarketPriceMargin() {
marketPriceMargin.set(FormattingUtils.formatToPercent(dataModel.getMarketPriceMargin()));
}
public void onInvalidatePrice() {
price.set(FormattingUtils.formatPrice(null));
price.set(FormattingUtils.formatPrice(dataModel.getPrice().get()));
}
public boolean isSecurityDepositValid() {
return securityDepositValidator.validate(buyerSecurityDeposit.get()).isValid;
}
@Override
public void triggerFocusOutOnAmountFields() {
// do not update BTC Amount or minAmount here
// issue 2798: "after a few edits of offer the BTC amount has increased"
}
public boolean isShownAsSellOffer() {
return OfferViewUtil.isShownAsSellOffer(getTradeCurrency(), dataModel.getDirection());
}
}

View File

@ -264,7 +264,7 @@ public class ClosedTradesView extends ActivatableViewAndModel<VBox, ClosedTrades
tableView -> {
TableRow<ClosedTradesListItem> row = new TableRow<>();
ContextMenu rowMenu = new ContextMenu();
MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
MenuItem duplicateItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer"));
duplicateItem.setOnAction((ActionEvent event) -> onDuplicateOffer(row.getItem().getTradable().getOffer()));
rowMenu.getItems().add(duplicateItem);
row.contextMenuProperty().bind(

View File

@ -63,8 +63,9 @@ import java.util.stream.Collectors;
class EditOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer openOffer;
private OpenOffer originalOpenOffer;
private OpenOffer.State initialState;
private Offer editedOffer;
@Inject
EditOfferDataModel(CreateOfferService createOfferService,
@ -117,7 +118,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
}
public void applyOpenOffer(OpenOffer openOffer) {
this.openOffer = openOffer;
this.originalOpenOffer = openOffer;
Offer offer = openOffer.getOffer();
direction = offer.getDirection();
@ -175,21 +176,21 @@ class EditOfferDataModel extends MutableOfferDataModel {
}
public void populateData() {
Offer offer = openOffer.getOffer();
Offer offer = originalOpenOffer.getOffer();
// Min amount need to be set before amount as if minAmount is null it would be set by amount
setMinAmount(offer.getMinAmount());
setAmount(offer.getAmount());
setPrice(offer.getPrice());
setVolume(offer.getVolume());
setUseMarketBasedPrice(offer.isUseMarketBasedPrice());
setTriggerPrice(openOffer.getTriggerPrice());
setTriggerPrice(originalOpenOffer.getTriggerPrice());
if (offer.isUseMarketBasedPrice()) {
setMarketPriceMargin(offer.getMarketPriceMargin());
}
}
public void onStartEditOffer(ErrorMessageHandler errorMessageHandler) {
openOfferManager.editOpenOfferStart(openOffer, () -> {
openOfferManager.editOpenOfferStart(originalOpenOffer, () -> {
}, errorMessageHandler);
}
@ -201,19 +202,33 @@ class EditOfferDataModel extends MutableOfferDataModel {
OfferPayload offerPayload = offer.getOfferPayload().orElseThrow();
var mutableOfferPayloadFields = new MutableOfferPayloadFields(offerPayload);
OfferPayload editedPayload = offerUtil.getMergedOfferPayload(openOffer, mutableOfferPayloadFields);
Offer editedOffer = new Offer(editedPayload);
OfferPayload editedPayload = offerUtil.getMergedOfferPayload(originalOpenOffer, mutableOfferPayloadFields);
editedOffer = new Offer(editedPayload);
editedOffer.setPriceFeedService(priceFeedService);
editedOffer.setState(Offer.State.AVAILABLE);
openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
openOffer = null;
if (cannotActivateOffer()) {
OpenOffer editedOpenOffer = openOfferManager.getOpenOfferById(editedOffer.getId()).orElseThrow();
editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
}
resultHandler.handleResult();
originalOpenOffer = null;
editedOffer = null;
}, errorMessageHandler);
}
public void onCancelEditOffer(ErrorMessageHandler errorMessageHandler) {
if (openOffer != null)
openOfferManager.editOpenOfferCancel(openOffer, initialState, () -> {
if (originalOpenOffer != null)
openOfferManager.editOpenOfferCancel(originalOpenOffer, initialState, () -> {
}, errorMessageHandler);
}
public boolean cannotActivateOffer() {
// The cannotActivateOffer check considers only activated offers but at editing offer we have set the
// offer DEACTIVATED. We temporarily flip the state so that our cannotActivateOffer works as expected.
originalOpenOffer.setState(OpenOffer.State.AVAILABLE);
boolean result = openOfferManager.cannotActivateOffer(editedOffer);
originalOpenOffer.setState(OpenOffer.State.DEACTIVATED);
return result;
}
}

View File

@ -59,8 +59,9 @@ import static bisq.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGrou
public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
private BusyAnimation busyAnimation;
private Button confirmButton;
private Button confirmEditButton;
private Button cancelButton;
private Label spinnerInfoLabel;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
@ -170,11 +171,11 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
///////////////////////////////////////////////////////////////////////////////////////////
private void addBindings() {
confirmButton.disableProperty().bind(model.isNextButtonDisabled);
confirmEditButton.disableProperty().bind(model.isNextButtonDisabled);
}
private void removeBindings() {
confirmButton.disableProperty().unbind();
confirmEditButton.disableProperty().unbind();
}
@Override
@ -195,28 +196,36 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
editOfferConfirmationBox.setAlignment(Pos.CENTER_LEFT);
GridPane.setHalignment(editOfferConfirmationBox, HPos.LEFT);
confirmButton = editOfferTuple.first;
confirmButton.setMinHeight(40);
confirmButton.setPadding(new Insets(0, 20, 0, 20));
confirmButton.setGraphicTextGap(10);
confirmEditButton = editOfferTuple.first;
confirmEditButton.setMinHeight(40);
confirmEditButton.setPadding(new Insets(0, 20, 0, 20));
confirmEditButton.setGraphicTextGap(10);
busyAnimation = editOfferTuple.second;
Label spinnerInfoLabel = editOfferTuple.third;
spinnerInfoLabel = editOfferTuple.third;
cancelButton = new AutoTooltipButton(Res.get("shared.cancel"));
cancelButton.setDefaultButton(false);
cancelButton.setOnAction(event -> close());
editOfferConfirmationBox.getChildren().add(cancelButton);
confirmButton.setOnAction(e -> {
confirmButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong)
if (model.isPriceInRange()) {
model.isNextButtonDisabled.setValue(true);
cancelButton.setDisable(true);
busyAnimation.play();
spinnerInfoLabel.setText(Res.get("editOffer.publishOffer"));
//edit offer
model.onPublishOffer(() -> {
confirmEditButton.setOnAction(e -> {
confirmEditButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong)
onConfirmEdit();
});
}
private void onConfirmEdit() {
if (model.isPriceInRange()) {
model.isNextButtonDisabled.setValue(true);
cancelButton.setDisable(true);
busyAnimation.play();
spinnerInfoLabel.setText(Res.get("editOffer.publishOffer"));
model.onPublishOffer(() -> {
if (model.dataModel.cannotActivateOffer()) {
new Popup().warning(Res.get("editOffer.cannotActivateOffer")).show();
} else {
String key = "editOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
@ -224,19 +233,19 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
.dontShowAgainId(key)
.show();
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
}, (message) -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
});
}
});
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
}, (message) -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
});
}
}
///////////////////////////////////////////////////////////////////////////////////////////
@ -246,7 +255,7 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
private void updateElementsWithDirection() {
ImageView iconView = new ImageView();
iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white");
confirmButton.setGraphic(iconView);
confirmButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
confirmEditButton.setGraphic(iconView);
confirmEditButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
}
}

View File

@ -50,7 +50,11 @@ class OpenOfferListItem implements FilterableListItem {
private final OpenOfferManager openOfferManager;
OpenOfferListItem(OpenOffer openOffer, PriceUtil priceUtil, CoinFormatter btcFormatter, BsqFormatter bsqFormatter, OpenOfferManager openOfferManager) {
OpenOfferListItem(OpenOffer openOffer,
PriceUtil priceUtil,
CoinFormatter btcFormatter,
BsqFormatter bsqFormatter,
OpenOfferManager openOfferManager) {
this.openOffer = openOffer;
this.priceUtil = priceUtil;
this.btcFormatter = btcFormatter;
@ -134,6 +138,11 @@ class OpenOfferListItem implements FilterableListItem {
}
}
String getMakerFeeTxId() {
String makerFeeTxId = getOffer().getOfferFeePaymentTxId();
return makerFeeTxId != null ? makerFeeTxId : "";
}
@Override
public boolean match(String filterString) {
if (filterString.isEmpty()) {

View File

@ -41,6 +41,7 @@
<TableView fx:id="tableView" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="offerIdColumn" minWidth="110" maxWidth="120"/>
<TableColumn fx:id="makerFeeTxIdColumn" minWidth="70"/>
<TableColumn fx:id="dateColumn" minWidth="170"/>
<TableColumn fx:id="marketColumn" minWidth="75"/>
<TableColumn fx:id="priceColumn" minWidth="100"/>
@ -54,6 +55,7 @@
<TableColumn fx:id="editItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="triggerIconColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="duplicateItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="cloneItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="removeItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
</columns>
</TableView>

View File

@ -37,8 +37,9 @@ import bisq.desktop.main.portfolio.presentation.PortfolioUtil;
import bisq.desktop.util.GUIUtil;
import bisq.core.locale.Res;
import bisq.core.offer.Offer;
import bisq.core.offer.OpenOffer;
import bisq.core.offer.OpenOfferManager;
import bisq.core.provider.price.PriceFeedService;
import bisq.core.user.DontShowAgainLookup;
import com.googlecode.jcsv.writer.CSVEntryConverter;
@ -80,6 +81,7 @@ import javafx.collections.transformation.SortedList;
import javafx.util.Callback;
import java.util.Comparator;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
@ -89,9 +91,9 @@ import static bisq.desktop.util.FormBuilder.getRegularIconForLabel;
@FxmlView
public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersViewModel> {
private enum ColumnNames {
OFFER_ID(Res.get("shared.offerId")),
MAKER_FEE_TX_ID(Res.get("openOffer.header.makerFeeTxId")),
DATE(Res.get("shared.dateTime")),
MARKET(Res.get("shared.market")),
PRICE(Res.get("shared.price")),
@ -119,8 +121,9 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
TableView<OpenOfferListItem> tableView;
@FXML
TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn;
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, makerFeeTxIdColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn,
cloneItemColumn;
@FXML
FilterBox filterBox;
@FXML
@ -132,28 +135,36 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
@FXML
AutoTooltipSlideToggleButton selectToggleButton;
private final PriceFeedService priceFeedService;
private final Navigation navigation;
private final OfferDetailsWindow offerDetailsWindow;
private final BsqSwapOfferDetailsWindow bsqSwapOfferDetailsWindow;
private final OpenOfferManager openOfferManager;
private SortedList<OpenOfferListItem> sortedList;
private PortfolioView.OpenOfferActionHandler openOfferActionHandler;
private PortfolioView.EditOpenOfferHandler editOpenOfferHandler;
private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler;
private ChangeListener<Number> widthListener;
private ListChangeListener<OpenOfferListItem> sortedListeChangedListener;
@Inject
public OpenOffersView(OpenOffersViewModel model,
OpenOfferManager openOfferManager,
PriceFeedService priceFeedService,
Navigation navigation,
OfferDetailsWindow offerDetailsWindow,
BsqSwapOfferDetailsWindow bsqSwapOfferDetailsWindow) {
super(model);
this.priceFeedService = priceFeedService;
this.navigation = navigation;
this.offerDetailsWindow = offerDetailsWindow;
this.bsqSwapOfferDetailsWindow = bsqSwapOfferDetailsWindow;
this.openOfferManager = openOfferManager;
}
@Override
public void initialize() {
widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
makerFeeTxIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MAKER_FEE_TX_ID.toString()));
paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString()));
priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString()));
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(),
@ -168,9 +179,11 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString()));
editItemColumn.setText("");
duplicateItemColumn.setText("");
cloneItemColumn.setText("");
removeItemColumn.setText("");
setOfferIdColumnCellFactory();
setMakerFeeTxIdColumnCellFactory();
setDirectionColumnCellFactory();
setMarketColumnCellFactory();
setPriceColumnCellFactory();
@ -184,12 +197,14 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
setTriggerIconColumnCellFactory();
setTriggerPriceColumnCellFactory();
setDuplicateColumnCellFactory();
setCloneColumnCellFactory();
setRemoveColumnCellFactory();
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
makerFeeTxIdColumn.setComparator(Comparator.comparing(OpenOfferListItem::getMakerFeeTxId));
directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
marketColumn.setComparator(Comparator.comparing(OpenOfferListItem::getMarketDescription));
amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount()));
@ -201,16 +216,22 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate()));
paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId())));
dateColumn.setSortType(TableColumn.SortType.DESCENDING);
dateColumn.setSortType(TableColumn.SortType.ASCENDING);
tableView.getSortOrder().add(dateColumn);
tableView.setRowFactory(
tableView -> {
final TableRow<OpenOfferListItem> row = new TableRow<>();
final ContextMenu rowMenu = new ContextMenu();
MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
duplicateItem.setOnAction((event) -> onDuplicateOffer(row.getItem().getOffer()));
rowMenu.getItems().add(duplicateItem);
MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer"));
duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem()));
rowMenu.getItems().add(duplicateOfferMenuItem);
MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer"));
cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem()));
rowMenu.getItems().add(cloneOfferMenuItem);
row.contextMenuProperty().bind(
Bindings.when(Bindings.isNotNull(row.itemProperty()))
.then(rowMenu)
@ -233,6 +254,8 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
c.next();
if (c.wasAdded() || c.wasRemoved()) {
updateNumberOfOffers();
updateMakerFeeTxIdColumnVisibility();
updateTriggerColumnVisibility();
}
};
}
@ -247,7 +270,8 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
filterBox.initializeWithCallback(filteredList, tableView, this::updateNumberOfOffers);
filterBox.activate();
updateMakerFeeTxIdColumnVisibility();
updateTriggerColumnVisibility();
updateSelectToggleButtonState();
selectToggleButton.setOnAction(event -> {
@ -272,6 +296,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
String[] columns = new String[ColumnNames.values().length];
columns[ColumnNames.OFFER_ID.ordinal()] = item.getOffer().getShortId();
columns[ColumnNames.MAKER_FEE_TX_ID.ordinal()] = item.getMakerFeeTxId();
columns[ColumnNames.DATE.ordinal()] = item.getDateAsString();
columns[ColumnNames.MARKET.ordinal()] = item.getMarketDescription();
columns[ColumnNames.PRICE.ordinal()] = item.getPriceAsString();
@ -301,6 +326,18 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
}
private void updateMakerFeeTxIdColumnVisibility() {
makerFeeTxIdColumn.setVisible(model.dataModel.getList().stream()
.collect(Collectors.groupingBy(OpenOfferListItem::getMakerFeeTxId, Collectors.counting()))
.values().stream().anyMatch(i -> i > 1));
}
private void updateTriggerColumnVisibility() {
triggerIconColumn.setVisible(model.dataModel.getList().stream()
.mapToLong(item -> item.getOpenOffer().getTriggerPrice())
.sum() > 0);
}
@Override
protected void deactivate() {
sortedList.comparatorProperty().unbind();
@ -329,7 +366,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
private void onWidthChange(double width) {
triggerPriceColumn.setVisible(width > 1200);
triggerPriceColumn.setVisible(width > 1300);
}
private void onDeactivateOpenOffer(OpenOffer openOffer) {
@ -350,7 +387,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
() -> log.debug("Activate offer was successful"),
(message) -> {
log.error(message);
new Popup().warning(Res.get("offerbook.activateOffer.failed", message)).show();
new Popup().warning(message).show();
});
updateSelectToggleButtonState();
}
@ -359,19 +396,23 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private void onRemoveOpenOffer(OpenOfferListItem item) {
OpenOffer openOffer = item.getOpenOffer();
if (model.isBootstrappedOrShowPopup()) {
String key = (openOffer.getOffer().isBsqSwapOffer() ? "RemoveBsqSwapWarning" : "RemoveOfferWarning");
if (DontShowAgainLookup.showAgain(key)) {
String message = openOffer.getOffer().isBsqSwapOffer() ?
Res.get("popup.warning.removeNoFeeOffer") :
Res.get("popup.warning.removeOffer", item.getMakerFeeAsString());
new Popup().warning(message)
.actionButtonText(Res.get("shared.removeOffer"))
.onAction(() -> doRemoveOpenOffer(openOffer))
.closeButtonText(Res.get("shared.dontRemoveOffer"))
.dontShowAgainId(key)
.show();
} else {
if (openOfferManager.hasOfferSharedMakerFee(openOffer)) {
doRemoveOpenOffer(openOffer);
} else {
String key = (openOffer.getOffer().isBsqSwapOffer() ? "RemoveBsqSwapWarning" : "RemoveOfferWarning");
if (DontShowAgainLookup.showAgain(key)) {
String message = openOffer.getOffer().isBsqSwapOffer() ?
Res.get("popup.warning.removeNoFeeOffer") :
Res.get("popup.warning.removeOffer", item.getMakerFeeAsString());
new Popup().warning(message)
.actionButtonText(Res.get("shared.removeOffer"))
.onAction(() -> doRemoveOpenOffer(openOffer))
.closeButtonText(Res.get("shared.dontRemoveOffer"))
.dontShowAgainId(key)
.show();
} else {
doRemoveOpenOffer(openOffer);
}
}
updateSelectToggleButtonState();
}
@ -384,9 +425,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
tableView.refresh();
if (openOffer.getOffer().isBsqSwapOffer()) {
return; // nothing to withdraw when Bsq swap is canceled (issue #5956)
// We do not show the popup if it's a BSQ offer or a cloned offer with shared maker fee
if (openOffer.getOffer().isBsqSwapOffer() ||
openOfferManager.hasOfferSharedMakerFee(openOffer)) {
return;
}
String key = "WithdrawFundsAfterRemoveOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal")))
@ -404,16 +448,43 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private void onEditOpenOffer(OpenOffer openOffer) {
if (model.isBootstrappedOrShowPopup()) {
openOfferActionHandler.onEditOpenOffer(openOffer);
editOpenOfferHandler.onEditOpenOffer(openOffer);
}
}
private void onDuplicateOffer(Offer offer) {
try {
PortfolioUtil.duplicateOffer(navigation, offer.getOfferPayloadBase());
} catch (NullPointerException e) {
log.warn("Unable to get offerPayload - {}", e.toString());
private void onDuplicateOffer(OpenOfferListItem item) {
if (item == null || item.getOffer().getOfferPayloadBase() == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
PortfolioUtil.duplicateOffer(navigation, item.getOffer().getOfferPayloadBase());
}
}
private void onCloneOffer(OpenOfferListItem item) {
if (item == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
String key = "clonedOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().backgroundInfo(Res.get("offerbook.clonedOffer.info"))
.useIUnderstandButton()
.dontShowAgainId(key)
.onClose(() -> doCloneOffer(item))
.show();
} else {
doCloneOffer(item);
}
}
}
private void doCloneOffer(OpenOfferListItem item) {
OpenOffer openOffer = item.getOpenOffer();
if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload().isEmpty()) {
return;
}
cloneOpenOfferHandler.onCloneOpenOffer(openOffer);
}
private void setOfferIdColumnCellFactory() {
@ -421,19 +492,23 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
offerIdColumn.getStyleClass().addAll("number-column", "first-column");
offerIdColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem,
OpenOfferListItem> column) {
return new TableCell<>() {
private HyperlinkWithIcon field;
private HyperlinkWithIcon hyperlinkWithIcon;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
field = new HyperlinkWithIcon(item.getOffer().getShortId());
field.setOnAction(event -> {
hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId());
if (item.isNotPublished()) {
// getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-(
hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;");
hyperlinkWithIcon.getIcon().setOpacity(0.2);
}
hyperlinkWithIcon.setOnAction(event -> {
if (item.getOffer().isBsqSwapOffer()) {
bsqSwapOfferDetailsWindow.show(item.getOffer());
} else {
@ -441,12 +516,52 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
});
field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(field);
hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(hyperlinkWithIcon);
} else {
setGraphic(null);
if (hyperlinkWithIcon != null)
hyperlinkWithIcon.setOnAction(null);
}
}
};
}
});
}
private void setMakerFeeTxIdColumnCellFactory() {
makerFeeTxIdColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
makerFeeTxIdColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(
TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
getStyleClass().removeAll("offer-disabled");
if (item != null) {
Label label = new Label(item.getMakerFeeTxId());
Text icon;
if (openOfferManager.hasOfferSharedMakerFee(item.getOpenOffer())) {
icon = getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon");
setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getMakerFeeTxId())));
} else {
icon = getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon");
setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getMakerFeeTxId())));
}
icon.setVisible(!item.getOffer().isBsqSwapOffer());
if (item.isNotPublished()) {
getStyleClass().add("offer-disabled");
icon.setOpacity(0.2);
}
setGraphic(label);
} else {
setGraphic(null);
if (field != null)
field.setOnAction(null);
}
}
};
@ -785,7 +900,41 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
button.setTooltip(new Tooltip(Res.get("shared.duplicateOffer")));
setGraphic(button);
}
button.setOnAction(event -> onDuplicateOffer(item.getOffer()));
button.setOnAction(event -> onDuplicateOffer(item));
} else {
setGraphic(null);
if (button != null) {
button.setOnAction(null);
button = null;
}
}
}
};
}
});
}
private void setCloneColumnCellFactory() {
cloneItemColumn.getStyleClass().add("avatar-column");
cloneItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
cloneItemColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
Button button;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (button == null) {
button = getRegularIconButton(MaterialDesignIcon.BOX_SHADOW);
button.setTooltip(new Tooltip(Res.get("offerbook.cloneOffer")));
setGraphic(button);
}
button.setOnAction(event -> onCloneOffer(item));
} else {
setGraphic(null);
if (button != null) {
@ -889,8 +1038,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) {
this.openOfferActionHandler = openOfferActionHandler;
public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) {
this.editOpenOfferHandler = editOpenOfferHandler;
}
public void setCloneOpenOfferHandler(PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler) {
this.cloneOpenOfferHandler = cloneOpenOfferHandler;
}
}

View File

@ -234,7 +234,7 @@ public class PendingTradesView extends ActivatableViewAndModel<VBox, PendingTrad
tableView -> {
final TableRow<PendingTradesListItem> row = new TableRow<>();
final ContextMenu rowMenu = new ContextMenu();
MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
MenuItem duplicateItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer"));
duplicateItem.setOnAction((event) -> {
try {
OfferPayload offerPayload = row.getItem().getTrade().getOffer().getOfferPayload().orElseThrow();

View File

@ -557,3 +557,7 @@
-fx-text-fill: -bs-text-color;
-fx-fill: -bs-text-color;
}
.icon {
-fx-fill: #fff;
}

View File

@ -2248,12 +2248,12 @@ public class FormBuilder {
// Icons
///////////////////////////////////////////////////////////////////////////////////////////
public static Text getIconForLabel(GlyphIcons icon, String iconSize, Label label, String style) {
public static Text getIconForLabel(GlyphIcons icon, String iconSize, Label label, String styleClass) {
if (icon.fontFamily().equals(MATERIAL_DESIGN_ICONS)) {
final Text textIcon = MaterialDesignIconFactory.get().createIcon(icon, iconSize);
textIcon.setOpacity(0.7);
if (style != null) {
textIcon.getStyleClass().add(style);
if (styleClass != null) {
textIcon.getStyleClass().add(styleClass);
}
label.setContentDisplay(ContentDisplay.LEFT);
label.setGraphic(textIcon);
@ -2279,8 +2279,8 @@ public class FormBuilder {
return getRegularIconForLabel(icon, label, null);
}
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) {
return getIconForLabel(icon, "1.231em", label, style);
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) {
return getIconForLabel(icon, "1.231em", label, styleClass);
}
public static Text getIcon(GlyphIcons icon) {