package lighthouse.activities; import com.google.common.base.Charsets; import com.google.common.base.*; import com.google.common.collect.*; import javafx.animation.*; import javafx.application.Platform; import javafx.beans.binding.*; import javafx.beans.property.*; import javafx.collections.*; import javafx.collections.transformation.*; import javafx.event.*; import javafx.fxml.*; import javafx.geometry.*; import javafx.scene.*; import javafx.scene.chart.*; import javafx.scene.control.*; import javafx.scene.effect.*; import javafx.scene.image.*; import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.stage.*; import kotlin.*; import lighthouse.*; import lighthouse.controls.*; import lighthouse.nav.*; import lighthouse.protocol.*; import lighthouse.subwindows.*; import lighthouse.threading.*; import lighthouse.utils.*; import org.bitcoinj.core.*; import org.bitcoinj.params.*; import org.slf4j.*; import javax.annotation.*; import java.io.*; import java.nio.file.*; import java.time.*; import java.time.format.*; import java.util.*; import java.util.concurrent.*; import static com.google.common.base.Preconditions.*; import static javafx.beans.binding.Bindings.*; import static javafx.collections.FXCollections.*; import static lighthouse.utils.GuiUtils.*; import static lighthouse.utils.I18nUtil.*; import static lighthouse.utils.MoreBindings.*; /** * The main content area that shows project details, pledges, a pie chart, buttons etc. */ public class ProjectActivity extends HBox implements Activity { private static final Logger log = LoggerFactory.getLogger(ProjectActivity.class); private static final String BLOCK_EXPLORER_SITE = "https://insight.bitpay.com/tx/%s"; private static final String BLOCK_EXPLORER_SITE_TESTNET = "https://www.biteasy.com/testnet/transactions/%s"; @FXML Label projectTitle; @FXML Label goalAmountLabel; @FXML Label raisedAmountLabel; @FXML MarkDownNode description; @FXML ListView<LHProtos.Pledge> pledgesList; @FXML PieChart pieChart; @FXML Button actionButton; @FXML Pane coverImage; @FXML Label numPledgersLabel; @FXML Label percentFundedLabel; @FXML Button editButton; @FXML Label copyDescriptionLink; @FXML Button importPledgeButton; public final ObjectProperty<Project> project = new SimpleObjectProperty<>(); private PieChart.Data emptySlice; private ObservableSet<LHProtos.Pledge> pledges; private UIBindings bindings; private LongProperty pledgedValue; private ObjectBinding<LighthouseBackend.CheckStatus> checkStatus; private ObservableMap<Sha256Hash, LighthouseBackend.ProjectStateInfo> projectStates; // project id -> status private ObservableMap<Project, LighthouseBackend.CheckStatus> statusMap; @Nullable private NotificationBarPane.Item notifyBarItem; @Nullable private Sha256Hash myPledgeHash; private String goalAmountFormatStr; private BooleanBinding isFullyFundedAndNotParticipating; private enum Mode { OPEN_FOR_PLEDGES, PLEDGED, CAN_CLAIM, CLAIMED, } private SimpleObjectProperty<Mode> mode = new SimpleObjectProperty<>(Mode.OPEN_FOR_PLEDGES); private Mode priorMode; public ProjectActivity(ObservableList<Project> projects, Project project, ObservableMap<Project, LighthouseBackend.CheckStatus> statusMap) { // Don't try and access Main.backend here in case you race with startup. try { this.statusMap = statusMap; FXMLLoader loader = new FXMLLoader(getResource("activities/project.fxml"), I18nUtil.translations); loader.setRoot(this); loader.setController(this); loader.load(); description.setUrlOpener(url -> Main.instance.getHostServices().showDocument(url)); goalAmountFormatStr = goalAmountLabel.getText(); pledgesList.setCellFactory(pledgeListView -> new PledgeListCell()); this.project.addListener(x -> updateForProject()); projects.addListener((ListChangeListener<Project>) change -> { while (change.next()) { if (change.wasReplaced()) { if (getProject().equals(change.getRemoved().get(0))) setProject(change.getAddedSubList().get(0)); } } }); this.project.set(project); } catch (IOException e) { throw new RuntimeException(e); } } // Holds together various bindings so we can disconnect them when we switch projects. private class UIBindings { private final ObservableList<LHProtos.Pledge> sortedByTime; private final ConcatenatingList<PieChart.Data> slices; public UIBindings() { // Bind the project pledges from the backend to the UI components so they react appropriately. projectStates = Main.backend.mirrorProjectStates(AffinityExecutor.UI_THREAD); projectStates.addListener((javafx.beans.InvalidationListener) x -> { setModeFor(project.get(), pledgedValue.get()); }); // pledges = fakePledges(); pledges = Main.backend.mirrorOpenPledges(project.get(), AffinityExecutor.UI_THREAD); pledges.addListener((SetChangeListener<? super LHProtos.Pledge>) change -> { if (change.wasAdded()) checkForMyPledge(project.get()); }); final long goalAmount = project.get().getGoalAmount().value; // - Bind the amount pledged to the label. pledgedValue = LighthouseBackend.Companion.bindTotalPledgedProperty(pledges); raisedAmountLabel.textProperty().bind(createStringBinding(() -> Coin.valueOf(pledgedValue.get()).toPlainString(), pledgedValue)); numPledgersLabel.textProperty().bind(Bindings.size(pledges).asString()); StringExpression format = Bindings.format("%.0f%%", pledgedValue.divide(1.0 * goalAmount).multiply(100.0)); percentFundedLabel.textProperty().bind(format); // - Make the action button update when the amount pledged changes. isFullyFundedAndNotParticipating = pledgedValue.isEqualTo(project.get().getGoalAmount().longValue()).and( mode.isEqualTo(Mode.OPEN_FOR_PLEDGES) ); pledgedValue.addListener(o -> pledgedValueChanged(goalAmount, pledgedValue)); pledgedValueChanged(goalAmount, pledgedValue); actionButton.disableProperty().bind(isFullyFundedAndNotParticipating); // - Put pledges into the list view. ObservableList<LHProtos.Pledge> list1 = FXCollections.observableArrayList(); bindSetToList(pledges, list1); sortedByTime = new SortedList<>(list1, (o1, o2) -> -Long.compareUnsigned(o1.getPledgeDetails().getTimestamp(), o2.getPledgeDetails().getTimestamp())); bindContent(pledgesList.getItems(), sortedByTime); pledgesList.prefHeightProperty().bind(Bindings.size(sortedByTime).multiply(75).add(10)); // +10 for misc extra pixels for focus etc. // - Convert pledges into pie slices. MappedList<PieChart.Data, LHProtos.Pledge> pledgeSlices = new MappedList<>(sortedByTime, pledge -> new PieChart.Data("", pledge.getPledgeDetails().getTotalInputValue())); // - Stick an invisible padding slice on the end so we can see through the unpledged part. slices = new ConcatenatingList<>(pledgeSlices, singletonObservableList(emptySlice)); // - Connect to the chart widget. bindContent(pieChart.getData(), slices); } public void unbind() { numPledgersLabel.textProperty().unbind(); percentFundedLabel.textProperty().unbind(); unbindContent(pledgesList.getItems(), sortedByTime); unbindContent(pieChart.getData(), slices); } } public void onStart() { if (project.get() == null) return; // Make the info bar appear if there's an error checkStatus = valueAt(statusMap, project); checkStatus.addListener(o -> updateInfoBar()); // Don't let the user perform an action whilst loading or if there's an error, unless that action would // be revoke: users must be able to revoke even if the server is dead. actionButton.disableProperty().unbind(); actionButton.disableProperty().bind( isFullyFundedAndNotParticipating.or( checkStatus.isNotNull().and(mode.isNotEqualTo(Mode.PLEDGED)) ) ); boolean needBtn = !project.get().isServerAssisted(); importPledgeButton.setVisible(needBtn); importPledgeButton.setManaged(needBtn); updateInfoBar(); } public void onStop() { if (project.get() == null) return; if (notifyBarItem != null) { notifyBarItem.cancel(); notifyBarItem = null; } } private void updateForProject() { pieChart.getData().clear(); pledgesList.getItems().clear(); final Project p = project.get(); projectTitle.setText(p.getTitle()); goalAmountLabel.setText(String.format(goalAmountFormatStr, p.getGoalAmount().toPlainString())); description.setText(project.get().getMemo()); // Load and set up the cover image. Image img = new Image(p.getCoverImage().newInput()); if (img.getException() != null) Throwables.propagate(img.getException()); BackgroundSize cover = new BackgroundSize(BackgroundSize.AUTO, BackgroundSize.AUTO, false, false, false, true); BackgroundImage bimg = new BackgroundImage(img, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, cover); coverImage.setBackground(new Background(bimg)); // Configure the pie chart. emptySlice = new PieChart.Data("", 0); if (bindings != null) bindings.unbind(); bindings = new UIBindings(); // This must be done after the binding because otherwise it has no node in the scene graph yet. emptySlice.getNode().setOpacity(0.1); emptySlice.getNode().setVisible(true); checkForMyPledge(p); editButton.setVisible(Main.wallet.isProjectMine(p)); // If a cloned wallet double spends our pledge, the backend can notice this before the wallet does. // Because the decision on what the button action should be depends on whether the wallet thinks it's pledged, // we have to watch out for this and update the mode here. Main.wallet.addOnRevokeHandler(Platform::runLater, pledge -> { setModeFor(p, pledgedValue.get()); return Unit.INSTANCE; }); if (p.isServerAssisted()) { Platform.runLater(() -> { Main.instance.scene.getAccelerators().put(KeyCombination.keyCombination("Shortcut+R"), () -> Main.backend.refreshProjectStatusFromServer(p)); }); } applyCss(); layout(); } private void checkForMyPledge(Project p) { LHProtos.Pledge myPledge = Main.wallet.getPledgeFor(p); if (myPledge != null) myPledgeHash = LHUtils.hashFromPledge(myPledge); } private void updateInfoBar() { if (notifyBarItem != null) notifyBarItem.cancel(); final LighthouseBackend.CheckStatus status = checkStatus.get(); if (status != null && status.getError() != null) { String msg = status.getError().getLocalizedMessage(); if (status.getError() instanceof FileNotFoundException) msg = tr("Project is not on the server yet: email the project file to the operator"); else if (status.getError() instanceof Ex.InconsistentUTXOAnswers) msg = tr("Bitcoin P2P network returned inconsistent answers, please contact support"); else if (status.getError() instanceof TimeoutException) msg = tr("Server error: Timed out"); else //noinspection ConstantConditions if (msg == null) msg = tr("Internal error: ") + status.getError().getClass().getName(); else // TRANS: %s = error message msg = String.format(tr("Error: %s"), msg); notifyBarItem = Main.instance.notificationBar.displayNewItem(msg); } } private void pledgedValueChanged(long goalAmount, LongProperty pledgedValue) { // Take the max so if we end up with more pledges than the goal in serverless mode, the pie chart is always // full and doesn't go backwards due to a negative pie slice. emptySlice.setPieValue(Math.max(0, goalAmount - pledgedValue.get())); setModeFor(project.get(), pledgedValue.get()); } private void updateGUIForState() { coverImage.setEffect(null); switch (mode.get()) { case OPEN_FOR_PLEDGES: if (isFullyFundedAndNotParticipating.get()) { actionButton.setText(tr("Fully funded")); // Disable state is handled by binding. } else { actionButton.setText(tr("Pledge")); } break; case PLEDGED: actionButton.setText(tr("Revoke")); break; case CAN_CLAIM: actionButton.setText(tr("Claim")); break; case CLAIMED: actionButton.setText(tr("View claim transaction")); ColorAdjust effect = new ColorAdjust(); coverImage.setEffect(effect); if (priorMode == Mode.CLAIMED) { effect.setSaturation(-0.9); } else { Timeline timeline = new Timeline(new KeyFrame(GuiUtils.UI_ANIMATION_TIME.multiply(3), new KeyValue(effect.saturationProperty(), -0.9))); timeline.play(); } break; } } private void setModeFor(Project project, long value) { priorMode = mode.get(); Mode newMode = Mode.OPEN_FOR_PLEDGES; if (projectStates.get(project.getIDHash()).getState() == LighthouseBackend.ProjectState.CLAIMED) { newMode = Mode.CLAIMED; } else { if (Main.wallet.getPledgedAmountFor(project) > 0) newMode = Mode.PLEDGED; if (value >= project.getGoalAmount().value && Main.wallet.isProjectMine(project)) newMode = Mode.CAN_CLAIM; } log.info("Mode is {}", newMode); mode.set(newMode); if (priorMode == null) priorMode = newMode; updateGUIForState(); } private ObservableSet<LHProtos.Pledge> fakePledges() { ImmutableList.Builder<LHProtos.Pledge> list = ImmutableList.builder(); LHProtos.Pledge.Builder builder = LHProtos.Pledge.newBuilder(); builder.getPledgeDetailsBuilder().setProjectId("abc"); long now = Instant.now().getEpochSecond(); for (int i = 0; i < 1; i++) { builder.getPledgeDetailsBuilder().setTotalInputValue(Coin.CENT.value * 70); builder.getPledgeDetailsBuilder().setTimestamp(now++); builder.getPledgeDetailsBuilder().setContactAddress("pinkponies87@gmail.com"); builder.getPledgeDetailsBuilder().setMemo("Great idea! I'll have the t-shirt please!"); list.add(builder.build()); builder.getPledgeDetailsBuilder().setTotalInputValue(Coin.CENT.value * 30); builder.getPledgeDetailsBuilder().setTimestamp(now++); builder.getPledgeDetailsBuilder().setContactAddress("satoshin@gmx.com"); builder.getPledgeDetailsBuilder().setMemo("There’s always going to be one more thing to do."); list.add(builder.build()); builder.getPledgeDetailsBuilder().setTotalInputValue(Coin.CENT.value * 20); builder.getPledgeDetailsBuilder().setTimestamp(now++); builder.getPledgeDetailsBuilder().setContactAddress("bill.gates@microsoft.com"); builder.getPledgeDetailsBuilder().setMemo("Charity begins at home"); list.add(builder.build()); builder.getPledgeDetailsBuilder().setTotalInputValue(Coin.CENT.value * 10); builder.getPledgeDetailsBuilder().setTimestamp(now++); builder.getPledgeDetailsBuilder().setContactAddress("hearn@vinumeris.com"); builder.getPledgeDetailsBuilder().setMemo("My evil plan is working!!!1!"); list.add(builder.build()); } ObservableSet<LHProtos.Pledge> set = FXCollections.observableSet(new HashSet<>(list.build())); Main.instance.scene.getAccelerators().put(KeyCombination.keyCombination("Shortcut+P"), () -> { LHProtos.Pledge.Builder pledge = LHProtos.Pledge.newBuilder(); pledge.getPledgeDetailsBuilder().setProjectId("abc"); pledge.getPledgeDetailsBuilder().setTotalInputValue(Coin.CENT.value * 110); pledge.getPledgeDetailsBuilder().setTimestamp(Instant.now().getEpochSecond()); set.add(pledge.build()); }); return set; } @FXML private void actionClicked(ActionEvent event) { final Project p = project.get(); switch (mode.get()) { case OPEN_FOR_PLEDGES: if (Main.wallet.getBalance(Wallet.BalanceType.AVAILABLE_SPENDABLE).value == 0) Main.instance.mainWindow.tellUserToSendSomeMoney(); else makePledge(p); break; case PLEDGED: revokePledge(p); break; case CAN_CLAIM: claimPledges(p); break; case CLAIMED: viewClaim(p); break; default: throw new AssertionError(); // Unreachable. } } private void viewClaim(Project p) { LighthouseBackend.ProjectStateInfo info = projectStates.get(p.getIDHash()); checkState(info.getState() == LighthouseBackend.ProjectState.CLAIMED); String url = String.format(Main.params == TestNet3Params.get() ? BLOCK_EXPLORER_SITE_TESTNET : BLOCK_EXPLORER_SITE, info.getClaimedBy()); log.info("Opening {}", url); Main.instance.getHostServices().showDocument(url); } private void makePledge(Project p) { log.info("Invoking pledge screen"); PledgeWindow window = Main.instance.<PledgeWindow>overlayUI("subwindows/pledge.fxml", tr("Pledge")).controller; window.setProject(p); window.setLimits(p.getGoalAmount().subtract(Coin.valueOf(pledgedValue.get())), p.getMinPledgeAmount()); window.onSuccess = () -> { mode.set(Mode.PLEDGED); updateGUIForState(); }; } private void claimPledges(Project p) { log.info("Claim button clicked for {}", p); Main.OverlayUI<RevokeAndClaimWindow> overlay = RevokeAndClaimWindow.openForClaim(p, pledges); overlay.controller.onSuccess = () -> { mode.set(Mode.OPEN_FOR_PLEDGES); updateGUIForState(); }; } private void revokePledge(Project project) { log.info("Revoke button clicked: {}", project.getTitle()); LHProtos.Pledge pledge = Main.wallet.getPledgeFor(project); checkNotNull(pledge, "UI invariant violation"); // Otherwise our UI is really messed up. Main.OverlayUI<RevokeAndClaimWindow> overlay = RevokeAndClaimWindow.openForRevoke(pledge); overlay.controller.onSuccess = () -> { mode.set(Mode.OPEN_FOR_PLEDGES); updateGUIForState(); }; } public void setProject(Project project) { this.project.set(project); } public Project getProject() { return this.project.get(); } // TODO: Should we show revoked pledges crossed out? private class PledgeListCell extends ListCell<LHProtos.Pledge> { private Label status, name, memoSnippet, date; private Label viewMore; public PledgeListCell() { Pane pane; HBox hbox; VBox vbox = new VBox( (status = new Label()), (hbox = new HBox( (name = new Label()), (pane = new Pane()), (date = new Label()) )), (memoSnippet = new Label()), (viewMore = new Label(tr("View more"))) ); vbox.getStyleClass().add("pledge-cell"); status.getStyleClass().add("pledge-cell-status"); name.getStyleClass().add("pledge-cell-name"); HBox.setHgrow(pane, Priority.ALWAYS); vbox.setFillWidth(true); hbox.maxWidthProperty().bind(vbox.widthProperty()); date.getStyleClass().add("pledge-cell-date"); date.setMinWidth(USE_PREF_SIZE); // Date is shown in preference to contact if contact data is too long memoSnippet.getStyleClass().add("pledge-cell-memo"); memoSnippet.setWrapText(true); memoSnippet.maxWidthProperty().bind(vbox.widthProperty()); memoSnippet.setMaxHeight(100); viewMore.getStyleClass().add("hover-link"); viewMore.setOnMouseClicked(ev -> ShowPledgeWindow.open(project.get(), getItem())); viewMore.setAlignment(Pos.CENTER_RIGHT); viewMore.prefWidthProperty().bind(vbox.widthProperty()); vbox.setPrefHeight(0); vbox.setMaxHeight(USE_PREF_SIZE); setGraphic(vbox); } @Override protected void updateItem(LHProtos.Pledge pledge, boolean empty) { super.updateItem(pledge, empty); if (empty) { getGraphic().setVisible(false); setOnMouseClicked(null); return; } setOnMouseClicked(ev -> { if (ev.getClickCount() == 2) ShowPledgeWindow.open(project.get(), getItem()); }); getGraphic().setVisible(true); LHProtos.PledgeDetails details = pledge.getPledgeDetails(); String msg = Coin.valueOf(details.getTotalInputValue()).toFriendlyString(); if (LHUtils.hashFromPledge(pledge).equals(myPledgeHash)) msg += " (yours)"; status.setText(msg); name.setText(details.hasName() ? details.getName() : tr("Anonymous")); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); LocalDateTime time = LocalDateTime.ofEpochSecond(details.getTimestamp(), 0, ZoneOffset.UTC); date.setText(time.format(formatter)); memoSnippet.setText(details.getMemo()); } } @FXML public void edit(ActionEvent event) { log.info("Edit button clicked"); if (pledgedValue.get() > 0) { informationalAlert(tr("Unable to edit"), tr("You cannot edit a project that has already started gathering pledges, as otherwise existing " + "pledges could be invalidated and participants could get confused. If you would like to " + "change this project either create a new one, or request revocation of existing pledges.") ); return; } EditProjectWindow.openForEdit(project.get()); } @FXML public void onViewTechDetailsClicked(MouseEvent event) { log.info("View tech details of project clicked for {}", project.get().getTitle()); ProjectTechDetailsWindow.open(project.get()); } @FXML public void onCopyDescriptionClicked(MouseEvent event) { log.info("Copy description to clipboard clicked"); Clipboard clipboard = Clipboard.getSystemClipboard(); ClipboardContent content = new ClipboardContent(); content.putString(project.get().getMemo()); clipboard.setContent(content); GuiUtils.arrowBubbleToNode(copyDescriptionLink, tr("Description copied to clipboard")); } @FXML public void exportPledgesClicked(MouseEvent event) { Project project = this.project.get(); log.info("Export pledges clicked for {}", project.getTitle()); if (Main.wallet.isEncrypted()) { log.info("Wallet is encrypted, requesting password"); WalletPasswordController.requestPassword(key -> { // TODO: Should really throw something up on the screen here. Main.instance.scene.setCursor(Cursor.WAIT); project.getStatus(Main.wallet, key).handleAsync((status, ex) -> { Main.instance.scene.setCursor(Cursor.DEFAULT); if (ex != null) { log.error("Unable to fetch project status", ex); informationalAlert(tr("Unable to fetch email addresses"), // TRANS: %s = error message tr("Could not fetch project status from server: %s"), ex); } else { exportPledges(status.getPledgesList()); } return null; }, Platform::runLater); }); } else { exportPledges(pledgesList.getItems()); } } private void exportPledges(List<LHProtos.Pledge> pledges) { FileChooser chooser = new FileChooser(); chooser.setTitle(tr("Export pledges to CSV file")); chooser.setInitialFileName("pledges.csv"); GuiUtils.platformFiddleChooser(chooser); File file = chooser.showSaveDialog(Main.instance.mainStage); if (file == null) { log.info(" ... but user cancelled"); return; } log.info("Saving pledges as CSV to file {}", file); try (Writer writer = new OutputStreamWriter(Files.newOutputStream(file.toPath()), Charsets.UTF_8)) { writer.append(String.format("num_satoshis,time,name,email,message%n")); for (LHProtos.Pledge pledge : pledges) { String time = Instant.ofEpochSecond(pledge.getPledgeDetails().getTimestamp()).atZone(ZoneId.of("UTC")).format(DateTimeFormatter.RFC_1123_DATE_TIME).replace(",", ""); String memo = pledge.getPledgeDetails().getMemo().replace('\n', ' ').replace(",", ""); writer.append(String.format("%d,%s,%s,%s,%s%n", pledge.getPledgeDetails().getTotalInputValue(), time, pledge.getPledgeDetails().getName(), pledge.getPledgeDetails().getContactAddress(), memo)); } GuiUtils.informationalAlert(tr("Export succeeded"), tr("Pledges are stored in a CSV file, which can be loaded with any spreadsheet application. Amounts are specified in satoshis.")); } catch (IOException e) { log.error("Failed to write to csv file", e); GuiUtils.informationalAlert(tr("Export failed"), // TRANS: %s = error message tr("Lighthouse was unable to save pledge data to the selected file: %s"), e.getLocalizedMessage()); } } @FXML public void importPledgeClicked(ActionEvent event) { log.info("Import pledge clicked"); FileChooser chooser = new FileChooser(); chooser.setTitle(tr("Import pledge")); chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(tr("Pledge files"), "*" + LighthouseBackend.PLEDGE_FILE_EXTENSION)); GuiUtils.platformFiddleChooser(chooser); File file = chooser.showOpenDialog(Main.instance.mainStage); if (file == null) { log.info(".... but user cancelled"); return; } log.info("Importing pledge from {}", file); Main.backend.importPledgeFrom(file.toPath()).fail(ex -> { log.error("Importing pledge failed", ex); informationalAlert(tr("Could not import pledge"), ex.getMessage()); return Unit.INSTANCE; }); } }