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/javafx.iml b/javafx.iml new file mode 100644 index 00000000..219cba0f --- /dev/null +++ b/javafx.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index c40f667e..0037c686 100644 --- a/pom.xml +++ b/pom.xml @@ -2,6 +2,7 @@ + 4.0.0 com.example @@ -15,26 +16,32 @@ 3.27.6 5.20.0 25 + 5.3.1 + + org.junit.jupiter junit-jupiter ${junit.jupiter.version} test + org.assertj assertj-core ${assertj.core.version} test + org.mockito mockito-junit-jupiter ${mockito.version} test + org.openjfx javafx-controls @@ -45,7 +52,21 @@ javafx-fxml ${javafx.version} + + + org.json + json + 20240303 + + + + org.apache.httpcomponents.client5 + httpclient5 + ${httpclient.version} + + + @@ -55,7 +76,7 @@ com.example.HelloFX - + javafx true @@ -65,4 +86,5 @@ + diff --git a/src/main/java/com/example/ChatMessage.java b/src/main/java/com/example/ChatMessage.java new file mode 100644 index 00000000..6f0aa49a --- /dev/null +++ b/src/main/java/com/example/ChatMessage.java @@ -0,0 +1,34 @@ +package com.example; + +public class ChatMessage { + private final String id; + private final String username; + private final String message; + private final String timestamp; + private final String fileName; + private final String fileUrl; + private final String mimeType; + + public ChatMessage(String id, String username, String message, String timestamp, + String fileName, String fileUrl, String mimeType) { + this.id = id; + this.username = username; + this.message = message; + this.timestamp = timestamp; + this.fileName = fileName; + this.fileUrl = fileUrl; + this.mimeType = mimeType; + } + + public ChatMessage(String id, String username, String message, String timestamp) { + this(id, username, message, timestamp, null, null, null); + } + + public String getId() { return id; } + public String getUsername() { return username; } + public String getMessage() { return message; } + public String getTimestamp() { return timestamp; } + public String getFileName() { return fileName; } + public String getFileUrl() { return fileUrl; } + public String getMimeType() { return mimeType; } +} diff --git a/src/main/java/com/example/EnvLoader.java b/src/main/java/com/example/EnvLoader.java new file mode 100644 index 00000000..3d18f64b --- /dev/null +++ b/src/main/java/com/example/EnvLoader.java @@ -0,0 +1,32 @@ +package com.example; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class EnvLoader { + + private static Map env = new HashMap<>(); + + static { + try (BufferedReader reader = new BufferedReader(new FileReader(".env"))) { + 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) { + env.put(parts[0].trim(), parts[1].trim()); + } + } + } catch (IOException e) { + System.err.println("Warning: .env file not found or could not be read"); + } + } + + public static String get(String key) { + return env.get(key); + } +} diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..a48eaf29 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,120 @@ package com.example; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.fxml.Initializable; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import javafx.stage.FileChooser; -/** - * Controller layer: mediates between the view (FXML) and the model. - */ -public class HelloController { +import java.io.File; +import java.net.URL; +import java.time.format.DateTimeFormatter; +import java.util.ResourceBundle; +import java.util.function.Predicate; + +public class HelloController implements Initializable { + + @FXML private ListView chatList; + @FXML private TextField inputField; + @FXML private TextField usernameField; + @FXML private CheckBox hideMyMessagesCheck; private final HelloModel model = new HelloModel(); + private final ObservableList masterList = FXCollections.observableArrayList(); + private FilteredList filteredList; + + private String getCurrentUsername() { + String u = usernameField.getText(); + if (u == null) return "Anonymous"; + u = u.trim(); + return u.isEmpty() ? "Anonymous" : u; + } + + @Override + public void initialize(URL url, ResourceBundle rb) { + filteredList = new FilteredList<>(masterList, msg -> true); + chatList.setItems(filteredList); + + chatList.setCellFactory(list -> new ListCell<>() { + @Override + protected void updateItem(ChatMessage msg, boolean empty) { + super.updateItem(msg, empty); + if (empty || msg == null) { setGraphic(null); return; } + + Text user = new Text(msg.getUsername()); + user.setStyle("-fx-font-weight: bold;"); + Text time = new Text(" (" + msg.getTimestamp() + ")\n"); + time.setStyle("-fx-fill: gray; -fx-font-size: 12px;"); + + if (msg.getFileName() != null && msg.getFileUrl() != null) { + if (msg.getMimeType() != null && msg.getMimeType().startsWith("image/")) { + try { + ImageView imageView = new ImageView(new Image(msg.getFileUrl(), true)); + imageView.setFitWidth(200); + imageView.setPreserveRatio(true); + Text messageText = new Text(msg.getMessage() + "\n"); + messageText.setStyle("-fx-font-size: 14px;"); + setGraphic(new TextFlow(user, time, messageText, imageView)); + } catch (Exception e) { e.printStackTrace(); } + } else { + Hyperlink link = new Hyperlink(msg.getFileName()); + final String fileUrl = msg.getFileUrl(); + link.setOnAction(ev -> HelloFX.hostServices().showDocument(fileUrl)); + Text messageText = new Text(msg.getMessage() + "\n"); + messageText.setStyle("-fx-font-size: 14px;"); + setGraphic(new TextFlow(user, time, messageText, link)); + } + } else { + Text text = new Text(msg.getMessage()); + text.setStyle("-fx-font-size: 14px;"); + setGraphic(new TextFlow(user, time, text)); + } + } + }); + + hideMyMessagesCheck.selectedProperty().addListener((obs, oldVal, newVal) -> updateFilterPredicate()); + usernameField.textProperty().addListener((obs, oldVal, newVal) -> updateFilterPredicate()); + + model.loadHistory(msg -> Platform.runLater(() -> masterList.add(msg))); + model.listenForMessages(msg -> Platform.runLater(() -> masterList.add(msg))); + } + + private void updateFilterPredicate() { + final String current = getCurrentUsername(); + final boolean hideMine = hideMyMessagesCheck.isSelected(); + Predicate pred = msg -> !hideMine || !current.equals(msg.getUsername()); + filteredList.setPredicate(pred); + } @FXML - private Label messageLabel; + private void onSend() { + String user = getCurrentUsername(); + String msg = inputField.getText().trim(); + if (msg.isEmpty()) return; + inputField.clear(); + + new Thread(() -> { + try { model.sendMessage(user, msg); } + catch (Exception e) { e.printStackTrace(); } + }).start(); + } @FXML - private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); - } + private void onAttachFile() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select a file to send"); + File file = fileChooser.showOpenDialog(chatList.getScene().getWindow()); + if (file != null) new Thread(() -> model.sendFile(getCurrentUsername(), file)).start(); } } diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..316c72ac 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,25 +1,32 @@ package com.example; import javafx.application.Application; +import javafx.application.HostServices; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; public class HelloFX extends Application { + public static HostServices hostServices; + + public static HostServices hostServices() { + return hostServices; + } @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"); + hostServices = getHostServices(); + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/example/hello-view.fxml")); + Parent root = loader.load(); + Scene scene = new Scene(root, 720, 520); + scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm()); + stage.setTitle("JavaFX NTFY Chat — mats_notiser"); 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/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..67fa465d 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,262 @@ package com.example; -/** - * Model layer: encapsulates application data and business logic. - */ +import org.json.JSONObject; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + 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 String TOPIC_URL; + private final Set seenIds = Collections.synchronizedSet(new HashSet<>()); + + public HelloModel() { + TOPIC_URL = EnvLoader.get("NTFY_URL"); + if (TOPIC_URL == null || TOPIC_URL.isBlank()) { + throw new IllegalStateException("NTFY_URL not found in .env file"); + } + } + + public HelloModel(String topicUrl) { + if (topicUrl == null || topicUrl.isBlank()) { + throw new IllegalStateException("NTFY_URL not found in .env file"); + } + this.TOPIC_URL = topicUrl; + } + + public void sendMessage(String username, String message) throws IOException { + JSONObject json = new JSONObject(); + json.put("username", username == null || username.isBlank() ? "Anonymous" : username); + json.put("message", message == null ? "" : message); + json.put("time", Instant.now().getEpochSecond()); + sendJsonToNtfy(json); + } + + protected void sendFile(String username, File file) { + if (file == null || !file.exists()) return; + final String safeUsername = (username == null || username.isBlank()) ? "Anonymous" : username; + + new Thread(() -> { + HttpURLConnection conn = null; + try { + URL url = new URL(TOPIC_URL); + conn = (HttpURLConnection) url.openConnection(); + conn.setDoOutput(true); + conn.setRequestMethod("PUT"); + + String mimeType = Files.probeContentType(file.toPath()); + if (mimeType == null) mimeType = "application/octet-stream"; + + conn.setRequestProperty("Filename", file.getName()); + conn.setRequestProperty("Content-Type", mimeType); + conn.setRequestProperty("X-Hide", "true"); + conn.setFixedLengthStreamingMode(file.length()); + conn.connect(); + + try (OutputStream os = conn.getOutputStream()) { + Files.copy(file.toPath(), os); + os.flush(); + } + + int rc = conn.getResponseCode(); + InputStream respStream = rc >= 400 ? conn.getErrorStream() : conn.getInputStream(); + String responseJson = ""; + if (respStream != null) responseJson = new String(respStream.readAllBytes(), StandardCharsets.UTF_8); + System.out.println("PUT upload response code: " + rc); + if (!responseJson.isBlank()) System.out.println("PUT upload response body: " + responseJson); + + String fileUrl = null; + try { + if (!responseJson.isBlank()) { + JSONObject resp = new JSONObject(responseJson); + if (resp.has("attachment")) { + JSONObject attach = resp.getJSONObject("attachment"); + if (attach.has("url")) fileUrl = attach.getString("url"); + } else if (resp.has("url")) { + fileUrl = resp.getString("url"); + } + } + } catch (Exception ex) { + System.err.println("Failed to parse upload response JSON: " + ex.getMessage()); + } + + if (fileUrl == null && rc >= 200 && rc < 300) { + if (!TOPIC_URL.endsWith("/")) fileUrl = TOPIC_URL + "/" + file.getName(); + else fileUrl = TOPIC_URL + file.getName(); + } + + JSONObject msg = new JSONObject(); + msg.put("username", safeUsername); + msg.put("message", "Sent file: " + file.getName()); + msg.put("time", Instant.now().getEpochSecond()); + msg.put("fileName", file.getName()); + msg.put("fileUrl", fileUrl); + msg.put("mimeType", mimeType); + + sendJsonToNtfy(msg); + + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (conn != null) conn.disconnect(); + } + }).start(); + } + + protected void sendJsonToNtfy(JSONObject json) throws IOException { + byte[] out = json.toString().getBytes(StandardCharsets.UTF_8); + + URL url = new URL(TOPIC_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setFixedLengthStreamingMode(out.length); + conn.connect(); + + try (OutputStream os = conn.getOutputStream()) { + os.write(out); + os.flush(); + } + + int rc = conn.getResponseCode(); + System.out.println("sendJsonToNtfy() -> response: " + rc); + if (rc < 200 || rc >= 300) { + InputStream err = conn.getErrorStream(); + if (err != null) { + String body = new String(err.readAllBytes(), StandardCharsets.UTF_8); + System.err.println("ntfy error body: " + body); + } + } + + try (InputStream is = conn.getInputStream()) { if (is != null) is.readAllBytes(); } catch (IOException ignored) {} + conn.disconnect(); } + + public void loadHistory(Consumer callback) { + new Thread(() -> { + HttpURLConnection conn = null; + try { + URL url = new URL(TOPIC_URL + "/json?since=all"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.connect(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + + try { + JSONObject envelope = new JSONObject(line); + String id = envelope.optString("id", null); + if (id != null && !seenIds.add(id)) continue; + + String rawMsg = envelope.optString("message", "").trim(); + if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue; + + ChatMessage msg = parseEnvelopeToChatMessage(envelope); + if (msg != null) callback.accept(msg); + + } catch (Exception ex) { + ex.printStackTrace(); + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (conn != null) conn.disconnect(); + } + }).start(); + } + + + public void listenForMessages(Consumer callback) { + new Thread(() -> { + HttpURLConnection conn = null; + try { + URL url = new URL(TOPIC_URL + "/json"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.connect(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + + try { + JSONObject envelope = new JSONObject(line); + String id = envelope.optString("id", null); + if (id != null && !seenIds.add(id)) continue; + + String rawMsg = envelope.optString("message", "").trim(); + if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) continue; + + ChatMessage msg = parseEnvelopeToChatMessage(envelope); + if (msg != null) callback.accept(msg); + + } catch (Exception ex) { + ex.printStackTrace(); + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (conn != null) conn.disconnect(); + } + }, "ntfy-listener-thread").start(); + } + + + + protected ChatMessage parseEnvelopeToChatMessage(JSONObject envelope) { + String rawMsg = envelope.optString("message", null); + if (rawMsg == null) return null; + if (!rawMsg.startsWith("{") || !rawMsg.endsWith("}")) return null; + try { + String id = envelope.optString("id", null); + long envelopeTime = envelope.optLong("time", Instant.now().getEpochSecond()); + String timestamp = Instant.ofEpochSecond(envelopeTime) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + + String username = envelope.optString("username", "unknown"); + + if (envelope.has("message")) { + rawMsg = envelope.getString("message"); + JSONObject inner = null; + try { inner = new JSONObject(rawMsg); } catch (Exception ignored) {} + + if (inner != null && inner.length() > 0) { + username = inner.optString("username", username); + String messageText = inner.optString("message", ""); + String fileName = inner.optString("fileName", null); + String fileUrl = inner.optString("fileUrl", null); + String mimeType = inner.optString("mimeType", null); + + if (fileName != null || fileUrl != null) + return new ChatMessage(id, username, messageText, timestamp, fileName, fileUrl, mimeType); + else + return new ChatMessage(id, username, messageText, timestamp); + } else { + return new ChatMessage(id, username, rawMsg, timestamp); + } + } + } catch (Exception e) { e.printStackTrace(); } + return null; + } + } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..bef7b565 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,6 +1,8 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires org.json; + requires java.net.http; opens com.example to javafx.fxml; exports com.example; diff --git a/src/main/resources/background.png b/src/main/resources/background.png new file mode 100644 index 00000000..65e8e29a Binary files /dev/null and b/src/main/resources/background.png differ diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..e2edd693 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,53 @@ - - - - - - - + + + + + + + + + + + + + + + + + +