diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index 091af372..817d8f81 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,9 +1,21 @@ package com.example; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.geometry.Insets; import javafx.scene.control.*; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; +import javafx.stage.FileChooser; + +import java.awt.*; +import java.io.File; +import java.net.URI; /** * Controller layer: mediates between the view (FXML) and the model. @@ -12,6 +24,7 @@ public class HelloController { private final HelloModel model = new HelloModel(new NtfyConnectionImpl()); public ListView messageView; + private boolean lastActionWasFile = false; @FXML private Label messageLabel; @@ -19,54 +32,156 @@ public class HelloController { @FXML private TextField messageField; + @FXML + private void attachFile() { + FileChooser chooser = new FileChooser(); + chooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Images", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.txt") + ); + + File file = chooser.showOpenDialog(messageField.getScene().getWindow()); + if (file != null) { + lastActionWasFile = true; + model.sendFile(file); + } + } + @FXML private void initialize() { + if (messageLabel != null) { messageLabel.setText(model.getGreeting()); } + messageView.setItems(model.getMessages()); + // quickfix :D prevent listview cell selection which forces a re-render and + // causes image messages to flicker or disappear when clicking or scrolling. + messageView.setSelectionModel(new NoSelectionModel<>()); + + messageView.setCellFactory(list -> new ListCell<>() { - private final Label messageLabel = new Label(); - private final HBox bubble = new HBox(messageLabel); + + private final Label textLabel = new Label(); + private final HBox textBubble = new HBox(textLabel); + { - bubble.setPadding(new Insets(5, 10, 5, 10)); - bubble.setMaxWidth(200); - messageLabel.setWrapText(true); - bubble.getStyleClass().add("chat-bubble"); + textBubble.setPadding(new Insets(5, 10, 5, 10)); + textBubble.setMaxWidth(200); + textLabel.setWrapText(true); + textBubble.getStyleClass().add("chat-bubble"); } @Override protected void updateItem(NtfyMessageDto item, boolean empty) { super.updateItem(item, empty); + if (empty || item == null) { + setText(null); setGraphic(null); - } else { - // Format tid + text - java.time.LocalTime time = java.time.Instant.ofEpochSecond(item.time()) - .atZone(java.time.ZoneId.systemDefault()) - .toLocalTime(); - String formattedTime = time.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")); - - messageLabel.setText(formattedTime + "\n" + item.message()); - setGraphic(bubble); + return; + } + + if (item.attachment() != null && + item.attachment().type() != null && + item.attachment().type().startsWith("image") && + item.attachment().url() != null) { + + try { + Image image = new Image(item.attachment().url(), 200, 0, true, true); + ImageView imageView = new ImageView(image); + imageView.setPreserveRatio(true); + + HBox imageBubble = new HBox(imageView); + imageBubble.setPadding(new Insets(5, 10, 5, 10)); + imageBubble.setMaxWidth(200); + imageBubble.getStyleClass().add("chat-bubble"); + + setText(null); + setGraphic(imageBubble); + } catch (Exception e) { + Label err = new Label("[Image failed to load]"); + HBox bubble = new HBox(err); + bubble.setPadding(new Insets(5, 10, 5, 10)); + bubble.getStyleClass().add("chat-bubble"); + setGraphic(bubble); + } + return; + } + + if (item.attachment() != null && item.attachment().url() != null) { + + String fileName = item.attachment().name(); + String fileUrl = item.attachment().url(); + + Label icon = new Label("📄"); + icon.setStyle("-fx-font-size: 20px;"); + + Label fileLabel = new Label(fileName); + fileLabel.setStyle("-fx-text-fill: white; -fx-font-size: 14px;"); + + Button openBtn = new Button("Open file"); + openBtn.setOnAction(e -> { + System.out.println("Opening: " + fileUrl); + new Thread(() -> { + try { + URI uri = new URI(fileUrl); + Desktop.getDesktop().browse(uri); + } catch (Exception ex) { + ex.printStackTrace(); + } + }).start(); + }); + + + HBox fileBox = new HBox(10, icon, fileLabel, openBtn); + fileBox.setPadding(new Insets(5, 10, 5, 10)); + fileBox.setMaxWidth(200); + fileBox.getStyleClass().add("chat-bubble"); + + setText(null); + setGraphic(fileBox); + return; } + + + java.time.LocalTime time = java.time.Instant.ofEpochSecond(item.time()) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalTime(); + String formattedTime = time.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")); + + Label msgLabel = new Label(formattedTime + "\n" + item.message()); + msgLabel.setWrapText(true); + msgLabel.setMaxWidth(250); + + HBox msgBubble = new HBox(msgLabel); + msgBubble.setPadding(new Insets(5, 10, 5, 10)); + msgBubble.setMaxWidth(300); + msgBubble.getStyleClass().add("chat-bubble"); + + setText(null); + setGraphic(msgBubble); } + }); model.messageToSendProperty().bind(messageField.textProperty()); - } + public void sendMessage(ActionEvent actionEvent) { String message = messageField.getText(); - if(message == null || message.isBlank()){ + if(!lastActionWasFile && (message == null || message.isBlank())){ showTemporaryAlert("You must write something before sending!"); return; } - model.sendMessage(); + if (message != null && !message.isBlank()) { + model.sendMessage(); + } + messageField.clear(); + lastActionWasFile = false; } private void showTemporaryAlert(String alertMessage) { @@ -83,4 +198,38 @@ private void showTemporaryAlert(String alertMessage) { } catch (InterruptedException e) {} }).start(); } + + private static class NoSelectionModel extends MultipleSelectionModel { + + /* + Quickfix to prevent the ListView from selecting cells when the user clicks on them. + Otherwise it triggers full re render of the listcells when which causes message bubbles + or images to flicker or dissapears on click or scroll + */ + + @Override + public ObservableList getSelectedIndices() { + return FXCollections.emptyObservableList(); + } + + @Override + public ObservableList getSelectedItems() { + return FXCollections.emptyObservableList(); + } + + @Override public void selectIndices(int index, int... indices) { } + @Override public void selectAll() { } + @Override public void clearAndSelect(int index) { } + @Override public void select(int index) { } + @Override public void select(T obj) { } + @Override public void clearSelection(int index) { } + @Override public void clearSelection() { } + @Override public boolean isSelected(int index) { return false; } + @Override public boolean isEmpty() { return true; } + @Override public void selectPrevious() { } + @Override public void selectNext() { } + @Override public void selectFirst() { } + @Override public void selectLast() { } + } } + diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index e5328798..b3692844 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -5,6 +5,8 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import java.io.File; + /** * Model layer: encapsulates application data and business logic. @@ -52,4 +54,8 @@ public void sendMessage() { public void receiveMessage() { connection.receive(m->Platform.runLater(()->messages.add(m))); } + + public void sendFile(File file) { + connection.sendFile(file); + } } diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java index c239f3ab..fefcce7b 100644 --- a/src/main/java/com/example/NtfyConnection.java +++ b/src/main/java/com/example/NtfyConnection.java @@ -4,6 +4,6 @@ public interface NtfyConnection { public boolean send(String message); - + boolean sendFile(java.io.File file); public void receive(Consumer messageHandler); } diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java index b89635d5..77bbc928 100644 --- a/src/main/java/com/example/NtfyConnectionImpl.java +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -1,6 +1,8 @@ package com.example; import io.github.cdimascio.dotenv.Dotenv; import tools.jackson.databind.ObjectMapper; + +import java.io.File; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -28,7 +30,7 @@ public NtfyConnectionImpl(String hostName){ public boolean send(String message) { HttpRequest httpRequest = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofString(message)) - .uri(URI.create(hostName + "/mytopic")) + .uri(URI.create(hostName + "/adam")) .build(); try { // TODO: handle long blocking send requests to not freeze the JavaFX thread @@ -46,17 +48,53 @@ public boolean send(String message) { @Override public void receive(Consumer messageHandler) { + String startId = "8TuugOLkvDz1"; // just to make the app wont load 10000000 messages HttpRequest httpRequest = HttpRequest.newBuilder() .GET() - .uri(URI.create(hostName + "/mytopic/json?since=wBuD2KGEaAe0")) + .uri(URI.create(hostName + "/adam/json?since=3hPbr2dcIUiU")) .build(); + http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(response -> response.body() - .map(s-> - mapper.readValue(s, NtfyMessageDto.class)) - .filter(message -> message.event().equals("message")) - .peek(System.out::println) - .forEach(messageHandler)); + .peek(s -> System.out.println(s)) + .map(s -> { + try { + return mapper.readValue(s, NtfyMessageDto.class); + } catch (Exception e) { + System.out.println("JSON parse fail: " + s); + return null; + } + }) + .filter(msg -> msg != null) + .filter(msg -> "message".equals(msg.event())) + .forEach(messageHandler) + ); + } + + @Override + public boolean sendFile(File file){ + try { + String mime = java.nio.file.Files.probeContentType(file.toPath()); + + if(mime == null) mime = "application/octet-stream"; + + long size = file.length(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/adam")) + .header("Filename", file.getName()) + .header("Content-Type", mime) + .PUT(HttpRequest.BodyPublishers.ofFile(file.toPath())) + .build(); + + http.sendAsync(request, HttpResponse.BodyHandlers.discarding()); + return true; + + } catch (Exception e) { + e.printStackTrace(); + return false; + } } } diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java index ba65d213..7296acf1 100644 --- a/src/main/java/com/example/NtfyMessageDto.java +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -3,4 +3,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) -public record NtfyMessageDto(String id, long time, String event, String topic, String message) {} +public record NtfyMessageDto( + String id, + long time, + long expires, + String event, + String topic, + String message, + Attachment attachment +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Attachment( + String name, + String type, + String url, + long expires, + long size + ){} + +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 89e041cb..1ddf4364 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -5,6 +5,7 @@ requires java.net.http; requires tools.jackson.databind; requires javafx.graphics; + requires java.desktop; opens com.example to javafx.fxml; exports com.example; diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 5a268db5..6250395f 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -16,6 +16,8 @@ +