diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..87b344a6 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,25 +1,38 @@ package com.example; +import com.example.util.EnvLoader; import javafx.application.Application; import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; +import java.io.IOException; + public class HelloFX extends Application { + /** + * Initializes the primary application window from the ChatView.fxml layout and displays it. + * + * @param stage the primary stage to initialize and show + * @throws IOException if the FXML resource cannot be loaded + */ @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"); + public void start(Stage stage) throws IOException { + FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("ChatView.fxml")); + Scene scene = new Scene(fxmlLoader.load()); + + stage.setTitle("Java25 Chat App"); stage.setScene(scene); stage.show(); } + /** + * Application entry point that loads environment configuration and launches the JavaFX application. + * + * @param args command-line arguments passed to the application + */ public static void main(String[] args) { + EnvLoader.load(); launch(); } - } \ No newline at end of file diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..fe58e68e 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,18 @@ package com.example; -/** - * Model layer: encapsulates application data and business logic. - */ public class HelloModel { + /** - * Returns a greeting based on the current Java and JavaFX versions. + * Builds a greeting string that includes the JavaFX and Java runtime versions. + * + *

The method reads the system properties "javafx.version" and "java.version" and inserts their + * values into the greeting text. + * + * @return the greeting in the form "Hello, JavaFX {javafxVersion}, running on Java {javaVersion}." */ public String getGreeting() { String javaVersion = System.getProperty("java.version"); String javafxVersion = System.getProperty("javafx.version"); return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/controller/ChatController.java b/src/main/java/com/example/controller/ChatController.java new file mode 100644 index 00000000..81fc85cf --- /dev/null +++ b/src/main/java/com/example/controller/ChatController.java @@ -0,0 +1,176 @@ +package com.example.controller; + +import com.example.model.ChatModel; +import com.example.model.NtfyMessage; +import javafx.fxml.FXML; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.concurrent.Task; +import javafx.geometry.Pos; +import javafx.stage.FileChooser; + +import java.io.File; + +public class ChatController { + + @FXML + private ListView messageListView; + + @FXML + private TextField messageInput; + + @FXML + private Label statusLabel; + + private ChatModel model; + + /** + * Initializes controller state and configures the message list view and its listeners. + * + * Instantiates the ChatModel, updates the status display to online, installs a custom cell + * factory to control presentation of message strings, and registers a listener on the model's + * message list that appends newly received NtfyMessage text (prefixed with an emoji) to the + * ListView and scrolls to the newest item. + */ + @FXML + public void initialize() { + this.model = new ChatModel(); + + updateStatusOnline(); + + messageListView.setCellFactory(listView -> new ListCell() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + setStyle(""); + } else { + setText(item); + setAlignment(Pos.CENTER_LEFT); + setStyle("-fx-background-color: rgba(255, 250, 240, 0.6); " + + "-fx-text-fill: #3d3d3d; " + + "-fx-padding: 14 18 14 18; " + + "-fx-border-color: rgba(214, 69, 69, 0.15); " + + "-fx-border-width: 0 0 0 3; " + + "-fx-font-size: 13px; " + + "-fx-background-radius: 0; " + + "-fx-border-radius: 0; " + + "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.05), 3, 0, 0, 2);"); + } + } + }); + + model.getMessages().addListener((javafx.collections.ListChangeListener.Change change) -> { + while (change.next()) { + if (change.wasAdded()) { + for (NtfyMessage msg : change.getAddedSubList()) { + String formatted = "🌸 " + msg.message(); + messageListView.getItems().add(formatted); + } + messageListView.scrollTo(messageListView.getItems().size() - 1); + } + } + }); + } + + /** + * Sends the current text from the message input through the model. + * + * If the input contains non-empty text, initiates a background send operation. + * On success the input is cleared and the status is updated to online. + * On failure an error message is written to standard error and the status is updated to offline. + */ + @FXML + private void handleSendButtonAction() { + String text = messageInput.getText(); + if (text != null && !text.trim().isEmpty()) { + Task task = new Task<>() { + @Override + protected Void call() throws Exception { + model.sendMessage(text.trim()); + return null; + } + }; + + task.setOnSucceeded(e -> { + messageInput.clear(); + updateStatusOnline(); + }); + + task.setOnFailed(e -> { + System.err.println("Send failed: " + task.getException().getMessage()); + updateStatusOffline(); + }); + + new Thread(task).start(); + } + } + + /** + * Opens a file chooser for the user to pick a file and sends the selected file via the model. + * + * If the user selects a file, the file is sent on a background thread; on success a confirmation + * message is printed to standard output and on failure an error message is printed to standard error. + * If the user cancels the dialog, no action is taken. + */ + @FXML + private void handleAttachFileAction() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select File"); + File file = fileChooser.showOpenDialog(messageInput.getScene().getWindow()); + + if (file != null) { + Task task = new Task<>() { + @Override + protected Void call() throws Exception { + model.sendFile(file); + return null; + } + }; + + task.setOnSucceeded(e -> { + System.out.println("βœ… File info sent: " + file.getName()); + }); + + task.setOnFailed(e -> { + System.err.println("❌ File send failed: " + task.getException().getMessage()); + }); + + new Thread(task).start(); + } + } + + /** + * Updates the status label to show an "online" state. + * + * If a status label is present, sets its text to "● online" and applies a red text color, + * 10px font size, and a subtle drop shadow; does nothing if the label is null. + */ + private void updateStatusOnline() { + if (statusLabel != null) { + statusLabel.setText("● online"); + statusLabel.setStyle("-fx-text-fill: #c93939; " + + "-fx-font-size: 10px; " + + "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);"); + } + } + + /** + * Update the status label to indicate the application is offline and apply muted styling. + * + * If the status label exists, sets its text to "● offline" and applies a muted brownish color, + * a small font size, and a subtle drop shadow. + */ + private void updateStatusOffline() { + if (statusLabel != null) { + statusLabel.setText("● offline"); + statusLabel.setStyle("-fx-text-fill: #6b5d54; " + + "-fx-font-size: 10px; " + + "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/model/ChatModel.java b/src/main/java/com/example/model/ChatModel.java new file mode 100644 index 00000000..3709058a --- /dev/null +++ b/src/main/java/com/example/model/ChatModel.java @@ -0,0 +1,114 @@ +package com.example.model; + +import com.example.network.ChatNetworkClient; +import com.example.network.NtfyHttpClient; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import java.io.File; + +public class ChatModel { + + private final ObservableList messages = FXCollections.observableArrayList(); + private final ChatNetworkClient networkClient; + private ChatNetworkClient.Subscription subscription; + private final String baseUrl; + private final String topic; + + /** + * Constructs a ChatModel, initializing the network client, selecting the NTFY base URL, + * setting the topic to "myChatTopic", and establishing the subscription. + * + *

The base URL is chosen in this order of precedence: the system property + * "NTFY_BASE_URL", the environment variable "NTFY_BASE_URL", and finally the default + * "http://localhost:8080". This constructor prints the chosen URL and calls {@code connect()} + * to begin receiving messages. + */ + public ChatModel() { + this.networkClient = new NtfyHttpClient(); + + String url = System.getProperty("NTFY_BASE_URL"); + if (url == null || url.isBlank()) { + url = System.getenv("NTFY_BASE_URL"); + } + if (url == null || url.isBlank()) { + url = "http://localhost:8080"; + } + + this.baseUrl = url; + this.topic = "myChatTopic"; + + System.out.println("Using NTFY URL: " + this.baseUrl); + connect(); + } + + /** + * Provides the observable list of chat messages. + * + * @return the observable list of NtfyMessage instances; changes to this list are observable and reflect the model's current messages + */ + public ObservableList getMessages() { + return messages; + } + + /** + * Establishes a subscription to the configured Ntfy topic and begins receiving messages. + * + * Incoming messages are appended to the model's observable messages list on the JavaFX + * application thread. Subscription errors are written to standard error. + */ + public void connect() { + this.subscription = networkClient.subscribe( + baseUrl, + topic, + msg -> Platform.runLater(() -> messages.add(msg)), + error -> System.err.println("Error: " + error.getMessage()) + ); + } + + /** + * Publish a text message to the configured chat topic. + * + * @param text the message content to send + * @throws Exception if sending the message fails + */ + public void sendMessage(String text) throws Exception { + NtfyMessage message = new NtfyMessage(topic, text); + networkClient.send(baseUrl, message); + } + + /** + * Sends the given file to the configured chat topic and posts a chat message describing the file. + * + * Validates that the file exists, uploads it via the network client, then sends a message containing + * the file name and a human-readable file size. + * + * @param file the file to send; must be non-null and exist on disk + * @throws IllegalArgumentException if {@code file} is null or does not exist + * @throws Exception if the upload or subsequent message send fails + */ + public void sendFile(File file) throws Exception { + if (file == null || !file.exists()) { + throw new IllegalArgumentException("File not found"); + } + + networkClient.sendFile(baseUrl, topic, file); + + String fileMessage = "πŸ“Ž " + file.getName() + " (" + formatFileSize(file.length()) + ")"; + sendMessage(fileMessage); + } + + /** + * Format a byte count into a human-readable string using B, KB, MB, or GB. + * + * @param size the size in bytes + * @return a formatted size string (e.g., "512 B", "1.5 KB", "2.0 MB", or "3.2 GB") + */ + private String formatFileSize(long size) { + if (size < 1024) return size + " B"; + if (size < 1024 * 1024) return String.format("%.1f KB", size / 1024.0); + if (size < 1024 * 1024 * 1024) return String.format("%.1f MB", size / (1024.0 * 1024)); + return String.format("%.1f GB", size / (1024.0 * 1024 * 1024)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/model/NtfyMessage.java b/src/main/java/com/example/model/NtfyMessage.java new file mode 100644 index 00000000..2d1c5cd8 --- /dev/null +++ b/src/main/java/com/example/model/NtfyMessage.java @@ -0,0 +1,35 @@ +package com.example.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessage( + String id, + Long time, + String event, + String topic, + String message, + String attachment // Nytt fΓ€lt fΓΆr filer +) { + /** + * Creates a NtfyMessage with the specified topic and message, using default values for id, time, event, and attachment. + * + * @param topic the topic for the message + * @param message the message body + */ + public NtfyMessage(String topic, String message) { + this(null, null, "message", topic, message, null); + } + + /** + * Creates a NtfyMessage for the given topic containing the provided message and attachment, + * with `id` and `time` set to null and `event` set to "message". + * + * @param topic the message topic + * @param message the message payload + * @param attachment optional attachment data (e.g., serialized file content or reference) + */ + public NtfyMessage(String topic, String message, String attachment) { + this(null, null, "message", topic, message, attachment); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/network/ChatNetworkClient.java b/src/main/java/com/example/network/ChatNetworkClient.java new file mode 100644 index 00000000..f9751927 --- /dev/null +++ b/src/main/java/com/example/network/ChatNetworkClient.java @@ -0,0 +1,49 @@ +package com.example.network; + +import com.example.model.NtfyMessage; +import java.io.File; +import java.io.IOException; +import java.util.function.Consumer; + +public interface ChatNetworkClient { + /** + * Sends an NtfyMessage to the service at the specified base URL. + * + * @param baseUrl the base URL of the ntfy service (e.g., "https://ntfy.example.com") + * @param message the message to deliver + * @throws IOException if a network or I/O error occurs while sending the message + * @throws InterruptedException if the calling thread is interrupted while sending + */ +void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException; + + /** + * Sends a file to the specified topic at the given base URL. + * + * @param baseUrl the server base URL to send the file to + * @param topic the destination topic or channel on the server + * @param file the file to upload; it must exist and be readable + * @throws IOException if an I/O error occurs while sending the file + * @throws InterruptedException if the operation is interrupted + */ +void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException; + + /** + * Subscribes to messages for a topic at the specified base URL. + * + * @param baseUrl the base URL of the server to connect to + * @param topic the topic to subscribe to + * @param onMessage callback invoked for each received {@link com.example.model.NtfyMessage} + * @param onError callback invoked with a {@link Throwable} if an error occurs while receiving messages + * @return an active {@link Subscription} whose {@code close()} method terminates the subscription + */ +Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError); + + interface Subscription { + /** + * Terminates the active subscription. + * + *

Closes any underlying resources and stops delivery of further messages for this subscription.

+ */ +void close(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/network/NtfyHttpClient.java b/src/main/java/com/example/network/NtfyHttpClient.java new file mode 100644 index 00000000..d70641d1 --- /dev/null +++ b/src/main/java/com/example/network/NtfyHttpClient.java @@ -0,0 +1,148 @@ +package com.example.network; + +import com.example.model.NtfyMessage; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +public class NtfyHttpClient implements ChatNetworkClient { + + private final HttpClient http; + private final ObjectMapper objectMapper; + + /** + * Creates a new NtfyHttpClient configured for HTTP requests and JSON (de)serialization. + * + * Initializes an internal HttpClient for network operations and a Jackson ObjectMapper + * for JSON serialization and deserialization. + */ + public NtfyHttpClient() { + this.http = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + /** + * Sends the given NtfyMessage to the specified base URL using an HTTP POST. + * + * The message is serialized to JSON and posted to the base URL (any trailing slash is removed). + * + * @param baseUrl the destination base URL for the POST request + * @param message the message to serialize and send + * @throws IOException if the request fails or the response status code is not 200 + * @throws InterruptedException if the thread is interrupted while waiting for the response + */ + @Override + public void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException { + String json = objectMapper.writeValueAsString(message); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ensureNoTrailingSlash(baseUrl))) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("Failed to send message. Status: " + response.statusCode()); + } + } + + /** + * Uploads the given file to the Ntfy server by sending its bytes with an HTTP PUT to {@code baseUrl}/{@code topic}. + * + * @param baseUrl the base URL of the Ntfy server (may include or omit a trailing slash) + * @param topic the topic path segment to upload the file to + * @param file the file to upload + * @throws IOException if the file does not exist or the server responds with a non-200 status (response body included in the message) + * @throws InterruptedException if the HTTP request is interrupted + */ + @Override + public void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException { + if (!file.exists()) { + throw new IOException("File not found: " + file.getName()); + } + + byte[] fileBytes = Files.readAllBytes(file.toPath()); + String url = ensureNoTrailingSlash(baseUrl) + "/" + topic; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Filename", file.getName()) + .PUT(HttpRequest.BodyPublishers.ofByteArray(fileBytes)) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + String errorBody = response.body(); + throw new IOException("Failed to send file. Status: " + response.statusCode() + ", Error: " + errorBody); + } + + System.out.println("βœ… File uploaded successfully: " + file.getName()); + } + + /** + * Subscribes to a topic's JSON event stream and dispatches incoming "message" events to a consumer. + * + * @param baseUrl the base URL of the ntfy server (may include scheme and host) + * @param topic the topic to subscribe to + * @param onMessage consumer invoked for each received `NtfyMessage` whose `event` equals `"message"` + * @param onError consumer invoked for any error encountered while receiving or parsing events + * @return a Subscription that cancels the underlying request and stops delivery when invoked + */ + @Override + public Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError) { + String url = ensureNoTrailingSlash(baseUrl) + "/" + topic + "/json"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + CompletableFuture future = http.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) + .thenAccept(response -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + NtfyMessage message = objectMapper.readValue(line.trim(), NtfyMessage.class); + if ("message".equals(message.event())) { + onMessage.accept(message); + } + } + } + } catch (Exception e) { + onError.accept(e); + } + }) + .exceptionally(error -> { + onError.accept(error); + return null; + }); + + return () -> future.cancel(true); + } + + /** + * Normalize a URL by removing a trailing slash if present. + * + * @param url the URL string to normalize; may end with a slash + * @return the URL without a trailing slash if one was present, otherwise the original URL + */ + private String ensureNoTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/util/EnvLoader.java b/src/main/java/com/example/util/EnvLoader.java new file mode 100644 index 00000000..45c67cb1 --- /dev/null +++ b/src/main/java/com/example/util/EnvLoader.java @@ -0,0 +1,48 @@ +package com.example.util; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class EnvLoader { + + /** + * Loads key=value pairs from a .env file in the current working directory into Java system properties. + * + * Each non-empty line that does not start with '#' and contains a single '=' is parsed; both key and + * value are trimmed before the key/value pair is stored with System.setProperty. + * If a .env file is not present, existing system properties are left unchanged. + */ + public static void load() { + Path envPath = Paths.get(".env"); + + if (!Files.exists(envPath)) { + System.out.println("No .env file found, using system environment variables"); + return; + } + + try (BufferedReader reader = new BufferedReader(new FileReader(envPath.toFile()))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + String[] parts = line.split("=", 2); + if (parts.length == 2) { + String key = parts[0].trim(); + String value = parts[1].trim(); + System.setProperty(key, value); + System.out.println("Loaded env: " + key + "=" + value); + } + } + } catch (IOException e) { + System.err.println("Failed to load .env file: " + e.getMessage()); + } + } +} \ No newline at end of file