diff --git a/.gitignore b/.gitignore
index 6ac465db..5a54815d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
/.idea/
+.env
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index c40f667e..deb79eb1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,21 @@
javafx-fxml
${javafx.version}
+
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+ tools.jackson.core
+ jackson-databind
+ 3.0.1
+
+
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..ea0630b5 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,147 @@
package com.example;
+import javafx.application.Platform;
+import javafx.collections.ListChangeListener;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
-import javafx.scene.control.Label;
+import javafx.scene.Node;
+import javafx.scene.control.*;
+import javafx.scene.input.DragEvent;
+import javafx.scene.input.Dragboard;
+import javafx.scene.input.TransferMode;
+import javafx.scene.layout.HBox;
+import javafx.stage.FileChooser;
+
+import javax.swing.event.HyperlinkListener;
+import javax.tools.Tool;
+import java.awt.Desktop;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
public class HelloController {
- private final HelloModel model = new HelloModel();
+ private final HelloModel model = new HelloModel(new NtfyConnectionImpl());
+
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
+
+ @FXML private ListView messageView;
+ @FXML private TextArea messageInput;
+
+ @FXML private void initialize() {
+ messageView.setItems(model.getMessages());
+ messageView.setCellFactory(lv -> new ListCell<>() {
+ @Override
+ protected void updateItem(NtfyMessageDto item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ setGraphic(null);
+ return;
+ }
+ String text = item.message();
+ var att = item.attachment();
+
+ if (att != null && att.url() != null && !att.url().isBlank()) {
+ Label msg = new Label(text != null ? text + " ": "");
+ msg.setWrapText(true);
+
+ String linkText = att.name() != null && !att.name().isBlank()
+ ? att.name()
+ : "Attachment";
+ Hyperlink link = new Hyperlink(linkText);
+ if (att.size() > 0) {
+ link.setTooltip(new Tooltip(humanSize(att.size()) + (att.type() != null ? " - " + att.type() : "")));
+ }
+ link.setOnAction(e -> openInBrowser(att.url()));
- @FXML
- private Label messageLabel;
+ HBox row = new HBox(8.0, (Node) msg, (Node) link);
+ row.setFillHeight(true);
+
+ setText(null);
+ setGraphic(row);
+ } else {
+ setText(text != null ? text : "");
+ setGraphic(null);
+ }
+ }
+ });
+
+ model.getMessages().addListener((ListChangeListener)
+ c -> Platform.runLater(() -> {
+ if (!messageView.getItems(). isEmpty()) {
+ messageView.scrollTo(messageView.getItems().size() - 1);
+ }
+ })
+ );
+ messageView.setOnDragOver(this::handleDragOver);
+ messageView.setOnDragDropped(this::handleDragDropped);
+ model.loadInitialMessagesAsync();
+ }
+
+ @FXML public void sendFile(ActionEvent actionEvent) throws FileNotFoundException {
+ FileChooser chooser = new FileChooser();
+ chooser.setTitle("Välj fil att skicka");
+ File file = chooser.showOpenDialog(messageView.getScene().getWindow());
+ if (file != null) {
+ Path path = file.toPath();
+ model.sendFile(path);
+ }
+ }
- @FXML
- private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
+ public void sendMessage(ActionEvent actionEvent) {
+ String text = messageInput != null ? messageInput.getText() : "";
+ if (text != null && !text.isBlank() && model.sendMessage(text)) {
+ messageInput.clear();
}
}
+
+ private void handleDragOver(DragEvent e) {
+ Dragboard db = e.getDragboard();
+ if (db.hasFiles()) {
+ e.acceptTransferModes(TransferMode.COPY);
+ }
+ e.consume();
+ }
+
+ private void handleDragDropped(DragEvent e) {
+ Dragboard db = e.getDragboard();
+ boolean success = false;
+ if (db.hasFiles()) {
+ List files = db.getFiles();
+ for (File f : files) {
+ try {
+ model.sendFile(f.toPath());
+ } catch (FileNotFoundException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+ success = true;
+ }
+ e.setDropCompleted(success);
+ e.consume();
+ }
+
+ private void openInBrowser(String url) {
+ try {
+ if (Desktop.isDesktopSupported()) {
+ Desktop.getDesktop().browse(URI.create(url));
+ } else {
+ // fallback: ingen Desktop, gör inget (kan ersättas med HostServices)
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ private static String humanSize(long bytes) {
+ // kort & enkel
+ String[] units = {"B","KB","MB","GB","TB"};
+ double v = bytes;
+ int i = 0;
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+ return String.format("%.1f %s", v, units[i]);
+ }
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..f37f5cb9 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -10,6 +10,7 @@ public class HelloFX extends Application {
@Override
public void start(Stage stage) throws Exception {
+
FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
Parent root = fxmlLoader.load();
Scene scene = new Scene(root, 640, 480);
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..c1f6f240 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,15 +1,65 @@
package com.example;
-/**
- * Model layer: encapsulates application data and business logic.
- */
+import javafx.application.Platform;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import java.io.FileNotFoundException;
+import java.nio.file.Path;
+import java.util.concurrent.CompletableFuture;
+
public class HelloModel {
- /**
- * Returns a greeting based on the current Java and JavaFX versions.
- */
- public String getGreeting() {
- String javaVersion = System.getProperty("java.version");
- String javafxVersion = System.getProperty("javafx.version");
- return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
+
+ private final NtfyConnection connection;
+ private final ObservableList messages = FXCollections.observableArrayList();
+ //private final StringProperty messageToSend = new SimpleStringProperty();
+
+ public HelloModel(NtfyConnection connection) {
+ this.connection = connection;
}
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ /*public String getMessageToSend() {
+ return messageToSend.get();
+ }*/
+
+ public void loadInitialMessagesAsync() {
+ CompletableFuture
+ .supplyAsync(connection::fetchHistory)
+ .thenAccept(list -> Platform.runLater(() -> {
+ messages.setAll(list);
+ subscribeLive(); // start streaming after history
+ }))
+ .exceptionally(ex -> null);
+ }
+
+ private void subscribeLive() {
+ connection.receive(m -> Platform.runLater(() -> messages.add(m)));
+ }
+
+ /*public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }*/
+
+ public boolean sendMessage(String text) {
+ return connection.send(text);
+ }
+
+ public boolean sendFile(Path path) throws FileNotFoundException {
+ return connection.sendFile(path);
+ }
+
+ public void receiveMessage() {
+ connection.receive(m -> Platform.runLater(() -> messages.add(m)));
+ }
+
+
}
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..cf3a5918
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,17 @@
+package com.example;
+
+import java.io.FileNotFoundException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.function.Consumer;
+
+public interface NtfyConnection {
+
+ boolean send(String message);
+
+ void receive(Consumer messageHandler);
+
+ List fetchHistory();
+
+ boolean sendFile(Path path) throws FileNotFoundException;
+}
diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java
new file mode 100644
index 00000000..f95d408a
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,117 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+import tools.jackson.databind.ObjectMapper;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public class NtfyConnectionImpl implements NtfyConnection {
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private final String hostName;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ public NtfyConnectionImpl() {
+ Dotenv dotenv = Dotenv.load();
+ hostName = Objects.requireNonNull(dotenv.get("HOST_NAME"));
+ }
+
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ }
+
+ @Override
+ public boolean send(String message) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .header("Content-Type", "text/plain; charset=utf-8")
+ .uri(URI.create(hostName + "/mytopic"))
+ .build();
+
+ try {
+ http.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+ return true;
+ } catch (IOException e) {
+ System.out.println("Error sending message");
+ } catch (InterruptedException e) {
+ System.out.println("Interrupted sending message");
+ Thread.currentThread().interrupt();
+ }
+ return false;
+ }
+
+ public boolean sendFile(Path file) throws FileNotFoundException {
+ String filename = file.getFileName().toString();
+
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .PUT(HttpRequest.BodyPublishers.ofFile(file))
+ .header("Filename", filename)
+ .uri(URI.create(hostName + "/mytopic"))
+ .build();
+
+ try {
+ http.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+ return true;
+ } catch (IOException e) {
+ System.out.println("Error sending file");
+ } catch (InterruptedException e) {
+ System.out.println("Interrupted sending file");
+ Thread.currentThread().interrupt();
+ }
+ return false;
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/mytopic/json"))
+ .build();
+
+ http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
+ .thenAccept(response ->
+ response.body()
+ .map(this::tryParse)
+ .filter(m -> "message".equals(m.event()))
+ .forEach(messageHandler));
+ }
+
+ @Override
+ public List fetchHistory() {
+ HttpRequest request = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/mytopic/json?poll=1&since=all"))
+ .build();
+ List list = new ArrayList<>();
+ try {
+ HttpResponse> response =
+ http.send(request, HttpResponse.BodyHandlers.ofLines());
+ response.body()
+ .map(this::tryParse)
+ .filter(Objects::nonNull)
+ .filter(m -> "message".equals(m.event()))
+ .forEach(list::add);
+ } catch (IOException | InterruptedException e) {
+ if (e instanceof InterruptedException) Thread.currentThread().interrupt();
+ }
+ return list;
+ }
+
+ private NtfyMessageDto tryParse(String line) {
+ try {
+ return mapper.readValue(line, NtfyMessageDto.class);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..746c26ae
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,22 @@
+package com.example;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessageDto(
+ String id,
+ long time,
+ String event,
+ String topic,
+ String message,
+ Attachment attachment
+) {
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Attachment(
+ String name,
+ String url,
+ String type,
+ Long size,
+ Long expires
+ ) {}
+}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..04f3f874 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,13 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires io.github.cdimascio.dotenv.java;
+ requires java.net.http;
+ requires java.sql;
+ requires tools.jackson.databind;
+ requires java.desktop;
+ requires javafx.graphics;
+ requires java.compiler;
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 20a7dc82..e3523ec6 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,127 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/example/images/live.png b/src/main/resources/com/example/images/live.png
new file mode 100644
index 00000000..66c4c09a
Binary files /dev/null and b/src/main/resources/com/example/images/live.png differ
diff --git a/src/main/resources/com/example/images/msn.png b/src/main/resources/com/example/images/msn.png
new file mode 100644
index 00000000..94235057
Binary files /dev/null and b/src/main/resources/com/example/images/msn.png differ
diff --git a/src/main/resources/com/example/images/profile.jpg b/src/main/resources/com/example/images/profile.jpg
new file mode 100644
index 00000000..1df4168f
Binary files /dev/null and b/src/main/resources/com/example/images/profile.jpg differ
diff --git a/src/main/resources/com/example/messenger.css b/src/main/resources/com/example/messenger.css
new file mode 100644
index 00000000..aac6b994
--- /dev/null
+++ b/src/main/resources/com/example/messenger.css
@@ -0,0 +1,36 @@
+.root { -fx-font-family: "Segoe UI", Arial, sans-serif; -fx-font-size: 12px; }
+
+.tool-bar { -fx-background-color: linear-gradient(#e6f0fb, #d3e6fb); }
+.tb-icon { -fx-min-width: 28; -fx-min-height: 28; }
+
+.chat-title { -fx-font-size: 14px; -fx-font-weight: bold; }
+.chat-subtitle { -fx-text-fill: #5a6a7a; }
+
+.nick { -fx-text-fill: #0047ab; -fx-font-weight: bold; }
+.nick.self { -fx-text-fill: #a00000; }
+
+.bubble-left {
+ -fx-background-color: #ffffff;
+ -fx-background-radius: 10;
+ -fx-padding: 6 10 6 10;
+ -fx-border-color: #cfd8e3;
+ -fx-border-radius: 10;
+}
+.bubble-right {
+ -fx-background-color: #e8f3ff;
+ -fx-background-radius: 10;
+ -fx-padding: 6 10 6 10;
+ -fx-border-color: #b9d4fb;
+ -fx-border-radius: 10;
+}
+
+.fmt { -fx-font-weight: bold; -fx-min-width: 32; }
+.emoji { -fx-min-width: 32; }
+.hint { -fx-text-fill: #6b7280; }
+
+.side-name { -fx-font-weight: bold; }
+.side-status { -fx-text-fill: #22aa22; }
+.side-heading { -fx-font-weight: bold; -fx-text-fill: #3b4a5a; }
+
+.status-text { -fx-text-fill: #4b5563; }
+.status-ok { -fx-text-fill: #16a34a; }
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..3984d400
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,40 @@
+package com.example;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import org.junit.jupiter.api.Test;
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+class HelloModelTest {
+
+ @Test
+ void sendMessageCallsConnectionWithMessageToSend() {
+ // Arrange, Given
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("Hello World");
+ // Act, When
+ model.sendMessage();
+ // Assert, Then
+ assertThat(spy .message).isEqualTo("Hello World");
+ }
+
+ @Test
+ void sendMessageToFakeServer(WireMockRuntimeInfo wmRuntimeInfo) {
+ var connection = new NtfyConnectionImpl("http://localhost:" + wmRuntimeInfo.getHttpPort());
+ var model = new HelloModel(connection);
+ model.setMessageToSend("Hello World");
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ model.sendMessage();
+
+ // Verify call made to server
+ verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(containing("Hello World")));
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionSpy.java b/src/test/java/com/example/NtfyConnectionSpy.java
new file mode 100644
index 00000000..7d3e2b7d
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,25 @@
+package com.example;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+
+public class NtfyConnectionSpy implements NtfyConnection {
+
+ String message;
+
+ @Override
+ public boolean send(String message) {
+ this.message = message;
+ return true;
+ }
+
+ @Override
+ public void receive(Consumer messageHandler) {
+ }
+
+ @Override
+ public List fetchHistory() {
+ return Collections.emptyList();
+ }
+}