diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..1ba9c1af
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+NTFY_BASE_URL=https://ntfy.sh
+NTFY_TOPIC= //Add your topic here
\ No newline at end of file
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/README.md b/README.md
index 5fdc622f..6df13fda 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,20 @@ A JavaFX-based chat client using [ntfy](https://docs.ntfy.sh/) for backend messa
- Unit tests for `Model` class
- (Advanced) Send files via "Attach local file" option
-## 🚀 Run Instructions
-1. Set `JAVA_HOME` to JDK 25
+
+## Requirements
+
+- **Java**
+ - **Version**: `25`
+
+- **Maven Compiler Plugin**
+ - **Version**: `3.11.0`
+ - **Configuration**:
+ - **Release**: `25`
+
+## Usage
+1. Set `JAVA_HOME` to JDK 25.
+2. Create a **.env** file with the required variables. You can also clone and fill **.env.example** and rename it to `.env`.
2. Start with:
```bash
./mvnw clean javafx:run
diff --git a/pom.xml b/pom.xml
index c40f667e..177d3c50 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,6 +17,21 @@
25
+
+ org.slf4j
+ slf4j-api
+ 2.0.9
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.9
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.2
+
org.junit.jupiter
junit-jupiter
@@ -45,6 +60,18 @@
javafx-fxml
${javafx.version}
+
+ org.testfx
+ testfx-junit5
+ 4.0.17
+ test
+
+
+ org.openjfx
+ javafx-swing
+ ${javafx.version}
+ test
+
@@ -63,6 +90,14 @@
true
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 25
+
+
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..7474c4ff 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,230 @@
package com.example;
+import com.example.client.ChatNetworkClient;
+import com.example.domain.ChatModel;
+import com.example.domain.NtfyEventResponse;
+import com.example.domain.NtfyMessage;
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
+import javafx.scene.control.*;
import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.VBox;
+import javafx.stage.FileChooser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
-public class HelloController {
+import java.awt.*;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
- private final HelloModel model = new HelloModel();
+public class HelloController {
+ private static final Logger log = LoggerFactory.getLogger(HelloController.class);
+ private ChatNetworkClient client;
+ private String baseUrl;
+ private String topic;
+ private File selectedFile = null;
@FXML
private Label messageLabel;
@FXML
- private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
+ private ListView messagesList;
+
+ @FXML
+ private TextField messageInput;
+
+ @FXML
+ private TextField titleInput;
+
+ @FXML
+ private TextField tagsInput;
+
+ public void setClient(ChatNetworkClient client, String baseUrl, String topic) {
+ this.client = client;
+ this.baseUrl = baseUrl;
+ this.topic = topic;
+ }
+
+ public void setModel(ChatModel model) {
+ messagesList.setItems(model.getMessages());
+ messagesList.setCellFactory(list -> new MessageCell());
+ }
+
+ private static String formatTime(long epochSeconds) {
+ Instant instant = Instant.ofEpochSecond(epochSeconds);
+ LocalTime time = LocalTime.ofInstant(instant, ZoneId.systemDefault());
+ return time.toString();
+ }
+
+ private void showStatus(String text) {
+ messageLabel.setText(text);
+ Timeline t = new Timeline(new KeyFrame(javafx.util.Duration.seconds(3),
+ ev -> messageLabel.setText("")));
+ t.setCycleCount(1);
+ t.play();
+ }
+
+
+ @FXML
+ private void onPickAttachment() {
+ FileChooser chooser = new FileChooser();
+ chooser.setTitle("Select attachment");
+ File file = chooser.showOpenDialog(messageInput.getScene().getWindow());
+
+ if (file != null) {
+ selectedFile = file;
+ messageLabel.setText("Attachment selected: " + file.getName());
+ }
+ }
+
+ @FXML
+ private void onSend() {
+ String txt = messageInput.getText();
+
+ if ((txt == null || txt.isBlank()) && selectedFile == null) {
+ showStatus("Nothing to send");
+ return;
+ }
+
+ String title = titleInput.getText();
+ if (title != null && title.isBlank()) title = null;
+
+ String tagsRaw = tagsInput.getText();
+ List tags = null;
+
+ if (tagsRaw != null && !tagsRaw.isBlank()) {
+ tags = java.util.Arrays.stream(tagsRaw.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+ }
+
+ NtfyMessage msg = new NtfyMessage.Builder()
+ .id(UUID.randomUUID().toString())
+ .time(System.currentTimeMillis())
+ .event("message")
+ .topic(topic)
+ .message(txt)
+ .title(title)
+ .tags(tags)
+ .attach(null)
+ .filename(null)
+ .build();
+
+ try {
+ client.send(baseUrl, msg, selectedFile);
+ showStatus(selectedFile == null ? "Message sent" : "Attachment sent");
+ } catch (InterruptedException | IOException e) {
+ showStatus("Error sending: " + e.getMessage());
+ }
+
+ messageInput.clear();
+ titleInput.clear();
+ tagsInput.clear();
+ selectedFile = null;
+ }
+
+
+ private static final class MessageCell extends ListCell {
+ @Override
+ protected void updateItem(NtfyEventResponse msg, boolean empty) {
+ super.updateItem(msg, empty);
+
+ if (empty || msg == null) {
+ setText(null);
+ setGraphic(null);
+ return;
+ }
+
+ setText(null);
+
+ VBox container = new VBox();
+ container.setSpacing(6);
+ container.getStyleClass().add("message-bubble");
+
+ container.setStyle("-fx-alignment: CENTER_LEFT;");
+ if (msg.title() != null) {
+ Label titleLabel = new Label(msg.title());
+ titleLabel.getStyleClass().add("message-title");
+ container.getChildren().add(titleLabel);
+ }
+
+ if (msg.attachment() != null) {
+ NtfyEventResponse.Attachment att = msg.attachment();
+
+ if (att.type() != null && att.type().startsWith("image")) {
+ Image image = new Image(att.url(), 300, 0, true, true);
+ ImageView imageView = new ImageView(image);
+ container.getChildren().add(imageView);
+ } else {
+ Label fileLabel = getFileLabel(att);
+ container.getChildren().add(fileLabel);
+ }
+ }
+
+ if (msg.message() != null && !msg.message().isBlank()) {
+ Label messageLabel = new Label(msg.message());
+ messageLabel.setWrapText(true);
+ messageLabel.getStyleClass().add("message-text");
+ container.getChildren().add(messageLabel);
+ }
+
+ if (msg.tags() != null && !msg.tags().isEmpty()) {
+ Label tagsLabel = new Label(String.join(", ", msg.tags()));
+ tagsLabel.getStyleClass().add("message-tags");
+ container.getChildren().add(tagsLabel);
+ }
+
+ if (msg.time() != null) {
+ Label timeLabel = new Label(formatTime(msg.time()));
+ timeLabel.getStyleClass().add("message-time");
+ container.getChildren().add(timeLabel);
+ }
+
+ setGraphic(container);
+ }
+
+ // Helper method to allow user to open file
+ private static Label getFileLabel(NtfyEventResponse.Attachment att) {
+ Label fileLabel = new Label("Open file: " + (att.name() != null ? att.name() : att.url()));
+ fileLabel.setStyle("-fx-text-fill: #2c75ff; -fx-underline: true;");
+ fileLabel.setOnMouseClicked(ev -> {
+ try {
+ String url = att.url();
+
+ // method that works on linux as Desktop is not always supported and crashes application
+ if (System.getProperty("os.name").toLowerCase().contains("linux")) {
+ try {
+ new ProcessBuilder("xdg-open", url).start();
+ return;
+ }catch (IOException e) {
+ log.error("Error opening file: {}", url, e);
+ }
+ }
+
+ if (Desktop.isDesktopSupported()) {
+ Desktop.getDesktop().browse(new URI(url));
+ }
+
+ } catch (IOException | URISyntaxException ex) {
+ log.error("Failed to open attachment: {}", ex.getMessage());
+ log.error(Arrays.toString(ex.getStackTrace()));
+ }
+ });
+ return fileLabel;
}
}
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..6d9a8345 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,25 +1,59 @@
package com.example;
+import com.example.client.ChatNetworkClient;
+import com.example.client.NtfyHttpClient;
+import com.example.domain.ChatModel;
+import com.example.domain.NtfyMessage;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Objects;
+import java.util.Properties;
+
+import static com.example.utils.EnvLoader.loadEnv;
public class HelloFX extends Application {
+ private static final Logger log = LoggerFactory.getLogger("MAIN");
+ static final ChatModel model = new ChatModel();
@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);
- stage.setTitle("Hello MVC");
+ Properties env = loadEnv();
+ String baseUrl = env.getProperty("NTFY_BASE_URL", "https://ntfy.sh");
+ String topic = env.getProperty("NTFY_TOPIC");
+
+ if (topic == null || topic.isBlank()) {
+ throw new IllegalStateException("NTFY_TOPIC is not set");
+ }
+
+ FXMLLoader loader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
+ Parent root = loader.load();
+
+ HelloController controller = loader.getController();
+ controller.setModel(model);
+
+ ChatNetworkClient client = new NtfyHttpClient(model);
+ controller.setClient(client, baseUrl, topic);
+
+ client.subscribe(baseUrl, topic);
+
+ Scene scene = new Scene(root);
+ scene.getStylesheets().add(
+ Objects.requireNonNull(HelloFX.class.getResource("styles.css")).toExternalForm()
+ );
+
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
- launch();
+ launch(args);
+
}
}
\ No newline at end of file
diff --git a/src/main/java/com/example/client/ChatNetworkClient.java b/src/main/java/com/example/client/ChatNetworkClient.java
new file mode 100644
index 00000000..815418e4
--- /dev/null
+++ b/src/main/java/com/example/client/ChatNetworkClient.java
@@ -0,0 +1,17 @@
+package com.example.client;
+
+
+import com.example.domain.NtfyMessage;
+
+import java.io.File;
+import java.io.IOException;
+
+public interface ChatNetworkClient {
+ Subscription subscribe(String baseUrl, String topic);
+ void send(String baseUrl, NtfyMessage message, File file) throws IOException, InterruptedException;
+ interface Subscription extends AutoCloseable {
+ @Override
+ void close();
+ boolean isOpen();
+ }
+}
diff --git a/src/main/java/com/example/client/HttpClientProvider.java b/src/main/java/com/example/client/HttpClientProvider.java
new file mode 100644
index 00000000..a0e86184
--- /dev/null
+++ b/src/main/java/com/example/client/HttpClientProvider.java
@@ -0,0 +1,15 @@
+package com.example.client;
+
+import java.net.http.HttpClient;
+
+public final class HttpClientProvider {
+
+ private static final HttpClient INSTANCE = HttpClient.newHttpClient();
+
+ private HttpClientProvider() {
+ }
+
+ public static HttpClient get() {
+ return INSTANCE;
+ }
+}
diff --git a/src/main/java/com/example/client/NtfyHttpClient.java b/src/main/java/com/example/client/NtfyHttpClient.java
new file mode 100644
index 00000000..e0cb8f5f
--- /dev/null
+++ b/src/main/java/com/example/client/NtfyHttpClient.java
@@ -0,0 +1,131 @@
+package com.example.client;
+
+import com.example.domain.ChatModel;
+import com.example.domain.NtfyEventResponse;
+import com.example.domain.NtfyMessage;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+
+public record NtfyHttpClient(ChatModel model) implements ChatNetworkClient {
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+ private static final Logger log = LoggerFactory.getLogger("NtfyClient");
+
+ @Override
+ public Subscription subscribe(String baseUrl, String topic) {
+
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl).resolve(topic + "/json"))
+ .header("accept", "application/json")
+ .GET()
+ .build();
+
+ CompletableFuture>> future =
+ HttpClientProvider.get().sendAsync(req, HttpResponse.BodyHandlers.ofLines());
+
+ AtomicBoolean open = new AtomicBoolean(true);
+
+ future.thenAccept(response -> {
+ response.body().forEach(line -> {
+
+ log.debug("Raw event received: {}", line);
+
+ if (!open.get()) return;
+
+ try {
+ NtfyEventResponse msg = mapper.readValue(line, NtfyEventResponse.class);
+ if (msg.event().equals("message")) {
+ model.addMessage(msg);
+ log.info("Message added: {}", msg);
+ }
+ } catch (JsonProcessingException e) {
+ log.error("Error parsing event: {}", line, e);
+ }
+ });
+ }).exceptionally(ex -> {
+ log.error("Error while subscribing to topic {}", topic, ex);
+ open.set(false);
+ return null;
+ });
+ log.info("Subscribing to topic: {}", topic);
+
+ return new Subscription() {
+ @Override
+ public void close() {
+ open.set(false);
+ future.cancel(true);
+ }
+
+ @Override
+ public boolean isOpen() {
+ return open.get();
+ }
+ };
+ }
+
+ @Override
+ public void send(String baseUrl, NtfyMessage msg, File attachment) throws IOException, InterruptedException {
+
+ if (attachment != null) {
+ sendWithAttachment(baseUrl, msg, attachment);
+ return;
+ }
+
+ sendJsonOnly(baseUrl, msg);
+ }
+
+ private void sendJsonOnly(String baseUrl, NtfyMessage msg) throws IOException, InterruptedException {
+ String json = mapper.writeValueAsString(msg);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(json))
+ .build();
+
+ var response = HttpClientProvider.get().send(request, HttpResponse.BodyHandlers.ofString());
+ int statusCode = response.statusCode();
+
+ if (statusCode >= 200 && statusCode < 300) {
+ log.debug("Sent message payload: {}", json);
+ log.info("Message sent");
+ }
+ log.error("Failed to send message payload: {}", json);
+ throw new IOException("Failed to send message payload: " + statusCode);
+ }
+
+ private void sendWithAttachment(String baseUrl, NtfyMessage msg, File file)
+ throws IOException, InterruptedException {
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl).resolve((msg.topic())))
+ .header("Filename", file.getName())
+ .PUT(HttpRequest.BodyPublishers.ofFile(file.toPath()))
+ .build();
+
+
+ var response = HttpClientProvider.get().send(request, HttpResponse.BodyHandlers.ofString());
+
+ int statusCode = response.statusCode();
+ if (200 <= statusCode && statusCode < 300) {
+ log.debug("Attachment sent: {}", statusCode);
+ log.info("status: {}", statusCode);
+ }
+ log.error("Failed to send attachment: {}", statusCode);
+ throw new IOException("Failed to send attachment: " + statusCode);
+
+ }
+
+
+}
diff --git a/src/main/java/com/example/domain/ChatModel.java b/src/main/java/com/example/domain/ChatModel.java
new file mode 100644
index 00000000..3524a777
--- /dev/null
+++ b/src/main/java/com/example/domain/ChatModel.java
@@ -0,0 +1,36 @@
+package com.example.domain;
+
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+
+public class ChatModel {
+ private final ObservableList messages = FXCollections.observableArrayList();
+
+
+ public void addMessage(NtfyEventResponse msg) {
+ runOnFx(() -> messages.add(msg));
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ private static void runOnFx(Runnable task) {
+ try {
+ if (Platform.isFxApplicationThread()) {
+ task.run();
+ } else if (!Platform.isImplicitExit()) {
+ Platform.runLater(task);
+ } else {
+ // execute test case immediately
+ task.run();
+ }
+ } catch (IllegalStateException notInitialized) {
+ task.run();
+ }
+ }
+
+
+}
diff --git a/src/main/java/com/example/domain/NtfyEventResponse.java b/src/main/java/com/example/domain/NtfyEventResponse.java
new file mode 100644
index 00000000..603f2a8d
--- /dev/null
+++ b/src/main/java/com/example/domain/NtfyEventResponse.java
@@ -0,0 +1,26 @@
+package com.example.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyEventResponse(
+ String id,
+ Long time,
+ String event,
+ String topic,
+ String message,
+ String title,
+ List tags,
+ Attachment attachment
+) {
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Attachment(
+ String name,
+ String type,
+ Long size,
+ Long expires,
+ String url
+ ) {}
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/domain/NtfyMessage.java b/src/main/java/com/example/domain/NtfyMessage.java
new file mode 100644
index 00000000..d838d5fc
--- /dev/null
+++ b/src/main/java/com/example/domain/NtfyMessage.java
@@ -0,0 +1,72 @@
+package com.example.domain;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessage(
+ String id,
+ Long time,
+ String event,
+ String topic,
+ String message,
+ String title,
+ List tags,
+ String attach,
+ String filename
+) {
+ public static class Builder {
+ private String id;
+ private Long time;
+ private String event;
+ private String topic;
+ private String message;
+ private String title;
+ private List tags;
+ private String attach;
+ private String filename;
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder time(long time) {
+ this.time = time;
+ return this;
+ }
+
+ public Builder event(String event) {
+ this.event = event;
+ return this;
+ }
+
+ public Builder topic(String topic) {
+ this.topic = topic;
+ return this;
+ }
+
+ public Builder message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public Builder title(String title) {
+ this.title = title;
+ return this;
+ }
+
+ public Builder tags(List tags) {
+ this.tags = tags;
+ return this;
+ }
+
+ public Builder attach(String attach) { this.attach = attach; return this; }
+ public Builder filename(String filename) { this.filename = filename; return this; }
+
+ public NtfyMessage build() {
+ return new NtfyMessage(id, time, event, topic, message, title, tags, attach, filename);
+ }
+ }
+}
diff --git a/src/main/java/com/example/utils/EnvLoader.java b/src/main/java/com/example/utils/EnvLoader.java
new file mode 100644
index 00000000..73ad87be
--- /dev/null
+++ b/src/main/java/com/example/utils/EnvLoader.java
@@ -0,0 +1,27 @@
+package com.example.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Properties;
+
+public class EnvLoader {
+ private static final Logger log = LoggerFactory.getLogger(EnvLoader.class);
+
+ public static Properties loadEnv() {
+ Properties props = new Properties();
+
+ try (FileInputStream fis = new FileInputStream(".env")) {
+ props.load(fis);
+ } catch (FileNotFoundException e) {
+ log.error("Could not load .env file", e);
+ } catch (IOException e) {
+ log.error("Failed to load env file: ", e);
+ }
+
+ return props;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..0c455103 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,7 +1,16 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires java.net.http;
+ requires com.fasterxml.jackson.annotation;
+ requires com.fasterxml.jackson.databind;
+ requires java.logging;
+ requires org.slf4j;
+ requires java.desktop;
opens com.example to javafx.fxml;
+ opens com.example.domain to com.fasterxml.jackson.databind;
exports com.example;
+ exports com.example.domain;
+ exports com.example.client;
}
\ No newline at end of file
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..b080e6d3 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,43 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/example/styles.css b/src/main/resources/com/example/styles.css
new file mode 100644
index 00000000..5c592f9d
--- /dev/null
+++ b/src/main/resources/com/example/styles.css
@@ -0,0 +1,86 @@
+/* Global dark background */
+.root {
+ -fx-background-color: #1b1b1d;
+}
+
+/* ListView container */
+.list-view {
+ -fx-background-color: #1b1b1d;
+ -fx-padding: 10;
+}
+
+/* Remove default selection white flash */
+.list-cell:filled:selected,
+.list-cell:filled:selected:focused {
+ -fx-background-color: transparent;
+}
+
+/* Remove default hover flash */
+.list-cell:hover {
+ -fx-background-color: transparent;
+}
+
+/* Base cell styling */
+.list-cell {
+ -fx-background-color: transparent;
+ -fx-padding: 6 0 6 0;
+}
+
+/* Dark message bubble */
+.message-bubble {
+ -fx-background-color: #2a2a2d;
+ -fx-background-radius: 10;
+ -fx-padding: 10 14 10 14;
+ -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.30), 10, 0, 0, 2);
+}
+
+/* Hover effect for message bubble */
+.list-cell:hover .message-bubble {
+ -fx-background-color: #353538;
+ -fx-effect: dropshadow(gaussian, rgba(255,255,255,0.05), 16, 0, 0, 1);
+}
+
+/* Title text */
+.message-title {
+ -fx-font-size: 13px;
+ -fx-font-weight: bold;
+ -fx-text-fill: #e8e8e8;
+}
+
+/* Message text */
+.message-text {
+ -fx-font-size: 14px;
+ -fx-text-fill: #f5f5f5;
+}
+
+/* Tags text */
+.message-tags {
+ -fx-font-size: 11px;
+ -fx-text-fill: #6aa0ff;
+}
+
+/* Timestamp */
+.message-time {
+ -fx-font-size: 10px;
+ -fx-text-fill: #a0a0a0;
+}
+
+/* Inputs styling */
+.text-field {
+ -fx-background-color: #2a2a2d;
+ -fx-text-fill: #f5f5f5;
+ -fx-background-radius: 6;
+ -fx-border-radius: 6;
+ -fx-prompt-text-fill: #777777;
+}
+
+/* Send button */
+.button {
+ -fx-background-color: #3d66ff;
+ -fx-text-fill: white;
+ -fx-background-radius: 6;
+}
+
+.button:hover {
+ -fx-background-color: #5176ff;
+}
diff --git a/src/test/java/com/example/ChatModelTest.java b/src/test/java/com/example/ChatModelTest.java
new file mode 100644
index 00000000..776f2f77
--- /dev/null
+++ b/src/test/java/com/example/ChatModelTest.java
@@ -0,0 +1,43 @@
+package com.example;
+
+import com.example.TestFxInitializer;
+import com.example.domain.ChatModel;
+import com.example.domain.NtfyEventResponse;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ChatModelTest extends TestFxInitializer {
+
+ @Test
+ void addMessageShouldAppend() {
+ ChatModel model = new ChatModel();
+
+ NtfyEventResponse msg = new NtfyEventResponse(
+ "1", 100L, "message", "topic", "Hello", null, null, null
+ );
+
+ model.addMessage(msg);
+
+ assertEquals(1, model.getMessages().size());
+ assertEquals(msg, model.getMessages().get(0));
+ }
+
+ @Test
+ void addMessageFromBackgroundThreadShouldWork() throws InterruptedException {
+ ChatModel model = new ChatModel();
+
+ NtfyEventResponse msg = new NtfyEventResponse(
+ "2", 200L, "message", "topic", "Background", null, null, null
+ );
+
+ Thread t = new Thread(() -> model.addMessage(msg));
+ t.start();
+ t.join();
+
+ // Allow queue to be processed before proceeding to assert
+ Thread.sleep(1000);
+
+ assertEquals(1, model.getMessages().size());
+ }
+}
diff --git a/src/test/java/com/example/TestFxInitializer.java b/src/test/java/com/example/TestFxInitializer.java
new file mode 100644
index 00000000..38ca8fc1
--- /dev/null
+++ b/src/test/java/com/example/TestFxInitializer.java
@@ -0,0 +1,21 @@
+package com.example;
+
+import javafx.application.Platform;
+import org.junit.jupiter.api.BeforeAll;
+
+public class TestFxInitializer {
+
+ private static boolean initialized = false;
+
+ @BeforeAll
+ static void initFx() {
+ if (!initialized) {
+ try {
+ Platform.startup(() -> {});
+ } catch (IllegalStateException ignored) {
+ // FX already started
+ }
+ initialized = true;
+ }
+ }
+}