Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4679f9f
Test raw client calls to ntfy.sh
fmazmz Nov 16, 2025
efce903
Add .env loader util
Nov 16, 2025
76fea66
feat(domain): implement NtfyMessage and enable JSON deserialization v…
Nov 16, 2025
e9a9a5f
feat(domain): map ntfy events into internal NtfyMessage, log events w…
Nov 16, 2025
1a1094b
feat(domain): add NtfyMessages to observable list in ChatModel
Nov 16, 2025
6aaef56
feat(client): abstract the HttpClient operations
fmazmz Nov 16, 2025
33f17eb
feature(client): update exception
fmazmz Nov 16, 2025
d91e839
feat(client): update send method to post synchronous request
fmazmz Nov 16, 2025
a730854
fix(domain): update UI through runOnFx to ensure thread safety
fmazmz Nov 16, 2025
69e40f6
fix(client): serialize msg to DTO before send
fmazmz Nov 16, 2025
9ab7852
fix(domain): update controller to deserialize and display the correct…
fmazmz Nov 16, 2025
8e718d1
fix(client): throw exceptions at client level
fmazmz Nov 16, 2025
cc3f95d
fix(client): log all received and appended events
fmazmz Nov 16, 2025
ed2f976
fix(client): remove reduntant Platform.runLater from sub handler
fmazmz Nov 16, 2025
c6476ce
feat(ui): move client operations to controller
fmazmz Nov 16, 2025
585f221
feat(ui): display message content instead of raw toString
fmazmz Nov 16, 2025
c0bfc48
feat(domain): implement builder pattern for NtfyMessage
fmazmz Nov 16, 2025
d7226af
feat(global): support title and tags
fmazmz Nov 16, 2025
becd5aa
feat(domain): split NtfyMessageDTOs into response and request records
fmazmz Nov 16, 2025
082373e
feat(ui): allow opening attachments through onClick
fmazmz Nov 16, 2025
14c7a72
feat(ui): update UI with stylesheet and improved UX
fmazmz Nov 16, 2025
17ad122
feat(global): support attachment uploads
fmazmz Nov 16, 2025
945a5ec
test: add simple model test cases
fmazmz Nov 16, 2025
32a53ad
fix: use proper URI building
fmazmz Nov 18, 2025
5e1358e
fix: validate attachment upload status
fmazmz Nov 18, 2025
c7c7211
fix: validate attachment upload status
fmazmz Nov 18, 2025
868b2ec
fix: validate message upload status
fmazmz Nov 18, 2025
504748d
fix: catch exception while opening file
fmazmz Nov 18, 2025
4879421
fix: validate ENV variables
fmazmz Nov 18, 2025
e2f6d60
fix: validate presence of .env file
fmazmz Nov 18, 2025
e930949
fix: pause thread before assertion in test case
fmazmz Nov 18, 2025
a45ea5c
remove redundant test
fmazmz Nov 18, 2025
c5d06b7
Update readme
fmazmz Nov 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NTFY_BASE_URL=https://ntfy.sh
NTFY_TOPIC= //Add your topic here
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
target/
/.idea/
.env
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@
<javafx.version>25</javafx.version>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down Expand Up @@ -45,6 +60,18 @@
<artifactId>javafx-fxml</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.testfx</groupId>
<artifactId>testfx-junit5</artifactId>
<version>4.0.17</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>${javafx.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
Expand All @@ -63,6 +90,14 @@
<noManPages>true</noManPages>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
224 changes: 216 additions & 8 deletions src/main/java/com/example/HelloController.java
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +35 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add null checks to prevent NPE from uninitialized fields.

The client, baseUrl, and topic fields are not initialized and have no null checks in onSend(). If a user triggers onSend() before setClient() is called, a NullPointerException will occur at line 124.

Consider adding validation:

 public void setClient(ChatNetworkClient client, String baseUrl, String topic) {
+    if (client == null || baseUrl == null || topic == null) {
+        throw new IllegalArgumentException("Client, baseUrl, and topic must not be null");
+    }
     this.client = client;
     this.baseUrl = baseUrl;
     this.topic = topic;
 }

And add a safety check in onSend():

 private void onSend() {
+    if (client == null) {
+        showStatus("Client not initialized");
+        return;
+    }
     String txt = messageInput.getText();

Also applies to: 51-55

🤖 Prompt for AI Agents
In src/main/java/com/example/HelloController.java around lines 31-34 (and
similarly lines 51-55), the fields client, baseUrl and topic (and selectedFile)
are left uninitialized and used in onSend causing possible NPEs; add validation
in their setters to reject or throw IllegalArgumentException on null/empty
values and/or initialize them with safe defaults, and modify onSend to perform
defensive null checks at the top (if client==null || baseUrl==null ||
topic==null) then log an error/notify the user and return early to avoid
proceeding; ensure any downstream use of selectedFile is guarded with a null
check as well.


@FXML
private Label messageLabel;

@FXML
private void initialize() {
if (messageLabel != null) {
messageLabel.setText(model.getGreeting());
private ListView<NtfyEventResponse> 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<String> 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();
Comment on lines +115 to +125
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Timestamp unit mismatch: milliseconds vs seconds.

Line 113 sets time to System.currentTimeMillis(), which returns milliseconds since epoch. However, formatTime() at line 63 uses Instant.ofEpochSecond(), expecting seconds. This mismatch will cause incorrect time display in the UI.

Fix the timestamp to use seconds:

 NtfyMessage msg = new NtfyMessage.Builder()
         .id(UUID.randomUUID().toString())
-        .time(System.currentTimeMillis())
+        .time(System.currentTimeMillis() / 1000)
         .event("message")
         .topic(topic)
         .message(txt)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
NtfyMessage msg = new NtfyMessage.Builder()
.id(UUID.randomUUID().toString())
.time(System.currentTimeMillis() / 1000)
.event("message")
.topic(topic)
.message(txt)
.title(title)
.tags(tags)
.attach(null)
.filename(null)
.build();
🤖 Prompt for AI Agents
In src/main/java/com/example/HelloController.java around lines 111 to 121, the
NtfyMessage.time is set with System.currentTimeMillis() (milliseconds) but
formatTime() expects seconds; change the timestamp to seconds (e.g., divide
milliseconds by 1000 or use an API returning epoch seconds such as
Instant.now().getEpochSecond()/TimeUnit conversion) so the stored time matches
Instant.ofEpochSecond() usage.


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<NtfyEventResponse> {
@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);
Comment on lines +168 to +171
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for image loading.

Loading an image directly from a URL on line 165 is synchronous and can fail if the URL is invalid or the network is unavailable. This could block the UI thread or cause rendering issues.

Consider adding error handling with a fallback:

-Image image = new Image(att.url(), 300, 0, true, true);
-ImageView imageView = new ImageView(image);
-container.getChildren().add(imageView);
+try {
+    Image image = new Image(att.url(), 300, 0, true, true, true); // backgroundLoading=true
+    if (!image.isError()) {
+        ImageView imageView = new ImageView(image);
+        container.getChildren().add(imageView);
+    } else {
+        Label errorLabel = new Label("Failed to load image: " + att.name());
+        errorLabel.getStyleClass().add("error-text");
+        container.getChildren().add(errorLabel);
+    }
+} catch (Exception e) {
+    Label errorLabel = new Label("Error loading image: " + e.getMessage());
+    errorLabel.getStyleClass().add("error-text");
+    container.getChildren().add(errorLabel);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
if (att.type() != null && att.type().startsWith("image")) {
try {
Image image = new Image(att.url(), 300, 0, true, true, true); // backgroundLoading=true
if (!image.isError()) {
ImageView imageView = new ImageView(image);
container.getChildren().add(imageView);
} else {
Label errorLabel = new Label("Failed to load image: " + att.name());
errorLabel.getStyleClass().add("error-text");
container.getChildren().add(errorLabel);
}
} catch (Exception e) {
Label errorLabel = new Label("Error loading image: " + e.getMessage());
errorLabel.getStyleClass().add("error-text");
container.getChildren().add(errorLabel);
}
}
🤖 Prompt for AI Agents
In src/main/java/com/example/HelloController.java around lines 164 to 167, add
robust error handling when creating/loading the Image from a remote URL: wrap
the image creation and view update so UI-thread work is performed via
Platform.runLater, construct the Image with background loading enabled, attach
listeners to image.errorProperty() (or image.exceptionProperty()) to detect load
failures, and on failure replace the ImageView with a safe fallback (a local
placeholder image or a simple Label) and log the error; ensure any exception
during URL creation is caught and handled so the UI is never blocked or left
without a visible fallback.

} 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;
}
}
}
44 changes: 39 additions & 5 deletions src/main/java/com/example/HelloFX.java
Original file line number Diff line number Diff line change
@@ -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);

}

}
Loading
Loading